commit bba4bb40c854b477e46f49a014d355e9f7b442c3 Author: chuan Date: Tue Nov 11 01:56:44 2025 +0800 feat: init diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 00000000..ffe730b7 --- /dev/null +++ b/.codespellrc @@ -0,0 +1,7 @@ +[codespell] +# Ref: https://github.com/codespell-project/codespell#using-a-config-file +skip = .git*,*.svg,i18n,*-lock.yaml,*.css,.codespellrc,migrations,*.js,*.map,*.mjs +check-hidden = true +# ignore all CamelCase and camelCase +ignore-regex = \b[A-Za-z][a-z]+[A-Z][a-zA-Z]+\b +ignore-words-list = tread diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..fe11e95b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,69 @@ +.git +*.pyc +.env +venv +.venv +node_modules/ +**/node_modules/ +npm-debug.log +.next/ +**/.next/ +.turbo/ +**/.turbo/ +build/ +**/build/ +out/ +**/out/ +dist/ +**/dist/ +# Logs +npm-debug.log* +pnpm-debug.log* +.pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS junk +.DS_Store +Thumbs.db + +# Editor settings +.vscode +.idea + +# Coverage and test output +coverage/ +**/coverage/ +*.lcov +.junit/ +test-results/ + +# Caches and build artifacts +.cache/ +**/.cache/ +storybook-static/ +*storybook.log +*.tsbuildinfo + +# Local env and secrets +.env.local +.env.development.local +.env.test.local +.env.production.local +.secrets +tmp/ +temp/ + +# Database/cache dumps +*.rdb +*.rdb.gz + +# Misc +*.pem +*.key + +# React Router - https://github.com/remix-run/react-router-templates/blob/dc79b1a065f59f3bfd840d4ef75cc27689b611e6/default/.dockerignore +.react-router/ +build/ +node_modules/ +README.md \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..90efa8b4 --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# Database Settings +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_DB="plane" +PGDATA="/var/lib/postgresql/data" + +# Redis Settings +REDIS_HOST="plane-redis" +REDIS_PORT="6379" + +# RabbitMQ Settings +RABBITMQ_HOST="plane-mq" +RABBITMQ_PORT="5672" +RABBITMQ_USER="plane" +RABBITMQ_PASSWORD="plane" +RABBITMQ_VHOST="plane" + +LISTEN_HTTP_PORT=80 +LISTEN_HTTPS_PORT=443 + +# AWS Settings +AWS_REGION="" +AWS_ACCESS_KEY_ID="access-key" +AWS_SECRET_ACCESS_KEY="secret-key" +AWS_S3_ENDPOINT_URL="http://plane-minio:9000" +# Changing this requires change in the proxy config for uploads if using minio setup +AWS_S3_BUCKET_NAME="uploads" +# Maximum file upload limit +FILE_SIZE_LIMIT=5242880 + +# GPT settings +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated + +# Settings related to Docker +DOCKERIZED=1 # deprecated + +# set to 1 If using the pre-configured minio setup +USE_MINIO=1 + +# If SSL Cert to be generated, set CERT_EMAIl="email " +CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory +TRUSTED_PROXIES=0.0.0.0/0 +SITE_ADDRESS=:80 +CERT_EMAIL= + +# For DNS Challenge based certificate generation, set the CERT_ACME_DNS, CERT_EMAIL +# CERT_ACME_DNS="acme_dns " +CERT_ACME_DNS= + +# Force HTTPS for handling SSL Termination +MINIO_ENDPOINT_SSL=0 + +# API key rate limit +API_KEY_RATE_LIMIT="60/minute" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..526c8a38 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml new file mode 100644 index 00000000..ec037692 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -0,0 +1,73 @@ +name: Bug report +description: Create a bug report to help us improve Plane +title: "[bug]: " +labels: [🐛bug] +assignees: [vihar, pushya22] +body: +- type: markdown + attributes: + value: | + Thank you for taking the time to fill out this bug report. +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Current behavior + description: A concise description of what you're experiencing and what you expect + placeholder: | + When I do , happens and I see the error message attached below: + ```...``` + What I expect is + validations: + required: true +- type: textarea + attributes: + label: Steps to reproduce + description: Add steps to reproduce this behaviour, include console or network logs and screenshots + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true +- type: dropdown + id: env + attributes: + label: Environment + options: + - Production + - Deploy preview + validations: + required: true +- type: dropdown + id: browser + attributes: + label: Browser + options: + - Google Chrome + - Mozilla Firefox + - Safari + - Other +- type: dropdown + id: variant + attributes: + label: Variant + options: + - Cloud + - Self-hosted + - Local + validations: + required: true +- type: input + id: version + attributes: + label: Version + placeholder: v0.17.0-dev + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/--feature-request.yaml b/.github/ISSUE_TEMPLATE/--feature-request.yaml new file mode 100644 index 00000000..390c95aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest a feature to improve Plane +title: "[feature]: " +labels: [✨feature] +assignees: [vihar, pushya22] +body: +- type: markdown + attributes: + value: | + Thank you for taking the time to request a feature for Plane +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue related to this feature request already exists + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Summary + description: One paragraph description of the feature + validations: + required: true +- type: textarea + attributes: + label: Why should this be worked on? + description: A concise description of the problems or use cases for this feature request + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 00000000..29c26783 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,6 @@ +contact_links: + - name: Help and support + about: Reach out to us on our Discord server or GitHub discussions. + - name: Dedicated support + url: mailto:support@plane.so + about: Write to us if you'd like dedicated support using Plane diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..fa445360 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +### Description + + +### Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] Feature (non-breaking change which adds functionality) +- [ ] Improvement (change that would cause existing functionality to not work as expected) +- [ ] Code refactoring +- [ ] Performance improvements +- [ ] Documentation update + +### Screenshots and Media (if applicable) + + +### Test Scenarios + + +### References + \ No newline at end of file diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml new file mode 100644 index 00000000..087a012d --- /dev/null +++ b/.github/workflows/build-branch.yml @@ -0,0 +1,409 @@ +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 + aio_build: + description: "Build for AIO docker image" + required: false + default: false + type: boolean + push: + branches: + - preview + - canary + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + TARGET_BRANCH: ${{ github.ref_name }} + ARM64_BUILD: ${{ github.event.inputs.arm64 }} + BUILD_TYPE: ${{ github.event.inputs.build_type }} + RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }} + IS_PRERELEASE: ${{ github.event.inputs.isPrerelease }} + AIO_BUILD: ${{ github.event.inputs.aio_build }} + +jobs: + branch_build_setup: + name: Build Setup + runs-on: ubuntu-22.04 + outputs: + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} + gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} + gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} + gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} + + 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 }} + dh_img_aio: ${{ steps.set_env_variables.outputs.DH_IMG_AIO }} + + 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 }} + aio_build: ${{ steps.set_env_variables.outputs.AIO_BUILD }} + + steps: + - id: set_env_variables + name: Set Environment Variables + run: | + 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 + echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT + else + echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT + fi + 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 "DH_IMG_AIO=plane-aio-community" >> $GITHUB_OUTPUT + + echo "BUILD_TYPE=${{env.BUILD_TYPE}}" >> $GITHUB_OUTPUT + BUILD_RELEASE=false + BUILD_PRERELEASE=false + RELVERSION="latest" + + BUILD_AIO=${{ env.AIO_BUILD }} + + 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 + + BUILD_AIO=true + fi + + echo "BUILD_RELEASE=${BUILD_RELEASE}" >> $GITHUB_OUTPUT + echo "BUILD_PRERELEASE=${BUILD_PRERELEASE}" >> $GITHUB_OUTPUT + echo "RELEASE_VERSION=${RELVERSION}" >> $GITHUB_OUTPUT + echo "AIO_BUILD=${BUILD_AIO}" >> $GITHUB_OUTPUT + + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + + branch_build_push_admin: + name: Build-Push Admin Docker Image + runs-on: ubuntu-22.04 + needs: [branch_build_setup] + steps: + - name: Admin Build and Push + uses: makeplane/actions/build-push@v1.0.0 + 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 }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }} + build-context: . + dockerfile-path: ./apps/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: + name: Build-Push Web Docker Image + runs-on: ubuntu-22.04 + needs: [branch_build_setup] + steps: + - name: Web Build and Push + uses: makeplane/actions/build-push@v1.0.0 + 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 }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }} + build-context: . + dockerfile-path: ./apps/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: + name: Build-Push Space Docker Image + runs-on: ubuntu-22.04 + needs: [branch_build_setup] + steps: + - name: Space Build and Push + uses: makeplane/actions/build-push@v1.0.0 + 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 }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }} + build-context: . + dockerfile-path: ./apps/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: + name: Build-Push Live Collaboration Docker Image + runs-on: ubuntu-22.04 + needs: [branch_build_setup] + steps: + - name: Live Build and Push + uses: makeplane/actions/build-push@v1.0.0 + 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 }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }} + build-context: . + dockerfile-path: ./apps/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_api: + name: Build-Push API Server Docker Image + runs-on: ubuntu-22.04 + needs: [branch_build_setup] + steps: + - name: Backend Build and Push + uses: makeplane/actions/build-push@v1.0.0 + 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 }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }} + build-context: ./apps/api + dockerfile-path: ./apps/api/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: + name: Build-Push Proxy Docker Image + runs-on: ubuntu-22.04 + needs: [branch_build_setup] + steps: + - name: Proxy Build and Push + uses: makeplane/actions/build-push@v1.0.0 + 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 }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }} + build-context: ./apps/proxy + dockerfile-path: ./apps/proxy/Dockerfile.ce + 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_aio: + if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }} + name: Build-Push AIO Docker Image + runs-on: ubuntu-22.04 + needs: + - branch_build_setup + - branch_build_push_admin + - branch_build_push_web + - branch_build_push_space + - branch_build_push_live + - branch_build_push_api + - branch_build_push_proxy + steps: + - name: Checkout Files + uses: actions/checkout@v4 + + - name: Prepare AIO Assets + id: prepare_aio_assets + run: | + cd deployments/aio/community + + if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then + aio_version=${{ needs.branch_build_setup.outputs.release_version }} + else + aio_version=${{ needs.branch_build_setup.outputs.gh_branch_name }} + fi + bash ./build.sh --release $aio_version + echo "AIO_BUILD_VERSION=${aio_version}" >> $GITHUB_OUTPUT + + - name: Upload AIO Assets + uses: actions/upload-artifact@v4 + with: + path: ./deployments/aio/community/dist + name: aio-assets-dist + + - name: AIO Build and Push + uses: makeplane/actions/build-push@v1.1.0 + 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 }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_aio }} + build-context: ./deployments/aio/community + dockerfile-path: ./deployments/aio/community/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 }} + additional-assets: aio-assets-dist + additional-assets-dir: ./deployments/aio/community/dist + build-args: | + PLANE_VERSION=${{ steps.prepare_aio_assets.outputs.AIO_BUILD_VERSION }} + + upload_build_assets: + name: Upload Build Assets + runs-on: ubuntu-22.04 + needs: + - branch_build_setup + - branch_build_push_admin + - branch_build_push_web + - branch_build_push_space + - branch_build_push_live + - branch_build_push_api + - branch_build_push_proxy + steps: + - name: Checkout Files + uses: actions/checkout@v4 + + - name: Update Assets + run: | + if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then + REL_VERSION=${{ needs.branch_build_setup.outputs.release_version }} + else + REL_VERSION=${{ needs.branch_build_setup.outputs.gh_branch_name }} + fi + + cp ./deployments/cli/community/install.sh deployments/cli/community/setup.sh + sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deployments/cli/community/docker-compose.yml + # sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env + + - name: Upload Assets + uses: actions/upload-artifact@v4 + with: + name: community-assets + path: | + ./deployments/cli/community/setup.sh + ./deployments/cli/community/restore.sh + ./deployments/cli/community/restore-airgapped.sh + ./deployments/cli/community/docker-compose.yml + ./deployments/cli/community/variables.env + ./deployments/swarm/community/swarm.sh + + publish_release: + if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }} + name: Build Release + runs-on: ubuntu-22.04 + needs: + [ + branch_build_setup, + branch_build_push_admin, + branch_build_push_web, + branch_build_push_space, + branch_build_push_live, + branch_build_push_api, + branch_build_push_proxy, + ] + env: + REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Update Assets + run: | + cp ./deployments/cli/community/install.sh deployments/cli/community/setup.sh + sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deployments/cli/community/docker-compose.yml + # sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env + + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v2.1.0 + env: + 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 }}/deployments/cli/community/setup.sh + ${{ github.workspace }}/deployments/cli/community/restore.sh + ${{ github.workspace }}/deployments/cli/community/restore-airgapped.sh + ${{ github.workspace }}/deployments/cli/community/docker-compose.yml + ${{ github.workspace }}/deployments/cli/community/variables.env + ${{ github.workspace }}/deployments/swarm/community/swarm.sh diff --git a/.github/workflows/check-version.yml b/.github/workflows/check-version.yml new file mode 100644 index 00000000..855ee359 --- /dev/null +++ b/.github/workflows/check-version.yml @@ -0,0 +1,43 @@ +name: Version Change Before Release + +on: + pull_request: + branches: + - master + +jobs: + check-version: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + + - name: Get PR Branch version + run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV + + - name: Fetch base branch + run: git fetch origin master:master + + - name: Get Master Branch version + run: | + git checkout master + echo "MASTER_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV + + - name: Get master branch version and compare + run: | + echo "Comparing versions: PR version is $PR_VERSION, Master version is $MASTER_VERSION" + if [ "$PR_VERSION" == "$MASTER_VERSION" ]; then + echo "Version in PR branch is the same as in master. Failing the CI." + exit 1 + else + echo "Version check passed. Versions are different." + fi + env: + PR_VERSION: ${{ env.PR_VERSION }} + MASTER_VERSION: ${{ env.MASTER_VERSION }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..e3aba5cf --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,62 @@ +name: "CodeQL" + +on: + workflow_dispatch: + push: + branches: ["preview", "canary", "master"] + pull_request: + branches: ["preview", "canary", "master"] + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["python", "javascript"] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + 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. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@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 + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 00000000..ca87dc93 --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,25 @@ +# Codespell configuration is within .codespellrc +--- +name: Codespell + +on: + push: + branches: [preview] + pull_request: + branches: [preview] + +permissions: + contents: read + +jobs: + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Annotate locations with typos + uses: codespell-project/codespell-problem-matcher@v1 + - name: Codespell + uses: codespell-project/actions-codespell@v2 diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml new file mode 100644 index 00000000..dad3489d --- /dev/null +++ b/.github/workflows/feature-deployment.yml @@ -0,0 +1,168 @@ +name: Feature Preview + +on: + workflow_dispatch: + inputs: + base_tag_name: + description: 'Base Tag Name' + required: false + default: 'preview' + +env: + TARGET_BRANCH: ${{ github.ref_name }} + +jobs: + branch_build_setup: + name: Build Setup + runs-on: ubuntu-latest + outputs: + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }} + gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} + gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} + gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} + gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} + aio_base_tag: ${{ steps.set_env_variables.outputs.AIO_BASE_TAG }} + do_full_build: ${{ steps.set_env_variables.outputs.DO_FULL_BUILD }} + do_slim_build: ${{ steps.set_env_variables.outputs.DO_SLIM_BUILD }} + + steps: + - id: set_env_variables + name: Set Environment Variables + run: | + echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT + + if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then + echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT + else + echo "AIO_BASE_TAG=develop" >> $GITHUB_OUTPUT + fi + + echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT + + FLAT_BRANCH_NAME=$(echo "${{ env.TARGET_BRANCH }}" | sed 's/[^a-zA-Z0-9]/-/g') + echo "FLAT_BRANCH_NAME=$FLAT_BRANCH_NAME" >> $GITHUB_OUTPUT + + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + + full_build_push: + runs-on: ubuntu-22.04 + needs: [branch_build_setup] + env: + BUILD_TYPE: full + AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }} + AIO_IMAGE_TAGS: makeplane/plane-aio-feature:${{ needs.branch_build_setup.outputs.flat_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push to Docker Hub + uses: docker/build-push-action@v6.9.0 + with: + context: . + file: ./aio/Dockerfile-app + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.AIO_IMAGE_TAGS }} + push: true + build-args: + BUILD_TAG=${{ env.AIO_BASE_TAG }} + BUILD_TYPE=${{env.BUILD_TYPE}} + # cache-from: type=gha + # cache-to: type=gha,mode=max + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + outputs: + AIO_IMAGE_TAGS: ${{ env.AIO_IMAGE_TAGS }} + + feature-deploy: + needs: [branch_build_setup, full_build_push] + name: Feature Deploy + runs-on: ubuntu-latest + env: + KUBE_CONFIG_FILE: ${{ secrets.FEATURE_PREVIEW_KUBE_CONFIG }} + DEPLOYMENT_NAME: ${{ needs.branch_build_setup.outputs.flat_branch_name }} + steps: + - name: Install AWS cli + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + pip3 install awscli + - name: Tailscale + uses: tailscale/github-action@v2 + with: + oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} + tags: tag:ci + - name: Kubectl Setup + run: | + curl -LO "https://dl.k8s.io/release/${{ vars.FEATURE_PREVIEW_KUBE_VERSION }}/bin/linux/amd64/kubectl" + chmod +x kubectl + + mkdir -p ~/.kube + echo "$KUBE_CONFIG_FILE" > ~/.kube/config + chmod 600 ~/.kube/config + - name: HELM Setup + run: | + curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 + chmod 700 get_helm.sh + ./get_helm.sh + - name: App Deploy + run: | + helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }} + + APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}" + + helm --kube-insecure-skip-tls-verify uninstall \ + ${{ env.DEPLOYMENT_NAME }} \ + --namespace $APP_NAMESPACE \ + --timeout 10m0s \ + --wait \ + --ignore-not-found + + METADATA=$(helm --kube-insecure-skip-tls-verify upgrade \ + --install=true \ + --namespace $APP_NAMESPACE \ + --set dockerhub.loginid=${{ secrets.DOCKERHUB_USERNAME }} \ + --set dockerhub.password=${{ secrets.DOCKERHUB_TOKEN_RO}} \ + --set config.feature_branch=${{ env.DEPLOYMENT_NAME }} \ + --set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \ + --set ingress.tls_secret=${{vars.FEATURE_PREVIEW_INGRESS_TLS_SECRET || '' }} \ + --output json \ + --timeout 10m0s \ + --wait \ + ${{ env.DEPLOYMENT_NAME }} feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} ) + + APP_NAME=$(echo $METADATA | jq -r '.name') + + INGRESS_HOSTNAME=$(kubectl get ingress -n $APP_NAMESPACE --insecure-skip-tls-verify \ + -o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \ + jq -r '.spec.rules[0].host') + + echo "****************************************" + echo "APP NAME ::: $APP_NAME" + echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME" + echo "****************************************" diff --git a/.github/workflows/pull-request-build-lint-api.yml b/.github/workflows/pull-request-build-lint-api.yml new file mode 100644 index 00000000..50d105ef --- /dev/null +++ b/.github/workflows/pull-request-build-lint-api.yml @@ -0,0 +1,40 @@ +name: Build and lint API + +on: + workflow_dispatch: + pull_request: + branches: + - "preview" + types: + - "opened" + - "synchronize" + - "ready_for_review" + - "review_requested" + - "reopened" + paths: + - "apps/api/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-api: + name: Lint API + runs-on: ubuntu-latest + timeout-minutes: 25 + if: | + github.event.pull_request.draft == false && + github.event.pull_request.requested_reviewers != null + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install Pylint + run: python -m pip install ruff + - name: Install API Dependencies + run: cd apps/api && pip install -r requirements.txt + - name: Lint apps/api + run: ruff check --fix apps/api diff --git a/.github/workflows/pull-request-build-lint-web-apps.yml b/.github/workflows/pull-request-build-lint-web-apps.yml new file mode 100644 index 00000000..435ec209 --- /dev/null +++ b/.github/workflows/pull-request-build-lint-web-apps.yml @@ -0,0 +1,53 @@ +name: Build and lint web apps + +on: + workflow_dispatch: + pull_request: + branches: + - "preview" + types: + - "opened" + - "synchronize" + - "ready_for_review" + - "review_requested" + - "reopened" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-lint: + name: Build and lint web apps + runs-on: ubuntu-latest + timeout-minutes: 25 + if: | + github.event.pull_request.draft == false && + github.event.pull_request.requested_reviewers != null + env: + TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} + TURBO_SCM_HEAD: ${{ github.sha }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 50 + filter: blob:none + + - name: Set up Node.js + uses: actions/setup-node@v4 + + - name: Enable Corepack and pnpm + run: corepack enable pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint Affected + run: pnpm turbo run check:lint --affected + + - name: Check Affected format + run: pnpm turbo run check:format --affected + + - name: Build Affected + run: pnpm turbo run build --affected diff --git a/.github/workflows/sync-repo-pr.yml b/.github/workflows/sync-repo-pr.yml new file mode 100644 index 00000000..548ccbf4 --- /dev/null +++ b/.github/workflows/sync-repo-pr.yml @@ -0,0 +1,52 @@ +name: Create PR on Sync + +on: + workflow_dispatch: + push: + branches: + - "sync/**" + +env: + CURRENT_BRANCH: ${{ github.ref_name }} + 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 + ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }} + ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }} + +jobs: + create_pull_request: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for all branches and tags + + - name: Setup Git + run: | + git config user.name "$ACCOUNT_USER_NAME" + git config user.email "$ACCOUNT_USER_EMAIL" + + - name: Setup GH CLI and Git Config + run: | + type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt update + sudo apt install gh -y + + - name: Create PR to Target Branch + run: | + # get all pull requests and check if there is already a PR + PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number') + if [ -n "$PR_EXISTS" ]; then + 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 "${{ vars.SYNC_PR_TITLE }}" --body "") + echo "Pull Request created: $PR_URL" + fi diff --git a/.github/workflows/sync-repo.yml b/.github/workflows/sync-repo.yml new file mode 100644 index 00000000..5d6c72cb --- /dev/null +++ b/.github/workflows/sync-repo.yml @@ -0,0 +1,44 @@ +name: Sync Repositories + +on: + workflow_dispatch: + push: + branches: + - preview + +env: + SOURCE_BRANCH_NAME: ${{ github.ref_name }} + +jobs: + sync_changes: + runs-on: ubuntu-22.04 + permissions: + pull-requests: write + contents: read + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup GH CLI + run: | + type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt update + sudo apt install gh -y + + - name: Push Changes to Target Repo + env: + GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + run: | + TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}" + TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}" + SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" + + git checkout $SOURCE_BRANCH + git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git" + git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0edc47dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,104 @@ +node_modules +.next +.yarn + +### NextJS ### +# Dependencies +/node_modules +/.pnp +.pnp.js + +# Testing +/coverage + +# Next.js +/.next/ +/out/ + +# Production +dist/ +out/ +build/ +.react-router/ + +# Misc +.DS_Store +*.pem +.history +tsconfig.tsbuildinfo + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-debug.log* + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Vercel +.vercel + +# Turborepo +.turbo + +## Django ## +venv +.venv +*.pyc +staticfiles +mediafiles +.env +.DS_Store +logs/ +htmlcov/ +.coverage + +node_modules/ +assets/dist/ +npm-debug.log +yarn-error.log +pnpm-debug.log + +# Editor directories and files +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +package-lock.json +.vscode + +# Sentry +.sentryclirc + +# lock files +package-lock.json + + + +.secrets +tmp/ + +## packages +dist +.temp/ +deploy/selfhost/plane-app/ + +## Storybook +*storybook.log +output.css + +dev-editor +# Redis +*.rdb +*.rdb.gz + +storybook-static + +CLAUDE.md diff --git a/.idx/dev.nix b/.idx/dev.nix new file mode 100644 index 00000000..f150f679 --- /dev/null +++ b/.idx/dev.nix @@ -0,0 +1,16 @@ +{ pkgs, ... }: { + + # Which nixpkgs channel to use. + channel = "stable-23.11"; # or "unstable" + + # Use https://search.nixos.org/packages to find packages + packages = [ + pkgs.nodejs_20 + pkgs.python3 + ]; + + services.docker.enable = true; + services.postgres.enable = true; + services.redis.enable = true; + +} \ No newline at end of file diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 00000000..716b1b5b --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "22.18.0" diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..d652acc3 --- /dev/null +++ b/.npmrc @@ -0,0 +1,34 @@ +# Enforce pnpm workspace behavior and allow Turbo's lifecycle hooks if scripts are disabled +# This repo uses pnpm with workspaces. + +# Prefer linking local workspace packages when available +prefer-workspace-packages=true +link-workspace-packages=true +shared-workspace-lockfile=true + +# Make peer installs smoother across the monorepo +auto-install-peers=true +strict-peer-dependencies=false + +# If scripts are disabled (e.g., CI with --ignore-scripts), allowlisted packages can still run their hooks +# Turbo occasionally performs postinstall tasks for optimal performance +# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo) + +public-hoist-pattern[]=*eslint* +public-hoist-pattern[]=prettier +public-hoist-pattern[]=typescript + +# Reproducible installs across CI and dev +prefer-frozen-lockfile=true + +# Prefer resolving to highest versions in monorepo to reduce duplication +resolution-mode=highest + +# Speed up native module builds by caching side effects +side-effects-cache=true + +# Speed up local dev by reusing local store when possible +prefer-offline=true + +# Ensure workspace protocol is used when adding internal deps +save-workspace-protocol=true diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..9fa847b6 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +squawk@plane.so. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..39eb4e80 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,245 @@ +# Contributing to Plane + +Thank you for showing an interest in contributing to Plane! All kinds of contributions are valuable to us. In this guide, we will cover how you can quickly onboard and make your first contribution. + +## Submitting an issue + +Before submitting a new issue, please search the [issues](https://github.com/makeplane/plane/issues) tab. Maybe an issue or discussion already exists and might inform you of workarounds. Otherwise, you can give new information. + +While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like: + +- 3rd-party libraries being used and their versions +- a use-case that fails + +Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved. + +You can open a new issue with this [issue form](https://github.com/makeplane/plane/issues/new). + +### Naming conventions for issues + +When opening a new issue, please use a clear and concise title that follows this format: + +- For bugs: `🐛 Bug: [short description]` +- For features: `🚀 Feature: [short description]` +- For improvements: `🛠️ Improvement: [short description]` +- For documentation: `📘 Docs: [short description]` + +**Examples:** + +- `🐛 Bug: API token expiry time not saving correctly` +- `📘 Docs: Clarify RAM requirement for local setup` +- `🚀 Feature: Allow custom time selection for token expiration` + +This helps us triage and manage issues more efficiently. + +## Projects setup and Architecture + +### Requirements + +- Docker Engine installed and running +- Node.js version 20+ [LTS version](https://nodejs.org/en/about/previous-releases) +- Python version 3.8+ +- Postgres version v14 +- Redis version v6.2.7 +- **Memory**: Minimum **12 GB RAM** recommended + > ⚠️ Running the project on a system with only 8 GB RAM may lead to setup failures or memory crashes (especially during Docker container build/start or dependency install). Use cloud environments like GitHub Codespaces or upgrade local RAM if possible. + +### Setup the project + +The project is a monorepo, with backend api and frontend in a single repo. + +The backend is a django project which is kept inside apps/api + +1. Clone the repo + +```bash +git clone https://github.com/makeplane/plane.git [folder-name] +cd [folder-name] +chmod +x setup.sh +``` + +2. Run setup.sh + +```bash +./setup.sh +``` + +3. Start the containers + +```bash +docker compose -f docker-compose-local.yml up +``` + +4. Start web apps: + +```bash +pnpm dev +``` + +5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin +6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step + +That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉 + +## Missing a Feature? + +If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository. +If you would like to _implement_ it, an issue with your proposal must be submitted first, to be sure that we can use it. Please consider the guidelines given below. + +## Coding guidelines + +To ensure consistency throughout the source code, please keep these rules in mind as you are working: + +- All features or bug fixes must be tested by one or more specs (unit-tests). +- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier. + +## Ways to contribute + +- Try Plane Cloud and the self hosting platform and give feedback +- Add new integrations +- Add or update translations +- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose) +- Share your thoughts and suggestions with us +- Help create tutorials and blog posts +- Request a feature by submitting a proposal +- Report a bug +- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations. + +## Contributing to language support + +This guide is designed to help contributors understand how to add or update translations in the application. + +### Understanding translation structure + +#### File organization + +Translations are organized by language in the locales directory. Each language has its own folder containing JSON files for translations. Here's how it looks: + +``` +packages/i18n/src/locales/ + ├── en/ + │ ├── core.json # Critical translations + │ └── translations.json + ├── fr/ + │ └── translations.json + └── [language]/ + └── translations.json +``` + +#### Nested structure + +To keep translations organized, we use a nested structure for keys. This makes it easier to manage and locate specific translations. For example: + +```json +{ + "issue": { + "label": "Work item", + "title": { + "label": "Work item title" + } + } +} +``` + +### Translation formatting guide + +We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) to handle dynamic content, such as variables and pluralization. Here's how to format your translations: + +#### Examples + +- **Simple variables** + + ```json + { + "greeting": "Hello, {name}!" + } + ``` + +- **Pluralization** + ```json + { + "items": "{count, plural, one {Work item} other {Work items}}" + } + ``` + +### Contributing guidelines + +#### Updating existing translations + +1. Locate the key in `locales//translations.json`. + +2. Update the value while ensuring the key structure remains intact. +3. Preserve any existing ICU formats (e.g., variables, pluralization). + +#### Adding new translation keys + +1. When introducing a new key, ensure it is added to **all** language files, even if translations are not immediately available. Use English as a placeholder if needed. + +2. Keep the nesting structure consistent across all languages. + +3. If the new key requires dynamic content (e.g., variables or pluralization), ensure the ICU format is applied uniformly across all languages. + +### Adding new languages + +Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully: + +1. **Update type definitions** + Add the new language to the TLanguage type in the language definitions file: + +```ts + // packages/i18n/src/types/language.ts + export type TLanguage = "en" | "fr" | "your-lang"; +``` + +1. **Add language configuration** + Include the new language in the list of supported languages: +```ts + // packages/i18n/src/constants/language.ts + export const SUPPORTED_LANGUAGES: ILanguageOption[] = [ + { label: "English", value: "en" }, + { label: "Your Language", value: "your-lang" } + ]; +``` + +2. **Create translation files** + 1. Create a new folder for your language under locales (e.g., `locales/your-lang/`). + + 2. Add a `translations.json` file inside the folder. + + 3. Copy the structure from an existing translation file and translate all keys. + +3. **Update import logic** + Modify the language import logic to include your new language: +```ts + private importLanguageFile(language: TLanguage): Promise { + switch (language) { + case "your-lang": + return import("../locales/your-lang/translations.json"); + // ... + } + } +``` + +### Quality checklist + +Before submitting your contribution, please ensure the following: + +- All translation keys exist in every language file. +- Nested structures match across all language files. +- ICU message formats are correctly implemented. +- All languages load without errors in the application. +- Dynamic values and pluralization work as expected. +- There are no missing or untranslated keys. + +#### Pro tips + +- When in doubt, refer to the English translations for context. +- Verify pluralization works with different numbers. +- Ensure dynamic values (e.g., `{name}`) are correctly interpolated. +- Double-check that nested key access paths are accurate. + +Happy translating! 🌍✨ + +## Need help? Questions and suggestions + +Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge). diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..5087e61e --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..f6b364be --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +

+ +

+ + Plane Logo + +

+

Modern project management for all teams

+ +

+ +Discord online members + +Commit activity per month +

+ +

+ Website • + Releases • + Twitter • + Documentation +

+ +

+ + Plane Screens + +

+ +Meet [Plane](https://plane.so/), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘‍♀️ + +> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most. + +## 🚀 Installation + +Getting started with Plane is simple. Choose the setup that works best for you: + +- **Plane Cloud** + Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure. + +- **Self-host Plane** + Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started. + +| Installation methods | Docs link | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://developers.plane.so/self-hosting/methods/docker-compose) | +| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://developers.plane.so/self-hosting/methods/kubernetes) | + +`Instance admins` can configure instance settings with [God mode](https://developers.plane.so/self-hosting/govern/instance-admin). + +## 🌟 Features + +- **Issues** + Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues. + +- **Cycles** + Maintain your team’s momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools. + +- **Modules** + Simplify complex projects by dividing them into smaller, manageable modules. + +- **Views** + Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease. + +- **Pages** + Capture and organize ideas using Plane Pages, complete with AI capabilities and a rich text editor. Format text, insert images, add hyperlinks, or convert your notes into actionable items. + +- **Analytics** + Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward. + +- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution. + +## 🛠️ Local development + +See [CONTRIBUTING](./CONTRIBUTING.md) + +## ⚙️ Built with + +[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/) +[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/) +[![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en) + +## 📸 Screenshots + +

+ + Plane Views + +

+

+ + + +

+

+ + Plane Cycles and Modules + +

+

+ + Plane Analytics + +

+

+ + Plane Pages + +

+

+ +## 📝 Documentation + +Explore Plane's [product documentation](https://docs.plane.so/) and [developer documentation](https://developers.plane.so/) to learn about features, setup, and usage. + +## ❤️ Community + +Join the Plane community on [GitHub Discussions](https://github.com/orgs/makeplane/discussions) and our [Discord server](https://discord.com/invite/A92xrEGCge). We follow a [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) in all our community channels. + +Feel free to ask questions, report bugs, participate in discussions, share ideas, request features, or showcase your projects. We’d love to hear from you! + +## 🛡️ Security + +If you discover a security vulnerability in Plane, please report it responsibly instead of opening a public issue. We take all legitimate reports seriously and will investigate them promptly. See [Security policy](https://github.com/makeplane/plane/blob/master/SECURITY.md) for more info. + +To disclose any security issues, please email us at security@plane.so. + +## 🤝 Contributing + +There are many ways you can contribute to Plane: + +- Report [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) or submit [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+). +- Review the [documentation](https://docs.plane.so/) and submit [pull requests](https://github.com/makeplane/docs) to improve it—whether it's fixing typos or adding new content. +- Talk or write about Plane or any other ecosystem integration and [let us know](https://discord.com/invite/A92xrEGCge)! +- Show your support by upvoting [popular feature requests](https://github.com/makeplane/plane/issues). + +Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md) for details on the process for submitting pull requests to us. + +### Repo activity + +![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image") + +### We couldn't have done this without you. + + + + + +## License + +This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..0e11bbb5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,39 @@ +# Security policy +This document outlines the security protocols and vulnerability reporting guidelines for the Plane project. Ensuring the security of our systems is a top priority, and while we work diligently to maintain robust protection, vulnerabilities may still occur. We highly value the community’s role in identifying and reporting security concerns to uphold the integrity of our systems and safeguard our users. + +## Reporting a vulnerability +If you have identified a security vulnerability, submit your findings to [security@plane.so](mailto:security@plane.so). +Ensure your report includes all relevant information needed for us to reproduce and assess the issue. Include the IP address or URL of the affected system. + +To ensure a responsible and effective disclosure process, please adhere to the following: + +- Maintain confidentiality and refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address the issue. +- Refrain from running automated vulnerability scans on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary. +- Do not exploit any discovered vulnerabilities for malicious purposes, such as accessing or altering user data. +- Do not engage in physical security attacks, social engineering, distributed denial of service (DDoS) attacks, spam campaigns, or attacks on third-party applications as part of your vulnerability testing. + +## Out of scope +While we appreciate all efforts to assist in improving our security, please note that the following types of vulnerabilities are considered out of scope: + +- Vulnerabilities requiring man-in-the-middle (MITM) attacks or physical access to a user’s device. +- Content spoofing or text injection issues without a clear attack vector or the ability to modify HTML/CSS. +- Issues related to email spoofing. +- Missing DNSSEC, CAA, or CSP headers. +- Absence of secure or HTTP-only flags on non-sensitive cookies. + +## Our commitment + +At Plane, we are committed to maintaining transparent and collaborative communication throughout the vulnerability resolution process. Here's what you can expect from us: + +- **Response Time**
+We will acknowledge receipt of your vulnerability report within three business days and provide an estimated timeline for resolution. +- **Legal Protection**
+We will not initiate legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines. +- **Confidentiality**
+Your report will be treated with confidentiality. We will not disclose your personal information to third parties without your consent. +- **Recognition**
+With your permission, we are happy to publicly acknowledge your contribution to improving our security once the issue is resolved. +- **Timely Resolution**
+We are committed to working closely with you throughout the resolution process, providing timely updates as necessary. Our goal is to address all reported vulnerabilities swiftly, and we will actively engage with you to coordinate a responsible disclosure once the issue is fully resolved. + +We appreciate your help in ensuring the security of our platform. Your contributions are crucial to protecting our users and maintaining a secure environment. Thank you for working with us to keep Plane safe. \ No newline at end of file diff --git a/apps/admin/.env.example b/apps/admin/.env.example new file mode 100644 index 00000000..15d7a36a --- /dev/null +++ b/apps/admin/.env.example @@ -0,0 +1,12 @@ +NEXT_PUBLIC_API_BASE_URL="http://localhost:8000" + +NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000" + +NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001" +NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" + +NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002" +NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" + +NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100" +NEXT_PUBLIC_LIVE_BASE_PATH="/live" diff --git a/apps/admin/.eslintignore b/apps/admin/.eslintignore new file mode 100644 index 00000000..27e50ad7 --- /dev/null +++ b/apps/admin/.eslintignore @@ -0,0 +1,12 @@ +.next/* +out/* +public/* +dist/* +node_modules/* +.turbo/* +.env* +.env +.env.local +.env.development +.env.production +.env.test \ No newline at end of file diff --git a/apps/admin/.eslintrc.js b/apps/admin/.eslintrc.js new file mode 100644 index 00000000..a0bc76d5 --- /dev/null +++ b/apps/admin/.eslintrc.js @@ -0,0 +1,18 @@ +module.exports = { + root: true, + extends: ["@plane/eslint-config/next.js"], + rules: { + "no-duplicate-imports": "off", + "import/no-duplicates": ["error", { "prefer-inline": false }], + "import/consistent-type-specifier-style": ["error", "prefer-top-level"], + "@typescript-eslint/no-import-type-side-effects": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "separate-type-imports", + disallowTypeAnnotations: false, + }, + ], + }, +}; diff --git a/apps/admin/.prettierignore b/apps/admin/.prettierignore new file mode 100644 index 00000000..3cd6b08a --- /dev/null +++ b/apps/admin/.prettierignore @@ -0,0 +1,6 @@ +.next +.vercel +.tubro +out/ +dist/ +build/ diff --git a/apps/admin/.prettierrc b/apps/admin/.prettierrc new file mode 100644 index 00000000..87d988f1 --- /dev/null +++ b/apps/admin/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/apps/admin/Dockerfile.admin b/apps/admin/Dockerfile.admin new file mode 100644 index 00000000..6bfa0765 --- /dev/null +++ b/apps/admin/Dockerfile.admin @@ -0,0 +1,103 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-alpine AS base + +# Setup pnpm package manager with corepack and configure global bin directory for caching +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +# ***************************************************************************** +# STAGE 1: Build the project +# ***************************************************************************** +FROM base AS builder +RUN apk add --no-cache libc6-compat +WORKDIR /app + +ARG TURBO_VERSION=2.5.6 +RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION} +COPY . . + +RUN turbo prune --scope=admin --docker + +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** +FROM base AS installer + +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN corepack enable pnpm +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store + +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store + +ARG NEXT_PUBLIC_API_BASE_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_URL="" +ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 + +RUN pnpm turbo run build --filter=admin + +# ***************************************************************************** +# STAGE 3: Copy the project and start it +# ***************************************************************************** +FROM base AS runner +WORKDIR /app + +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +USER nextjs + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer /app/apps/admin/.next/standalone ./ +COPY --from=installer /app/apps/admin/.next/static ./apps/admin/.next/static +COPY --from=installer /app/apps/admin/public ./apps/admin/public + +ARG NEXT_PUBLIC_API_BASE_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_URL="" +ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 + +EXPOSE 3000 + +CMD ["node", "apps/admin/server.js"] diff --git a/apps/admin/Dockerfile.dev b/apps/admin/Dockerfile.dev new file mode 100644 index 00000000..0b82669c --- /dev/null +++ b/apps/admin/Dockerfile.dev @@ -0,0 +1,17 @@ +FROM node:22-alpine +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +COPY . . + +RUN corepack enable pnpm && pnpm add -g turbo +RUN pnpm install + +ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" + +EXPOSE 3000 + +VOLUME [ "/app/node_modules", "/app/admin/node_modules" ] + +CMD ["pnpm", "dev", "--filter=admin"] diff --git a/apps/admin/app/(all)/(dashboard)/ai/form.tsx b/apps/admin/app/(all)/(dashboard)/ai/form.tsx new file mode 100644 index 00000000..64970a54 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/ai/form.tsx @@ -0,0 +1,136 @@ +"use client"; +import type { FC } from "react"; +import { useForm } from "react-hook-form"; +import { Lightbulb } from "lucide-react"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types"; +// components +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; +// hooks +import { useInstance } from "@/hooks/store"; + +type IInstanceAIForm = { + config: IFormattedInstanceConfiguration; +}; + +type AIFormValues = Record; + +export const InstanceAIForm: FC = (props) => { + const { config } = props; + // store + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + LLM_API_KEY: config["LLM_API_KEY"], + LLM_MODEL: config["LLM_MODEL"], + }, + }); + + const aiFormFields: TControllerInputFormField[] = [ + { + key: "LLM_MODEL", + type: "text", + label: "LLM Model", + description: ( + <> + Choose an OpenAI engine.{" "} + + Learn more + + + ), + placeholder: "gpt-4o-mini", + error: Boolean(errors.LLM_MODEL), + required: false, + }, + { + key: "LLM_API_KEY", + type: "password", + label: "API key", + description: ( + <> + You will find your API key{" "} + + here. + + + ), + placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd", + error: Boolean(errors.LLM_API_KEY), + required: false, + }, + ]; + + const onSubmit = async (formData: AIFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "AI Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+
+
OpenAI
+
If you use ChatGPT, this is for you.
+
+
+ {aiFormFields.map((field) => ( + + ))} +
+
+ +
+ + +
+ +
+ If you have a preferred AI models vendor, please get in{" "} + + touch with us. + +
+
+
+
+ ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/ai/layout.tsx b/apps/admin/app/(all)/(dashboard)/ai/layout.tsx new file mode 100644 index 00000000..303ed560 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/ai/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Artificial Intelligence Settings - God Mode", +}; + +export default function AILayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/ai/page.tsx b/apps/admin/app/(all)/(dashboard)/ai/page.tsx new file mode 100644 index 00000000..2a074777 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/ai/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { Loader } from "@plane/ui"; +// hooks +import { useInstance } from "@/hooks/store"; +// components +import { InstanceAIForm } from "./form"; + +const InstanceAIPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig } = useInstance(); + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + return ( + <> +
+
+
AI features for all your workspaces
+
+ Configure your AI API credentials so Plane AI features are turned on for all your workspaces. +
+
+
+ {formattedConfig ? ( + + ) : ( + + +
+ + +
+ +
+ )} +
+
+ + ); +}); + +export default InstanceAIPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx new file mode 100644 index 00000000..ae0f54c4 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx @@ -0,0 +1,249 @@ +"use client"; + +import type { FC } from "react"; +import { useState } from "react"; +import { isEmpty } from "lodash-es"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +import { Monitor } from "lucide-react"; +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; + +import { cn } from "@plane/utils"; +// components +import { CodeBlock } from "@/components/common/code-block"; +import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; +import type { TCopyField } from "@/components/common/copy-field"; +import { CopyField } from "@/components/common/copy-field"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type GithubConfigFormValues = Record; + +export const InstanceGithubConfigForm: FC = (props) => { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"], + GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"], + GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"], + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const GITHUB_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "GITHUB_CLIENT_ID", + type: "text", + label: "Client ID", + description: ( + <> + You will get this from your{" "} + + GitHub OAuth application settings. + + + ), + placeholder: "70a44354520df8bd9bcd", + error: Boolean(errors.GITHUB_CLIENT_ID), + required: true, + }, + { + key: "GITHUB_CLIENT_SECRET", + type: "password", + label: "Client secret", + description: ( + <> + Your client secret is also found in your{" "} + + GitHub OAuth application settings. + + + ), + placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb", + error: Boolean(errors.GITHUB_CLIENT_SECRET), + required: true, + }, + { + key: "GITHUB_ORGANIZATION_ID", + type: "text", + label: "Organization ID", + description: <>The organization github ID., + placeholder: "123456789", + error: Boolean(errors.GITHUB_ORGANIZATION_ID), + required: false, + }, + ]; + + const GITHUB_COMMON_SERVICE_DETAILS: TCopyField[] = [ + { + key: "Origin_URL", + label: "Origin URL", + url: originURL, + description: ( + <> + We will auto-generate this. Paste this into the Authorized origin URL field{" "} + + here. + + + ), + }, + ]; + + const GITHUB_SERVICE_DETAILS: TCopyField[] = [ + { + key: "Callback_URI", + label: "Callback URI", + url: `${originURL}/auth/github/callback/`, + description: ( + <> + We will auto-generate this. Paste this into your Authorized Callback URI{" "} + field{" "} + + here. + + + ), + }, + ]; + + const onSubmit = async (formData: GithubConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then((response = []) => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your GitHub authentication is configured. You should test it now.", + }); + reset({ + GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, + GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value, + GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value, + }); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
GitHub-provided details for Plane
+ {GITHUB_FORM_FIELDS.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
Plane-provided details for GitHub
+ +
+ {/* common service details */} +
+ {GITHUB_COMMON_SERVICE_DETAILS.map((field) => ( + + ))} +
+ + {/* web service details */} +
+
+ + Web +
+
+ {GITHUB_SERVICE_DETAILS.map((field) => ( + + ))} +
+
+
+
+
+
+ + ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx new file mode 100644 index 00000000..2da5a903 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "GitHub Authentication - God Mode", +}; + +export default function GitHubAuthenticationLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx new file mode 100644 index 00000000..5709ba4b --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +// plane internal packages +import { setPromiseToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; +import { resolveGeneralTheme } from "@plane/utils"; +// components +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +// hooks +import { useInstance } from "@/hooks/store"; +// icons +import githubLightModeImage from "@/public/logos/github-black.png"; +import githubDarkModeImage from "@/public/logos/github-white.png"; +// local components +import { InstanceGithubConfigForm } from "./form"; + +const InstanceGithubAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // theme + const { resolvedTheme } = useTheme(); + // config + const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? ""; + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_GITHUB_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Configuration saved", + message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + + const isGithubEnabled = enableGithubConfig === "1"; + + return ( + <> +
+
+ + } + config={ + { + updateConfig("IS_GITHUB_ENABLED", isGithubEnabled ? "0" : "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGithubAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx new file mode 100644 index 00000000..91e4ee8e --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx @@ -0,0 +1,212 @@ +import type { FC } from "react"; +import { useState } from "react"; +import { isEmpty } from "lodash-es"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types"; +import { cn } from "@plane/utils"; +// components +import { CodeBlock } from "@/components/common/code-block"; +import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; +import type { TCopyField } from "@/components/common/copy-field"; +import { CopyField } from "@/components/common/copy-field"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type GitlabConfigFormValues = Record; + +export const InstanceGitlabConfigForm: FC = (props) => { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + GITLAB_HOST: config["GITLAB_HOST"], + GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"], + GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"], + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const GITLAB_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "GITLAB_HOST", + type: "text", + label: "Host", + description: ( + <> + This is either https://gitlab.com or the domain.tld where you host GitLab. + + ), + placeholder: "https://gitlab.com", + error: Boolean(errors.GITLAB_HOST), + required: true, + }, + { + key: "GITLAB_CLIENT_ID", + type: "text", + label: "Application ID", + description: ( + <> + Get this from your{" "} + + GitLab OAuth application settings + + . + + ), + placeholder: "c2ef2e7fc4e9d15aa7630f5637d59e8e4a27ff01dceebdb26b0d267b9adcf3c3", + error: Boolean(errors.GITLAB_CLIENT_ID), + required: true, + }, + { + key: "GITLAB_CLIENT_SECRET", + type: "password", + label: "Secret", + description: ( + <> + The client secret is also found in your{" "} + + GitLab OAuth application settings + + . + + ), + placeholder: "gloas-f79cfa9a03c97f6ffab303177a5a6778a53c61e3914ba093412f68a9298a1b28", + error: Boolean(errors.GITLAB_CLIENT_SECRET), + required: true, + }, + ]; + + const GITLAB_SERVICE_FIELD: TCopyField[] = [ + { + key: "Callback_URL", + label: "Callback URL", + url: `${originURL}/auth/gitlab/callback/`, + description: ( + <> + We will auto-generate this. Paste this into the Redirect URI field of your{" "} + + GitLab OAuth application + + . + + ), + }, + ]; + + const onSubmit = async (formData: GitlabConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then((response = []) => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your GitLab authentication is configured. You should test it now.", + }); + reset({ + GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value, + GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value, + GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value, + }); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
GitLab-provided details for Plane
+ {GITLAB_FORM_FIELDS.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Plane-provided details for GitLab
+ {GITLAB_SERVICE_FIELD.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx new file mode 100644 index 00000000..79b5de5a --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "GitLab Authentication - God Mode", +}; + +export default function GitlabAuthenticationLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx new file mode 100644 index 00000000..ae85168a --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import useSWR from "swr"; +import { setPromiseToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; +// components +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +// hooks +import { useInstance } from "@/hooks/store"; +// icons +import GitlabLogo from "@/public/logos/gitlab-logo.svg"; +// local components +import { InstanceGitlabConfigForm } from "./form"; + +const InstanceGitlabAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // config + const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? ""; + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_GITLAB_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Configuration saved", + message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + return ( + <> +
+
+ } + config={ + { + if (Boolean(parseInt(enableGitlabConfig)) === true) { + updateConfig("IS_GITLAB_ENABLED", "0"); + } else { + updateConfig("IS_GITLAB_ENABLED", "1"); + } + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGitlabAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx new file mode 100644 index 00000000..d9c3646b --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx @@ -0,0 +1,235 @@ +"use client"; +import type { FC } from "react"; +import { useState } from "react"; +import { isEmpty } from "lodash-es"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +import { Monitor } from "lucide-react"; +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; +import { cn } from "@plane/utils"; +// components +import { CodeBlock } from "@/components/common/code-block"; +import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; +import type { TCopyField } from "@/components/common/copy-field"; +import { CopyField } from "@/components/common/copy-field"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type GoogleConfigFormValues = Record; + +export const InstanceGoogleConfigForm: FC = (props) => { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"], + GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"], + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const GOOGLE_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "GOOGLE_CLIENT_ID", + type: "text", + label: "Client ID", + description: ( + <> + Your client ID lives in your Google API Console.{" "} + + Learn more + + + ), + placeholder: "840195096245-0p2tstej9j5nc4l8o1ah2dqondscqc1g.apps.googleusercontent.com", + error: Boolean(errors.GOOGLE_CLIENT_ID), + required: true, + }, + { + key: "GOOGLE_CLIENT_SECRET", + type: "password", + label: "Client secret", + description: ( + <> + Your client secret should also be in your Google API Console.{" "} + + Learn more + + + ), + placeholder: "GOCShX-ADp4cI0kPqav1gGCBg5bE02E", + error: Boolean(errors.GOOGLE_CLIENT_SECRET), + required: true, + }, + ]; + + const GOOGLE_COMMON_SERVICE_DETAILS: TCopyField[] = [ + { + key: "Origin_URL", + label: "Origin URL", + url: originURL, + description: ( +

+ We will auto-generate this. Paste this into your{" "} + Authorized JavaScript origins field. For this OAuth client{" "} + + here. + +

+ ), + }, + ]; + + const GOOGLE_SERVICE_DETAILS: TCopyField[] = [ + { + key: "Callback_URI", + label: "Callback URI", + url: `${originURL}/auth/google/callback/`, + description: ( +

+ We will auto-generate this. Paste this into your Authorized Redirect URI{" "} + field. For this OAuth client{" "} + + here. + +

+ ), + }, + ]; + + const onSubmit = async (formData: GoogleConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then((response = []) => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your Google authentication is configured. You should test it now.", + }); + reset({ + GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value, + GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value, + }); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Google-provided details for Plane
+ {GOOGLE_FORM_FIELDS.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
Plane-provided details for Google
+ +
+ {/* common service details */} +
+ {GOOGLE_COMMON_SERVICE_DETAILS.map((field) => ( + + ))} +
+ + {/* web service details */} +
+
+ + Web +
+
+ {GOOGLE_SERVICE_DETAILS.map((field) => ( + + ))} +
+
+
+
+
+
+ + ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx new file mode 100644 index 00000000..ddc0cff4 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Google Authentication - God Mode", +}; + +export default function GoogleAuthenticationLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx new file mode 100644 index 00000000..d6ca370d --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import useSWR from "swr"; +import { setPromiseToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; +// components +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +// hooks +import { useInstance } from "@/hooks/store"; +// icons +import GoogleLogo from "@/public/logos/google-logo.svg"; +// local components +import { InstanceGoogleConfigForm } from "./form"; + +const InstanceGoogleAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // config + const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? ""; + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_GOOGLE_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Configuration saved", + message: () => `Google authentication is now ${value ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + return ( + <> +
+
+ } + config={ + { + if (Boolean(parseInt(enableGoogleConfig)) === true) { + updateConfig("IS_GOOGLE_ENABLED", "0"); + } else { + updateConfig("IS_GOOGLE_ENABLED", "1"); + } + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGoogleAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx new file mode 100644 index 00000000..bed80f22 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Authentication Settings - Plane Web", +}; + +export default function AuthenticationLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx new file mode 100644 index 00000000..16be71e5 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane internal packages +import { setPromiseToast } from "@plane/propel/toast"; +import type { TInstanceConfigurationKeys } from "@plane/types"; +import { Loader, ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useInstance } from "@/hooks/store"; +// plane admin components +import { AuthenticationModes } from "@/plane-admin/components/authentication"; + +const InstanceAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // derived values + const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? ""; + + 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 ( + <> +
+
+
Manage authentication modes for your instance
+
+ Configure authentication modes for your team and restrict sign-ups to be invite only. +
+
+
+ {formattedConfig ? ( +
+
+
+
+
Allow anyone to sign up even without an invite
+
+ Toggling this off will only let users sign up when they are invited. +
+
+
+
+
+ { + if (Boolean(parseInt(enableSignUpConfig)) === true) { + updateConfig("ENABLE_SIGNUP", "0"); + } else { + updateConfig("ENABLE_SIGNUP", "1"); + } + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+
Available authentication modes
+ +
+ ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx new file mode 100644 index 00000000..450a5f4e --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx @@ -0,0 +1,228 @@ +"use client"; + +import type { FC } from "react"; +import React, { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +// types +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types"; +// ui +import { CustomSelect } from "@plane/ui"; +// components +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; +// hooks +import { useInstance } from "@/hooks/store"; +// local components +import { SendTestEmailModal } from "./test-email-modal"; + +type IInstanceEmailForm = { + config: IFormattedInstanceConfiguration; +}; + +type EmailFormValues = Record; + +type TEmailSecurityKeys = "EMAIL_USE_TLS" | "EMAIL_USE_SSL" | "NONE"; + +const EMAIL_SECURITY_OPTIONS: { [key in TEmailSecurityKeys]: string } = { + EMAIL_USE_TLS: "TLS", + EMAIL_USE_SSL: "SSL", + NONE: "No email security", +}; + +export const InstanceEmailForm: FC = (props) => { + const { config } = props; + // states + const [isSendTestEmailModalOpen, setIsSendTestEmailModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + watch, + setValue, + control, + formState: { errors, isValid, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + EMAIL_HOST: config["EMAIL_HOST"], + EMAIL_PORT: config["EMAIL_PORT"], + EMAIL_HOST_USER: config["EMAIL_HOST_USER"], + EMAIL_HOST_PASSWORD: config["EMAIL_HOST_PASSWORD"], + EMAIL_USE_TLS: config["EMAIL_USE_TLS"], + EMAIL_USE_SSL: config["EMAIL_USE_SSL"], + EMAIL_FROM: config["EMAIL_FROM"], + ENABLE_SMTP: config["ENABLE_SMTP"], + }, + }); + const emailFormFields: TControllerInputFormField[] = [ + { + key: "EMAIL_HOST", + type: "text", + label: "Host", + placeholder: "email.google.com", + error: Boolean(errors.EMAIL_HOST), + required: true, + }, + { + key: "EMAIL_PORT", + type: "text", + label: "Port", + placeholder: "8080", + error: Boolean(errors.EMAIL_PORT), + required: true, + }, + { + key: "EMAIL_FROM", + type: "text", + 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", + error: Boolean(errors.EMAIL_FROM), + required: true, + }, + ]; + + const OptionalEmailFormFields: TControllerInputFormField[] = [ + { + key: "EMAIL_HOST_USER", + type: "text", + label: "Username", + placeholder: "getitdone@projectplane.so", + error: Boolean(errors.EMAIL_HOST_USER), + required: false, + }, + { + key: "EMAIL_HOST_PASSWORD", + type: "password", + label: "Password", + placeholder: "Password", + error: Boolean(errors.EMAIL_HOST_PASSWORD), + required: false, + }, + ]; + + const onSubmit = async (formData: EmailFormValues) => { + const payload: Partial = { ...formData, ENABLE_SMTP: "1" }; + + await updateInstanceConfigurations(payload) + .then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Email Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + const useTLSValue = watch("EMAIL_USE_TLS"); + const useSSLValue = watch("EMAIL_USE_SSL"); + const emailSecurityKey: TEmailSecurityKeys = useMemo(() => { + if (useTLSValue === "1") return "EMAIL_USE_TLS"; + if (useSSLValue === "1") return "EMAIL_USE_SSL"; + return "NONE"; + }, [useTLSValue, useSSLValue]); + + const handleEmailSecurityChange = (key: TEmailSecurityKeys) => { + if (key === "EMAIL_USE_SSL") { + setValue("EMAIL_USE_TLS", "0"); + setValue("EMAIL_USE_SSL", "1"); + } + if (key === "EMAIL_USE_TLS") { + setValue("EMAIL_USE_TLS", "1"); + setValue("EMAIL_USE_SSL", "0"); + } + if (key === "NONE") { + setValue("EMAIL_USE_TLS", "0"); + setValue("EMAIL_USE_SSL", "0"); + } + }; + + return ( +
+
+ setIsSendTestEmailModalOpen(false)} /> +
+ {emailFormFields.map((field) => ( + + ))} +
+

Email security

+ + {Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => ( + + {value} + + ))} + +
+
+
+
+
+
+
Authentication
+
+ This is optional, but we recommend setting up a username and a password for your SMTP server. +
+
+
+
+
+ {OptionalEmailFormFields.map((field) => ( + + ))} +
+
+
+
+ + +
+
+ ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/email/layout.tsx b/apps/admin/app/(all)/(dashboard)/email/layout.tsx new file mode 100644 index 00000000..0e6fc06c --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/email/layout.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; + +interface EmailLayoutProps { + children: ReactNode; +} + +export const metadata: Metadata = { + title: "Email Settings - God Mode", +}; + +export default function EmailLayout({ children }: EmailLayoutProps) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/email/page.tsx b/apps/admin/app/(all)/(dashboard)/email/page.tsx new file mode 100644 index 00000000..a509f6d2 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/email/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; +// hooks +import { useInstance } from "@/hooks/store"; +// components +import { InstanceEmailForm } from "./email-config-form"; + +const InstanceEmailPage: React.FC = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance(); + + const { isLoading } = useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSMTPEnabled, setIsSMTPEnabled] = useState(false); + + const handleToggle = async () => { + if (isSMTPEnabled) { + setIsSubmitting(true); + try { + await disableEmail(); + setIsSMTPEnabled(false); + setToast({ + title: "Email feature disabled", + message: "Email feature has been disabled", + type: TOAST_TYPE.SUCCESS, + }); + } catch (_error) { + setToast({ + title: "Error disabling email", + message: "Failed to disable email feature. Please try again.", + type: TOAST_TYPE.ERROR, + }); + } finally { + setIsSubmitting(false); + } + return; + } + setIsSMTPEnabled(true); + }; + useEffect(() => { + if (formattedConfig) { + setIsSMTPEnabled(formattedConfig.ENABLE_SMTP === "1"); + } + }, [formattedConfig]); + + return ( + <> +
+
+
+
Secure emails from your own instance
+
+ Plane can send useful emails to you and your users from your own instance without talking to the Internet. +
+ Set it up below and please test your settings before you save them.  + Misconfigs can lead to email bounces and errors. +
+
+
+ {isLoading ? ( + + + + ) : ( + + )} +
+ {isSMTPEnabled && !isLoading && ( +
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+ )} +
+ + ); +}); + +export default InstanceEmailPage; diff --git a/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx b/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx new file mode 100644 index 00000000..09117096 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx @@ -0,0 +1,137 @@ +import type { FC } from "react"; +import React, { useEffect, useState } from "react"; +import { Dialog, Transition } from "@headlessui/react"; +// plane imports +import { Button } from "@plane/propel/button"; +import { InstanceService } from "@plane/services"; +// ui +import { Input } from "@plane/ui"; + +type Props = { + isOpen: boolean; + handleClose: () => void; +}; + +enum ESendEmailSteps { + SEND_EMAIL = "SEND_EMAIL", + SUCCESS = "SUCCESS", + FAILED = "FAILED", +} + +const instanceService = new InstanceService(); + +export const SendTestEmailModal: FC = (props) => { + const { isOpen, handleClose } = props; + + // state + const [receiverEmail, setReceiverEmail] = useState(""); + const [sendEmailStep, setSendEmailStep] = useState(ESendEmailSteps.SEND_EMAIL); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + // reset state + const resetState = () => { + setReceiverEmail(""); + setSendEmailStep(ESendEmailSteps.SEND_EMAIL); + setIsLoading(false); + setError(""); + }; + + useEffect(() => { + if (!isOpen) { + resetState(); + } + }, [isOpen]); + + const handleSubmit = async (e: React.MouseEvent) => { + e.preventDefault(); + + setIsLoading(true); + await instanceService + .sendTestEmail(receiverEmail) + .then(() => { + setSendEmailStep(ESendEmailSteps.SUCCESS); + }) + .catch((error) => { + setError(error?.error || "Failed to send email"); + setSendEmailStep(ESendEmailSteps.FAILED); + }) + .finally(() => { + setIsLoading(false); + }); + }; + + return ( + + + +
+ +
+
+ + +

+ {sendEmailStep === ESendEmailSteps.SEND_EMAIL + ? "Send test email" + : sendEmailStep === ESendEmailSteps.SUCCESS + ? "Email send" + : "Failed"}{" "} +

+
+ {sendEmailStep === ESendEmailSteps.SEND_EMAIL && ( + setReceiverEmail(e.target.value)} + placeholder="Receiver email" + className="w-full resize-none text-lg" + tabIndex={1} + /> + )} + {sendEmailStep === ESendEmailSteps.SUCCESS && ( +
+

+ We have sent the test email to {receiverEmail}. Please check your spam folder if you cannot find + it. +

+

If you still cannot find it, recheck your SMTP configuration and trigger a new test email.

+
+ )} + {sendEmailStep === ESendEmailSteps.FAILED &&
{error}
} +
+ + {sendEmailStep === ESendEmailSteps.SEND_EMAIL && ( + + )} +
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/general/form.tsx b/apps/admin/app/(all)/(dashboard)/general/form.tsx new file mode 100644 index 00000000..c91069b5 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/general/form.tsx @@ -0,0 +1,157 @@ +"use client"; +import type { FC } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { Telescope } from "lucide-react"; +// types +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IInstance, IInstanceAdmin } from "@plane/types"; +// ui +import { Input, ToggleSwitch } from "@plane/ui"; +// components +import { ControllerInput } from "@/components/common/controller-input"; +import { useInstance } from "@/hooks/store"; +import { IntercomConfig } from "./intercom"; +// hooks + +export interface IGeneralConfigurationForm { + instance: IInstance; + instanceAdmins: IInstanceAdmin[]; +} + +export const GeneralConfigurationForm: FC = observer((props) => { + const { instance, instanceAdmins } = props; + // hooks + const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance(); + + // form data + const { + handleSubmit, + control, + watch, + formState: { errors, isSubmitting }, + } = useForm>({ + defaultValues: { + instance_name: instance?.instance_name, + is_telemetry_enabled: instance?.is_telemetry_enabled, + }, + }); + + const onSubmit = async (formData: Partial) => { + const payload: Partial = { ...formData }; + + // update the intercom configuration + const isIntercomEnabled = + instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"; + if (!payload.is_telemetry_enabled && isIntercomEnabled) { + try { + await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" }); + } catch (error) { + console.error(error); + } + } + + await updateInstanceInfo(payload) + .then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+
Instance details
+
+ + +
+

Email

+ +
+ +
+

Instance ID

+ +
+
+
+ +
+
Chat + telemetry
+ +
+
+
+
+ +
+
+
+
+ Let Plane collect anonymous usage data +
+
+ No PII is collected.This anonymized data is used to understand how you use Plane and build new features + in line with{" "} + + our Telemetry Policy. + +
+
+
+
+ ( + + )} + /> +
+
+
+ +
+ +
+
+ ); +}); diff --git a/apps/admin/app/(all)/(dashboard)/general/intercom.tsx b/apps/admin/app/(all)/(dashboard)/general/intercom.tsx new file mode 100644 index 00000000..a6f17d62 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/general/intercom.tsx @@ -0,0 +1,83 @@ +"use client"; + +import type { FC } from "react"; +import { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { MessageSquare } from "lucide-react"; +import type { IFormattedInstanceConfiguration } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +// hooks +import { useInstance } from "@/hooks/store"; + +type TIntercomConfig = { + isTelemetryEnabled: boolean; +}; + +export const IntercomConfig: FC = observer((props) => { + const { isTelemetryEnabled } = props; + // hooks + const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance(); + // states + const [isSubmitting, setIsSubmitting] = useState(false); + + // derived values + const isIntercomEnabled = isTelemetryEnabled + ? instanceConfigurations + ? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1" + ? true + : false + : undefined + : false; + + const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () => + isTelemetryEnabled ? fetchInstanceConfigurations() : null + ); + + const initialLoader = isLoading && isIntercomEnabled === undefined; + + const submitInstanceConfigurations = async (payload: Partial) => { + try { + await updateInstanceConfigurations(payload); + } catch (error) { + console.error(error); + } finally { + setIsSubmitting(false); + } + }; + + const enableIntercomConfig = () => { + submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" }); + }; + + return ( + <> +
+
+
+
+ +
+
+ +
+
Chat with us
+
+ Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off + automatically. +
+
+ +
+ +
+
+
+ + ); +}); diff --git a/apps/admin/app/(all)/(dashboard)/general/layout.tsx b/apps/admin/app/(all)/(dashboard)/general/layout.tsx new file mode 100644 index 00000000..f5167e75 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/general/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "General Settings - God Mode", +}; + +export default function GeneralLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/general/page.tsx b/apps/admin/app/(all)/(dashboard)/general/page.tsx new file mode 100644 index 00000000..f0d32f26 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/general/page.tsx @@ -0,0 +1,31 @@ +"use client"; +import { observer } from "mobx-react"; +// hooks +import { useInstance } from "@/hooks/store"; +// components +import { GeneralConfigurationForm } from "./form"; + +function GeneralPage() { + const { instance, instanceAdmins } = useInstance(); + + return ( + <> +
+
+
General settings
+
+ Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your + instance. +
+
+
+ {instance && instanceAdmins && ( + + )} +
+
+ + ); +} + +export default observer(GeneralPage); diff --git a/apps/admin/app/(all)/(dashboard)/header.tsx b/apps/admin/app/(all)/(dashboard)/header.tsx new file mode 100644 index 00000000..82d7241f --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/header.tsx @@ -0,0 +1,105 @@ +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +import { Menu, Settings } from "lucide-react"; +// icons +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +// hooks +import { useTheme } from "@/hooks/store"; + +export const HamburgerToggle: FC = observer(() => { + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + return ( +
toggleSidebar(!isSidebarCollapsed)} + > + +
+ ); +}); + +export const AdminHeader: FC = observer(() => { + const pathName = usePathname(); + + const getHeaderTitle = (pathName: string) => { + switch (pathName) { + case "general": + return "General"; + case "ai": + return "Artificial Intelligence"; + case "email": + return "Email"; + case "authentication": + return "Authentication"; + case "image": + return "Image"; + case "google": + return "Google"; + case "github": + return "GitHub"; + case "gitlab": + return "GitLab"; + case "workspace": + return "Workspace"; + case "create": + return "Create"; + default: + return pathName.toUpperCase(); + } + }; + + // Function to dynamically generate breadcrumb items based on pathname + const generateBreadcrumbItems = (pathname: string) => { + const pathSegments = pathname.split("/").slice(1); // removing the first empty string. + pathSegments.pop(); + + let currentUrl = ""; + const breadcrumbItems = pathSegments.map((segment) => { + currentUrl += "/" + segment; + return { + title: getHeaderTitle(segment), + href: currentUrl, + }; + }); + return breadcrumbItems; + }; + + const breadcrumbItems = generateBreadcrumbItems(pathName); + + return ( +
+
+ + {breadcrumbItems.length >= 0 && ( +
+ + } + /> + } + /> + {breadcrumbItems.map( + (item) => + item.title && ( + } + /> + ) + )} + +
+ )} +
+
+ ); +}); diff --git a/apps/admin/app/(all)/(dashboard)/image/form.tsx b/apps/admin/app/(all)/(dashboard)/image/form.tsx new file mode 100644 index 00000000..f6adcaee --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/image/form.tsx @@ -0,0 +1,81 @@ +"use client"; +import type { FC } from "react"; +import { useForm } from "react-hook-form"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types"; +// components +import { ControllerInput } from "@/components/common/controller-input"; +// hooks +import { useInstance } from "@/hooks/store"; + +type IInstanceImageConfigForm = { + config: IFormattedInstanceConfiguration; +}; + +type ImageConfigFormValues = Record; + +export const InstanceImageConfigForm: FC = (props) => { + const { config } = props; + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + UNSPLASH_ACCESS_KEY: config["UNSPLASH_ACCESS_KEY"], + }, + }); + + const onSubmit = async (formData: ImageConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Image Configuration Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+ + You will find your access key in your Unsplash developer console.  + + Learn more. + + + } + placeholder="oXgq-sdfadsaeweqasdfasdf3234234rassd" + error={Boolean(errors.UNSPLASH_ACCESS_KEY)} + required + /> +
+ +
+ +
+
+ ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/image/layout.tsx b/apps/admin/app/(all)/(dashboard)/image/layout.tsx new file mode 100644 index 00000000..559a15f9 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/image/layout.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; + +interface ImageLayoutProps { + children: ReactNode; +} + +export const metadata: Metadata = { + title: "Images Settings - God Mode", +}; + +export default function ImageLayout({ children }: ImageLayoutProps) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/image/page.tsx b/apps/admin/app/(all)/(dashboard)/image/page.tsx new file mode 100644 index 00000000..ade9687d --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/image/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { Loader } from "@plane/ui"; +// hooks +import { useInstance } from "@/hooks/store"; +// local +import { InstanceImageConfigForm } from "./form"; + +const InstanceImagePage = observer(() => { + // store + const { formattedConfig, fetchInstanceConfigurations } = useInstance(); + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + return ( + <> +
+
+
Third-party image libraries
+
+ Let your users search and choose images from third-party libraries +
+
+
+ {formattedConfig ? ( + + ) : ( + + + + + )} +
+
+ + ); +}); + +export default InstanceImagePage; diff --git a/apps/admin/app/(all)/(dashboard)/layout.tsx b/apps/admin/app/(all)/(dashboard)/layout.tsx new file mode 100644 index 00000000..76d74f46 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/layout.tsx @@ -0,0 +1,57 @@ +"use client"; + +import type { FC, ReactNode } from "react"; +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { NewUserPopup } from "@/components/new-user-popup"; +// hooks +import { useUser } from "@/hooks/store"; +// local components +import { AdminHeader } from "./header"; +import { AdminSidebar } from "./sidebar"; + +type TAdminLayout = { + children: ReactNode; +}; + +const AdminLayout: FC = (props) => { + const { children } = props; + // router + const router = useRouter(); + // store hooks + const { isUserLoggedIn } = useUser(); + + useEffect(() => { + if (isUserLoggedIn === false) { + router.push("/"); + } + }, [router, isUserLoggedIn]); + + if (isUserLoggedIn === undefined) { + return ( +
+ +
+ ); + } + + if (isUserLoggedIn) { + return ( +
+ +
+ +
{children}
+
+ +
+ ); + } + + return <>; +}; + +export default observer(AdminLayout); diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx new file mode 100644 index 00000000..656d0531 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Fragment, useEffect, useState } from "react"; +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 internal packages +import { API_BASE_URL } from "@plane/constants"; +import { AuthService } from "@plane/services"; +import { Avatar } from "@plane/ui"; +import { getFileURL, cn } from "@plane/utils"; +// hooks +import { useTheme, useUser } from "@/hooks/store"; + +// service initialization +const authService = new AuthService(); + +export const AdminSidebarDropdown = observer(() => { + // store hooks + const { isSidebarCollapsed } = useTheme(); + const { currentUser, signOut } = useUser(); + // hooks + const { resolvedTheme, setTheme } = useNextTheme(); + // state + const [csrfToken, setCsrfToken] = useState(undefined); + + const handleThemeSwitch = () => { + const newTheme = resolvedTheme === "dark" ? "light" : "dark"; + setTheme(newTheme); + }; + + const handleSignOut = () => signOut(); + + const getSidebarMenuItems = () => ( + +
+ {currentUser?.email} +
+
+ + + Switch to {resolvedTheme === "dark" ? "light" : "dark"} mode + +
+
+
+ + + + Sign out + +
+
+
+ ); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + return ( +
+
+
+ + +
+ +
+
+ {isSidebarCollapsed && ( + + {getSidebarMenuItems()} + + )} +
+ + {!isSidebarCollapsed && ( +
+

Instance admin

+
+ )} +
+
+ + {!isSidebarCollapsed && currentUser && ( + + + + + + + {getSidebarMenuItems()} + + + )} +
+ ); +}); diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx new file mode 100644 index 00000000..cf479119 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx @@ -0,0 +1,141 @@ +"use client"; + +import type { FC } from "react"; +import { useState, useRef } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { ExternalLink, HelpCircle, MoveLeft } from "lucide-react"; +import { Transition } from "@headlessui/react"; +// plane internal packages +import { WEB_BASE_URL } from "@plane/constants"; +import { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; +// hooks +import { useTheme } from "@/hooks/store"; +// assets +// eslint-disable-next-line import/order +import packageJson from "package.json"; + +const helpOptions = [ + { + name: "Documentation", + href: "https://docs.plane.so/", + Icon: PageIcon, + }, + { + name: "Join our Discord", + href: "https://discord.com/invite/A92xrEGCge", + Icon: DiscordIcon, + }, + { + name: "Report a bug", + href: "https://github.com/makeplane/plane/issues/new/choose", + Icon: GithubIcon, + }, +]; + +export const AdminSidebarHelpSection: FC = observer(() => { + // states + const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); + // store + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + // refs + const helpOptionsRef = useRef(null); + + const redirectionLink = encodeURI(WEB_BASE_URL + "/"); + + return ( +
+
+ + + + {!isSidebarCollapsed && "Redirect to Plane"} + + + + + + + + +
+ +
+ +
+
+ {helpOptions.map(({ name, Icon, href }) => { + if (href) + return ( + +
+
+ +
+ {name} +
+ + ); + else + return ( + + ); + })} +
+
Version: v{packageJson.version}
+
+
+
+
+ ); +}); diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx new file mode 100644 index 00000000..b33ccecf --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { observer } from "mobx-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; +// plane internal packages +import { WorkspaceIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; +// hooks +import { useTheme } from "@/hooks/store"; + +const INSTANCE_ADMIN_LINKS = [ + { + Icon: Cog, + name: "General", + description: "Identify your instances and get key details.", + href: `/general/`, + }, + { + Icon: WorkspaceIcon, + name: "Workspaces", + description: "Manage all workspaces on this instance.", + href: `/workspace/`, + }, + { + Icon: Mail, + name: "Email", + description: "Configure your SMTP controls.", + href: `/email/`, + }, + { + Icon: Lock, + name: "Authentication", + description: "Configure authentication modes.", + href: `/authentication/`, + }, + { + Icon: BrainCog, + name: "Artificial intelligence", + description: "Configure your OpenAI creds.", + href: `/ai/`, + }, + { + Icon: Image, + name: "Images in Plane", + description: "Allow third-party image libraries.", + href: `/image/`, + }, +]; + +export const AdminSidebarMenu = observer(() => { + // store hooks + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + // router + const pathName = usePathname(); + + const handleItemClick = () => { + if (window.innerWidth < 768) { + toggleSidebar(!isSidebarCollapsed); + } + }; + + return ( +
+ {INSTANCE_ADMIN_LINKS.map((item, index) => { + const isActive = item.href === pathName || pathName.includes(item.href); + return ( + +
+ +
+ {} + {!isSidebarCollapsed && ( +
+
+ {item.name} +
+
+ {item.description} +
+
+ )} +
+
+
+ + ); + })} +
+ ); +}); diff --git a/apps/admin/app/(all)/(dashboard)/sidebar.tsx b/apps/admin/app/(all)/(dashboard)/sidebar.tsx new file mode 100644 index 00000000..e37d6eb5 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/sidebar.tsx @@ -0,0 +1,59 @@ +"use client"; + +import type { FC } from "react"; +import { useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +// plane helpers +import { useOutsideClickDetector } from "@plane/hooks"; +// hooks +import { useTheme } from "@/hooks/store"; +// components +import { AdminSidebarDropdown } from "./sidebar-dropdown"; +import { AdminSidebarHelpSection } from "./sidebar-help-section"; +import { AdminSidebarMenu } from "./sidebar-menu"; + +export const AdminSidebar: FC = observer(() => { + // store + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + + const ref = useRef(null); + + useOutsideClickDetector(ref, () => { + if (isSidebarCollapsed === false) { + if (window.innerWidth < 768) { + toggleSidebar(!isSidebarCollapsed); + } + } + }); + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth <= 768) { + toggleSidebar(true); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [toggleSidebar]); + + return ( +
+
+ + + +
+
+ ); +}); diff --git a/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx new file mode 100644 index 00000000..6ec3fe4a --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx @@ -0,0 +1,212 @@ +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Controller, useForm } from "react-hook-form"; +// plane imports +import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { InstanceWorkspaceService } from "@plane/services"; +import type { IWorkspace } from "@plane/types"; +// components +import { CustomSelect, Input } from "@plane/ui"; +// hooks +import { useWorkspace } from "@/hooks/store"; + +const instanceWorkspaceService = new InstanceWorkspaceService(); + +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 instanceWorkspaceService + .slugCheck(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/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx new file mode 100644 index 00000000..0186286a --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/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/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx b/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx new file mode 100644 index 00000000..4749e2f7 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Workspace Management - God Mode", +}; + +export default function WorkspaceManagementLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/workspace/page.tsx b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx new file mode 100644 index 00000000..a03c443d --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx @@ -0,0 +1,170 @@ +"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 { Button, getButtonStyling } from "@plane/propel/button"; +import { setPromiseToast } from "@plane/propel/toast"; +import type { TInstanceConfigurationKeys } from "@plane/types"; +import { Loader, ToggleSwitch } from "@plane/ui"; + +import { cn } from "@plane/utils"; +// components +import { WorkspaceListItem } from "@/components/workspace/list-item"; +// 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/apps/admin/app/(all)/(home)/auth-banner.tsx b/apps/admin/app/(all)/(home)/auth-banner.tsx new file mode 100644 index 00000000..c0a9a0e9 --- /dev/null +++ b/apps/admin/app/(all)/(home)/auth-banner.tsx @@ -0,0 +1,29 @@ +import type { FC } from "react"; +import { Info, X } from "lucide-react"; +// plane constants +import type { TAdminAuthErrorInfo } from "@plane/constants"; + +type TAuthBanner = { + bannerData: TAdminAuthErrorInfo | undefined; + handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void; +}; + +export const AuthBanner: FC = (props) => { + const { bannerData, handleBannerData } = props; + + if (!bannerData) return <>; + return ( +
+
+ +
+
{bannerData?.message}
+
handleBannerData && handleBannerData(undefined)} + > + +
+
+ ); +}; diff --git a/apps/admin/app/(all)/(home)/auth-header.tsx b/apps/admin/app/(all)/(home)/auth-header.tsx new file mode 100644 index 00000000..115c8538 --- /dev/null +++ b/apps/admin/app/(all)/(home)/auth-header.tsx @@ -0,0 +1,12 @@ +"use client"; + +import Link from "next/link"; +import { PlaneLockup } from "@plane/propel/icons"; + +export const AuthHeader = () => ( +
+ + + +
+); diff --git a/apps/admin/app/(all)/(home)/auth-helpers.tsx b/apps/admin/app/(all)/(home)/auth-helpers.tsx new file mode 100644 index 00000000..4da6d7ec --- /dev/null +++ b/apps/admin/app/(all)/(home)/auth-helpers.tsx @@ -0,0 +1,163 @@ +import type { ReactNode } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { KeyRound, Mails } from "lucide-react"; +// plane packages +import type { TAdminAuthErrorInfo } from "@plane/constants"; +import { SUPPORT_EMAIL, EAdminAuthErrorCodes } from "@plane/constants"; +import type { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types"; +import { resolveGeneralTheme } from "@plane/utils"; +// components +import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; +import { GithubConfiguration } from "@/components/authentication/github-config"; +import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; +import { GoogleConfiguration } from "@/components/authentication/google-config"; +import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch"; +// images +import githubLightModeImage from "@/public/logos/github-black.png"; +import githubDarkModeImage from "@/public/logos/github-white.png"; +import GitlabLogo from "@/public/logos/gitlab-logo.svg"; +import GoogleLogo from "@/public/logos/google-logo.svg"; + +export enum EErrorAlertType { + BANNER_ALERT = "BANNER_ALERT", + INLINE_FIRST_NAME = "INLINE_FIRST_NAME", + INLINE_EMAIL = "INLINE_EMAIL", + INLINE_PASSWORD = "INLINE_PASSWORD", + INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", +} + +const errorCodeMessages: { + [key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; +} = { + // admin + [EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: { + title: `Admin already exists`, + message: () => `Admin already exists. Please try again.`, + }, + [EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { + title: `Email, password and first name required`, + message: () => `Email, password and first name required. Please try again.`, + }, + [EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL]: { + title: `Invalid admin email`, + message: () => `Invalid admin email. Please try again.`, + }, + [EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD]: { + title: `Invalid admin password`, + message: () => `Invalid admin password. Please try again.`, + }, + [EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: { + title: `Admin user already exists`, + message: () => ( +
+ Admin user already exists.  + + Sign In + +  now. +
+ ), + }, + [EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { + title: `Admin user does not exist`, + message: () => ( +
+ Admin user does not exist.  + + Sign In + +  now. +
+ ), + }, + [EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: { + title: `User account deactivated`, + message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`, + }, +}; + +export const authErrorHandler = ( + errorCode: EAdminAuthErrorCodes, + email?: string | undefined +): TAdminAuthErrorInfo | undefined => { + const bannerAlertErrorCodes = [ + EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST, + EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, + EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL, + EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD, + EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD, + EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED, + EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST, + EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED, + ]; + + if (bannerAlertErrorCodes.includes(errorCode)) + return { + type: EErrorAlertType.BANNER_ALERT, + code: errorCode, + title: errorCodeMessages[errorCode]?.title || "Error", + message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", + }; + + return undefined; +}; + +export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({ + disabled, + updateConfig, + resolvedTheme, +}) => [ + { + key: "unique-codes", + name: "Unique codes", + description: + "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.", + icon: , + config: , + }, + { + key: "passwords-login", + name: "Passwords", + description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", + icon: , + config: , + }, + { + key: "google", + name: "Google", + description: "Allow members to log in or sign up for Plane with their Google accounts.", + icon: Google Logo, + config: , + }, + { + key: "github", + name: "GitHub", + description: "Allow members to log in or sign up for Plane with their GitHub accounts.", + icon: ( + GitHub Logo + ), + config: , + }, + { + key: "gitlab", + name: "GitLab", + description: "Allow members to log in or sign up to plane with their GitLab accounts.", + icon: GitLab Logo, + config: , + }, +]; diff --git a/apps/admin/app/(all)/(home)/layout.tsx b/apps/admin/app/(all)/(home)/layout.tsx new file mode 100644 index 00000000..25638c67 --- /dev/null +++ b/apps/admin/app/(all)/(home)/layout.tsx @@ -0,0 +1,9 @@ +"use client"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/admin/app/(all)/(home)/page.tsx b/apps/admin/app/(all)/(home)/page.tsx new file mode 100644 index 00000000..e6ebdf45 --- /dev/null +++ b/apps/admin/app/(all)/(home)/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { InstanceFailureView } from "@/components/instance/failure"; +import { InstanceSetupForm } from "@/components/instance/setup-form"; +// hooks +import { useInstance } from "@/hooks/store"; +// components +import { InstanceSignInForm } from "./sign-in-form"; + +const HomePage = () => { + // store hooks + const { instance, error } = useInstance(); + + // if instance is not fetched, show loading + if (!instance && !error) { + return ( +
+ +
+ ); + } + + // if instance fetch fails, show failure view + if (error) { + return ; + } + + // if instance is fetched and setup is not done, show setup form + if (instance && !instance?.is_setup_done) { + return ; + } + + // if instance is fetched and setup is done, show sign in form + return ; +}; + +export default observer(HomePage); diff --git a/apps/admin/app/(all)/(home)/sign-in-form.tsx b/apps/admin/app/(all)/(home)/sign-in-form.tsx new file mode 100644 index 00000000..2049bda6 --- /dev/null +++ b/apps/admin/app/(all)/(home)/sign-in-form.tsx @@ -0,0 +1,196 @@ +"use client"; + +import type { FC } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Eye, EyeOff } from "lucide-react"; +// plane internal packages +import type { EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants"; +import { API_BASE_URL } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { AuthService } from "@plane/services"; +import { Input, Spinner } from "@plane/ui"; +// components +import { Banner } from "@/components/common/banner"; +// local components +import { FormHeader } from "../../../core/components/instance/form-header"; +import { AuthBanner } from "./auth-banner"; +import { AuthHeader } from "./auth-header"; +import { authErrorHandler } from "./auth-helpers"; + +// service initialization +const authService = new AuthService(); + +// error codes +enum EErrorCodes { + INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED", + REQUIRED_EMAIL_PASSWORD = "REQUIRED_EMAIL_PASSWORD", + INVALID_EMAIL = "INVALID_EMAIL", + USER_DOES_NOT_EXIST = "USER_DOES_NOT_EXIST", + AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED", +} + +type TError = { + type: EErrorCodes | undefined; + message: string | undefined; +}; + +// form data +type TFormData = { + email: string; + password: string; +}; + +const defaultFromData: TFormData = { + email: "", + password: "", +}; + +export const InstanceSignInForm: FC = () => { + // search params + const searchParams = useSearchParams(); + const emailParam = searchParams.get("email") || undefined; + const errorCode = searchParams.get("error_code") || undefined; + const errorMessage = searchParams.get("error_message") || undefined; + // state + const [showPassword, setShowPassword] = useState(false); + const [csrfToken, setCsrfToken] = useState(undefined); + const [formData, setFormData] = useState(defaultFromData); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorInfo, setErrorInfo] = useState(undefined); + + const handleFormChange = (key: keyof TFormData, value: string | boolean) => + setFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + useEffect(() => { + if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam })); + }, [emailParam]); + + // derived values + const errorData: TError = useMemo(() => { + if (errorCode && errorMessage) { + switch (errorCode) { + case EErrorCodes.INSTANCE_NOT_CONFIGURED: + return { type: EErrorCodes.INSTANCE_NOT_CONFIGURED, message: errorMessage }; + case EErrorCodes.REQUIRED_EMAIL_PASSWORD: + return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD, message: errorMessage }; + case EErrorCodes.INVALID_EMAIL: + return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage }; + case EErrorCodes.USER_DOES_NOT_EXIST: + return { type: EErrorCodes.USER_DOES_NOT_EXIST, message: errorMessage }; + case EErrorCodes.AUTHENTICATION_FAILED: + return { type: EErrorCodes.AUTHENTICATION_FAILED, message: errorMessage }; + default: + return { type: undefined, message: undefined }; + } + } else return { type: undefined, message: undefined }; + }, [errorCode, errorMessage]); + + const isButtonDisabled = useMemo( + () => (!isSubmitting && formData.email && formData.password ? false : true), + [formData.email, formData.password, isSubmitting] + ); + + useEffect(() => { + if (errorCode) { + const errorDetail = authErrorHandler(errorCode?.toString() as EAdminAuthErrorCodes); + if (errorDetail) { + setErrorInfo(errorDetail); + } + } + }, [errorCode]); + + return ( + <> + +
+
+ +
setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + {errorData.type && errorData?.message ? ( + + ) : ( + <> + {errorInfo && setErrorInfo(value)} />} + + )} + + +
+ + handleFormChange("email", e.target.value)} + autoComplete="on" + autoFocus + /> +
+ +
+ +
+ handleFormChange("password", e.target.value)} + autoComplete="on" + /> + {showPassword ? ( + + ) : ( + + )} +
+
+
+ +
+ +
+
+ + ); +}; diff --git a/apps/admin/app/(all)/instance.provider.tsx b/apps/admin/app/(all)/instance.provider.tsx new file mode 100644 index 00000000..19e15ec5 --- /dev/null +++ b/apps/admin/app/(all)/instance.provider.tsx @@ -0,0 +1,23 @@ +import type { FC, ReactNode } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// hooks +import { useInstance } from "@/hooks/store"; + +type InstanceProviderProps = { + children: ReactNode; +}; + +export const InstanceProvider: FC = observer((props) => { + const { children } = props; + // store hooks + const { fetchInstanceInfo } = useInstance(); + // fetching instance details + useSWR("INSTANCE_DETAILS", () => fetchInstanceInfo(), { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + }); + + return <>{children}; +}); diff --git a/apps/admin/app/(all)/layout.tsx b/apps/admin/app/(all)/layout.tsx new file mode 100644 index 00000000..ddfba732 --- /dev/null +++ b/apps/admin/app/(all)/layout.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { ThemeProvider } from "next-themes"; +import { SWRConfig } from "swr"; +// providers +import { InstanceProvider } from "./instance.provider"; +import { StoreProvider } from "./store.provider"; +import { ToastWithTheme } from "./toast"; +import { UserProvider } from "./user.provider"; + +const DEFAULT_SWR_CONFIG = { + refreshWhenHidden: false, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnMount: true, + refreshInterval: 600000, + errorRetryCount: 3, +}; + +export default function InstanceLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + + ); +} diff --git a/apps/admin/app/(all)/store.provider.tsx b/apps/admin/app/(all)/store.provider.tsx new file mode 100644 index 00000000..648a3711 --- /dev/null +++ b/apps/admin/app/(all)/store.provider.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { ReactNode } from "react"; +import { createContext } from "react"; +// plane admin store +import { RootStore } from "@/plane-admin/store/root.store"; + +let rootStore = new RootStore(); + +export const StoreContext = createContext(rootStore); + +function initializeStore(initialData = {}) { + const singletonRootStore = rootStore ?? new RootStore(); + // If your page has Next.js data fetching methods that use a Mobx store, it will + // get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details + if (initialData) { + singletonRootStore.hydrate(initialData); + } + // For SSG and SSR always create a new store + if (typeof window === "undefined") return singletonRootStore; + // Create the store once in the client + if (!rootStore) rootStore = singletonRootStore; + return singletonRootStore; +} + +export type StoreProviderProps = { + children: ReactNode; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initialState?: any; +}; + +export const StoreProvider = ({ children, initialState = {} }: StoreProviderProps) => { + const store = initializeStore(initialState); + return {children}; +}; diff --git a/apps/admin/app/(all)/toast.tsx b/apps/admin/app/(all)/toast.tsx new file mode 100644 index 00000000..9cd1c46a --- /dev/null +++ b/apps/admin/app/(all)/toast.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Toast } from "@plane/propel/toast"; +import { resolveGeneralTheme } from "@plane/utils"; + +export const ToastWithTheme = () => { + const { resolvedTheme } = useTheme(); + return ; +}; diff --git a/apps/admin/app/(all)/user.provider.tsx b/apps/admin/app/(all)/user.provider.tsx new file mode 100644 index 00000000..e026c31d --- /dev/null +++ b/apps/admin/app/(all)/user.provider.tsx @@ -0,0 +1,33 @@ +"use client"; + +import type { FC, ReactNode } from "react"; +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// hooks +import { useInstance, useTheme, useUser } from "@/hooks/store"; + +interface IUserProvider { + children: ReactNode; +} + +export const UserProvider: FC = observer(({ children }) => { + // hooks + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + const { currentUser, fetchCurrentUser } = useUser(); + const { fetchInstanceAdmins } = useInstance(); + + useSWR("CURRENT_USER", () => fetchCurrentUser(), { + shouldRetryOnError: false, + }); + + useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins()); + + useEffect(() => { + const localValue = localStorage && localStorage.getItem("god_mode_sidebar_collapsed"); + const localBoolValue = localValue ? (localValue === "true" ? true : false) : false; + if (isSidebarCollapsed === undefined && localBoolValue != isSidebarCollapsed) toggleSidebar(localBoolValue); + }, [isSidebarCollapsed, currentUser, toggleSidebar]); + + return <>{children}; +}); diff --git a/apps/admin/app/error.tsx b/apps/admin/app/error.tsx new file mode 100644 index 00000000..76794e04 --- /dev/null +++ b/apps/admin/app/error.tsx @@ -0,0 +1,9 @@ +"use client"; + +export default function RootErrorPage() { + return ( +
+

Something went wrong.

+
+ ); +} diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx new file mode 100644 index 00000000..b9cdd17c --- /dev/null +++ b/apps/admin/app/layout.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; +// plane imports +import { ADMIN_BASE_PATH } from "@plane/constants"; +// styles +import "@/styles/globals.css"; + +export const metadata: Metadata = { + title: "Plane | Simple, extensible, open-source project management tool.", + description: + "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.", + openGraph: { + title: "Plane | Simple, extensible, open-source project management tool.", + description: + "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.", + url: "https://plane.so/", + }, + keywords: + "software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + }, +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + const ASSET_PREFIX = ADMIN_BASE_PATH; + return ( + + + + + + + + + {children} + + ); +} diff --git a/apps/admin/ce/components/authentication/authentication-modes.tsx b/apps/admin/ce/components/authentication/authentication-modes.tsx new file mode 100644 index 00000000..386e0c05 --- /dev/null +++ b/apps/admin/ce/components/authentication/authentication-modes.tsx @@ -0,0 +1,121 @@ +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import { KeyRound, Mails } from "lucide-react"; +// types +import type { + TGetBaseAuthenticationModeProps, + TInstanceAuthenticationMethodKeys, + TInstanceAuthenticationModes, +} from "@plane/types"; +import { resolveGeneralTheme } from "@plane/utils"; +// components +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; +import { GithubConfiguration } from "@/components/authentication/github-config"; +import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; +import { GoogleConfiguration } from "@/components/authentication/google-config"; +import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch"; +// plane admin components +import { UpgradeButton } from "@/plane-admin/components/common"; +// assets +import githubLightModeImage from "@/public/logos/github-black.png"; +import githubDarkModeImage from "@/public/logos/github-white.png"; +import GitlabLogo from "@/public/logos/gitlab-logo.svg"; +import GoogleLogo from "@/public/logos/google-logo.svg"; +import OIDCLogo from "@/public/logos/oidc-logo.svg"; +import SAMLLogo from "@/public/logos/saml-logo.svg"; + +export type TAuthenticationModeProps = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +// Authentication methods +export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({ + disabled, + updateConfig, + resolvedTheme, +}) => [ + { + key: "unique-codes", + name: "Unique codes", + description: + "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.", + icon: , + config: , + }, + { + key: "passwords-login", + name: "Passwords", + description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", + icon: , + config: , + }, + { + key: "google", + name: "Google", + description: "Allow members to log in or sign up for Plane with their Google accounts.", + icon: Google Logo, + config: , + }, + { + key: "github", + name: "GitHub", + description: "Allow members to log in or sign up for Plane with their GitHub accounts.", + icon: ( + GitHub Logo + ), + config: , + }, + { + key: "gitlab", + name: "GitLab", + description: "Allow members to log in or sign up to plane with their GitLab accounts.", + icon: GitLab Logo, + config: , + }, + { + key: "oidc", + name: "OIDC", + description: "Authenticate your users via the OpenID Connect protocol.", + icon: OIDC Logo, + config: , + unavailable: true, + }, + { + key: "saml", + name: "SAML", + description: "Authenticate your users via the Security Assertion Markup Language protocol.", + icon: SAML Logo, + config: , + unavailable: true, + }, +]; + +export const AuthenticationModes: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // next-themes + const { resolvedTheme } = useTheme(); + + return ( + <> + {getAuthenticationModes({ disabled, updateConfig, resolvedTheme }).map((method) => ( + + ))} + + ); +}); diff --git a/apps/admin/ce/components/authentication/index.ts b/apps/admin/ce/components/authentication/index.ts new file mode 100644 index 00000000..d2aa7485 --- /dev/null +++ b/apps/admin/ce/components/authentication/index.ts @@ -0,0 +1 @@ +export * from "./authentication-modes"; diff --git a/apps/admin/ce/components/common/index.ts b/apps/admin/ce/components/common/index.ts new file mode 100644 index 00000000..c6a1da8b --- /dev/null +++ b/apps/admin/ce/components/common/index.ts @@ -0,0 +1 @@ +export * from "./upgrade-button"; diff --git a/apps/admin/ce/components/common/upgrade-button.tsx b/apps/admin/ce/components/common/upgrade-button.tsx new file mode 100644 index 00000000..14a955f2 --- /dev/null +++ b/apps/admin/ce/components/common/upgrade-button.tsx @@ -0,0 +1,15 @@ +"use client"; + +import React from "react"; +// icons +import { SquareArrowOutUpRight } from "lucide-react"; +// plane internal packages +import { getButtonStyling } from "@plane/propel/button"; +import { cn } from "@plane/utils"; + +export const UpgradeButton: React.FC = () => ( + + Upgrade + + +); diff --git a/apps/admin/ce/store/root.store.ts b/apps/admin/ce/store/root.store.ts new file mode 100644 index 00000000..1be816f7 --- /dev/null +++ b/apps/admin/ce/store/root.store.ts @@ -0,0 +1,19 @@ +import { enableStaticRendering } from "mobx-react"; +// stores +import { CoreRootStore } from "@/store/root.store"; + +enableStaticRendering(typeof window === "undefined"); + +export class RootStore extends CoreRootStore { + constructor() { + super(); + } + + hydrate(initialData: any) { + super.hydrate(initialData); + } + + resetOnSignOut() { + super.resetOnSignOut(); + } +} diff --git a/apps/admin/core/components/authentication/authentication-method-card.tsx b/apps/admin/core/components/authentication/authentication-method-card.tsx new file mode 100644 index 00000000..df8e6dba --- /dev/null +++ b/apps/admin/core/components/authentication/authentication-method-card.tsx @@ -0,0 +1,56 @@ +"use client"; + +import type { FC } from "react"; +// helpers +import { cn } from "@plane/utils"; + +type Props = { + name: string; + description: string; + icon: React.ReactNode; + config: React.ReactNode; + disabled?: boolean; + withBorder?: boolean; + unavailable?: boolean; +}; + +export const AuthenticationMethodCard: FC = (props) => { + const { name, description, icon, config, disabled = false, withBorder = true, unavailable = false } = props; + + return ( +
+
+
+
{icon}
+
+
+
+ {name} +
+
+ {description} +
+
+
+
{config}
+
+ ); +}; diff --git a/apps/admin/core/components/authentication/email-config-switch.tsx b/apps/admin/core/components/authentication/email-config-switch.tsx new file mode 100644 index 00000000..3a2a5f54 --- /dev/null +++ b/apps/admin/core/components/authentication/email-config-switch.tsx @@ -0,0 +1,35 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// hooks +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +import { useInstance } from "@/hooks/store"; +// ui +// types + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const EmailCodesConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableMagicLogin = formattedConfig?.ENABLE_MAGIC_LINK_LOGIN ?? ""; + + return ( + { + const newEnableMagicLogin = Boolean(parseInt(enableMagicLogin)) === true ? "0" : "1"; + updateConfig("ENABLE_MAGIC_LINK_LOGIN", newEnableMagicLogin); + }} + size="sm" + disabled={disabled} + /> + ); +}); diff --git a/apps/admin/core/components/authentication/github-config.tsx b/apps/admin/core/components/authentication/github-config.tsx new file mode 100644 index 00000000..33219145 --- /dev/null +++ b/apps/admin/core/components/authentication/github-config.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// icons +import { Settings2 } from "lucide-react"; +// plane internal packages +import { getButtonStyling } from "@plane/propel/button"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const GithubConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? ""; + const isGithubConfigured = !!formattedConfig?.GITHUB_CLIENT_ID && !!formattedConfig?.GITHUB_CLIENT_SECRET; + + return ( + <> + {isGithubConfigured ? ( +
+ + Edit + + { + const newEnableGithubConfig = Boolean(parseInt(enableGithubConfig)) === true ? "0" : "1"; + updateConfig("IS_GITHUB_ENABLED", newEnableGithubConfig); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/apps/admin/core/components/authentication/gitlab-config.tsx b/apps/admin/core/components/authentication/gitlab-config.tsx new file mode 100644 index 00000000..6f0294c3 --- /dev/null +++ b/apps/admin/core/components/authentication/gitlab-config.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// icons +import { Settings2 } from "lucide-react"; +// plane internal packages +import { getButtonStyling } from "@plane/propel/button"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const GitlabConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? ""; + const isGitlabConfigured = !!formattedConfig?.GITLAB_CLIENT_ID && !!formattedConfig?.GITLAB_CLIENT_SECRET; + + return ( + <> + {isGitlabConfigured ? ( +
+ + Edit + + { + const newEnableGitlabConfig = Boolean(parseInt(enableGitlabConfig)) === true ? "0" : "1"; + updateConfig("IS_GITLAB_ENABLED", newEnableGitlabConfig); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/apps/admin/core/components/authentication/google-config.tsx b/apps/admin/core/components/authentication/google-config.tsx new file mode 100644 index 00000000..ae0cecf3 --- /dev/null +++ b/apps/admin/core/components/authentication/google-config.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// icons +import { Settings2 } from "lucide-react"; +// plane internal packages +import { getButtonStyling } from "@plane/propel/button"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const GoogleConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? ""; + const isGoogleConfigured = !!formattedConfig?.GOOGLE_CLIENT_ID && !!formattedConfig?.GOOGLE_CLIENT_SECRET; + + return ( + <> + {isGoogleConfigured ? ( +
+ + Edit + + { + const newEnableGoogleConfig = Boolean(parseInt(enableGoogleConfig)) === true ? "0" : "1"; + updateConfig("IS_GOOGLE_ENABLED", newEnableGoogleConfig); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/apps/admin/core/components/authentication/password-config-switch.tsx b/apps/admin/core/components/authentication/password-config-switch.tsx new file mode 100644 index 00000000..1126ff4f --- /dev/null +++ b/apps/admin/core/components/authentication/password-config-switch.tsx @@ -0,0 +1,35 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// hooks +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +import { useInstance } from "@/hooks/store"; +// ui +// types + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const PasswordLoginConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableEmailPassword = formattedConfig?.ENABLE_EMAIL_PASSWORD ?? ""; + + return ( + { + const newEnableEmailPassword = Boolean(parseInt(enableEmailPassword)) === true ? "0" : "1"; + updateConfig("ENABLE_EMAIL_PASSWORD", newEnableEmailPassword); + }} + size="sm" + disabled={disabled} + /> + ); +}); diff --git a/apps/admin/core/components/common/banner.tsx b/apps/admin/core/components/common/banner.tsx new file mode 100644 index 00000000..32bc5bc7 --- /dev/null +++ b/apps/admin/core/components/common/banner.tsx @@ -0,0 +1,32 @@ +import type { FC } from "react"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; + +type TBanner = { + type: "success" | "error"; + message: string; +}; + +export const Banner: FC = (props) => { + const { type, message } = props; + + return ( +
+
+
+ {type === "error" ? ( + + + ) : ( +
+
+

{message}

+
+
+
+ ); +}; diff --git a/apps/admin/core/components/common/breadcrumb-link.tsx b/apps/admin/core/components/common/breadcrumb-link.tsx new file mode 100644 index 00000000..567b88d9 --- /dev/null +++ b/apps/admin/core/components/common/breadcrumb-link.tsx @@ -0,0 +1,38 @@ +"use client"; + +import Link from "next/link"; +import { Tooltip } from "@plane/propel/tooltip"; + +type Props = { + label?: string; + href?: string; + icon?: React.ReactNode | undefined; +}; + +export const BreadcrumbLink: React.FC = (props) => { + const { href, label, icon } = props; + return ( + +
  • +
    + {href ? ( + + {icon && ( +
    {icon}
    + )} +
    {label}
    + + ) : ( +
    + {icon &&
    {icon}
    } +
    {label}
    +
    + )} +
    +
  • +
    + ); +}; diff --git a/apps/admin/core/components/common/code-block.tsx b/apps/admin/core/components/common/code-block.tsx new file mode 100644 index 00000000..88ad78a1 --- /dev/null +++ b/apps/admin/core/components/common/code-block.tsx @@ -0,0 +1,21 @@ +import { cn } from "@plane/utils"; + +type TProps = { + children: React.ReactNode; + className?: string; + darkerShade?: boolean; +}; + +export const CodeBlock = ({ children, className, darkerShade }: TProps) => ( + + {children} + +); diff --git a/apps/admin/core/components/common/confirm-discard-modal.tsx b/apps/admin/core/components/common/confirm-discard-modal.tsx new file mode 100644 index 00000000..d1931f06 --- /dev/null +++ b/apps/admin/core/components/common/confirm-discard-modal.tsx @@ -0,0 +1,74 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// ui +import { Button, getButtonStyling } from "@plane/propel/button"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onDiscardHref: string; +}; + +export const ConfirmDiscardModal: React.FC = (props) => { + const { isOpen, handleClose, onDiscardHref } = props; + + return ( + + + +
    + +
    +
    + + +
    +
    +
    + + You have unsaved changes + +
    +

    + Changes you made will be lost if you go back. Do you wish to go back? +

    +
    +
    +
    +
    +
    + + + Go back + +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/admin/core/components/common/controller-input.tsx b/apps/admin/core/components/common/controller-input.tsx new file mode 100644 index 00000000..4b16ffd0 --- /dev/null +++ b/apps/admin/core/components/common/controller-input.tsx @@ -0,0 +1,84 @@ +"use client"; + +import React, { useState } from "react"; +import type { Control } from "react-hook-form"; +import { Controller } from "react-hook-form"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// plane internal packages +import { Input } from "@plane/ui"; +import { cn } from "@plane/utils"; + +type Props = { + control: Control; + type: "text" | "password"; + name: string; + label: string; + description?: string | React.ReactNode; + placeholder: string; + error: boolean; + required: boolean; +}; + +export type TControllerInputFormField = { + key: string; + type: "text" | "password"; + label: string; + description?: string | React.ReactNode; + placeholder: string; + error: boolean; + required: boolean; +}; + +export const ControllerInput: React.FC = (props) => { + const { name, control, type, label, description, placeholder, error, required } = props; + // states + const [showPassword, setShowPassword] = useState(false); + + return ( +
    +

    {label}

    +
    + ( + + )} + /> + {type === "password" && + (showPassword ? ( + + ) : ( + + ))} +
    + {description &&

    {description}

    } +
    + ); +}; diff --git a/apps/admin/core/components/common/copy-field.tsx b/apps/admin/core/components/common/copy-field.tsx new file mode 100644 index 00000000..4f4f7175 --- /dev/null +++ b/apps/admin/core/components/common/copy-field.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React from "react"; +// ui +import { Copy } from "lucide-react"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; + +type Props = { + label: string; + url: string; + description: string | React.ReactNode; +}; + +export type TCopyField = { + key: string; + label: string; + url: string; + description: string | React.ReactNode; +}; + +export const CopyField: React.FC = (props) => { + const { label, url, description } = props; + + return ( +
    +

    {label}

    + +
    {description}
    +
    + ); +}; diff --git a/apps/admin/core/components/common/empty-state.tsx b/apps/admin/core/components/common/empty-state.tsx new file mode 100644 index 00000000..4bf291f5 --- /dev/null +++ b/apps/admin/core/components/common/empty-state.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +import { Button } from "@plane/propel/button"; + +type Props = { + title: string; + description?: React.ReactNode; + image?: any; + primaryButton?: { + icon?: any; + text: string; + onClick: () => void; + }; + secondaryButton?: React.ReactNode; + disabled?: boolean; +}; + +export const EmptyState: React.FC = ({ + title, + description, + image, + primaryButton, + secondaryButton, + disabled = false, +}) => ( +
    +
    + {image && {primaryButton?.text} +
    {title}
    + {description &&

    {description}

    } +
    + {primaryButton && ( + + )} + {secondaryButton} +
    +
    +
    +); diff --git a/apps/admin/core/components/common/logo-spinner.tsx b/apps/admin/core/components/common/logo-spinner.tsx new file mode 100644 index 00000000..fda44fca --- /dev/null +++ b/apps/admin/core/components/common/logo-spinner.tsx @@ -0,0 +1,17 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif"; +import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif"; + +export const LogoSpinner = () => { + const { resolvedTheme } = useTheme(); + + const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark; + + return ( +
    + logo +
    + ); +}; diff --git a/apps/admin/core/components/common/page-header.tsx b/apps/admin/core/components/common/page-header.tsx new file mode 100644 index 00000000..a4b27b92 --- /dev/null +++ b/apps/admin/core/components/common/page-header.tsx @@ -0,0 +1,17 @@ +"use client"; + +type TPageHeader = { + title?: string; + description?: string; +}; + +export const PageHeader: React.FC = (props) => { + const { title = "God Mode - Plane", description = "Plane god mode" } = props; + + return ( + <> + {title} + + + ); +}; diff --git a/apps/admin/core/components/instance/failure.tsx b/apps/admin/core/components/instance/failure.tsx new file mode 100644 index 00000000..97ace834 --- /dev/null +++ b/apps/admin/core/components/instance/failure.tsx @@ -0,0 +1,42 @@ +"use client"; +import type { FC } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import { Button } from "@plane/propel/button"; +// assets +import { AuthHeader } from "@/app/(all)/(home)/auth-header"; +import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg"; +import InstanceFailureImage from "@/public/instance/instance-failure.svg"; + +export const InstanceFailureView: FC = observer(() => { + const { resolvedTheme } = useTheme(); + + const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; + + const handleRetry = () => { + window.location.reload(); + }; + + return ( + <> + +
    +
    +
    + Plane Logo +

    Unable to fetch instance details.

    +

    + We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue. +

    +
    +
    + +
    +
    +
    + + ); +}); diff --git a/apps/admin/core/components/instance/form-header.tsx b/apps/admin/core/components/instance/form-header.tsx new file mode 100644 index 00000000..d915ad29 --- /dev/null +++ b/apps/admin/core/components/instance/form-header.tsx @@ -0,0 +1,8 @@ +"use client"; + +export const FormHeader = ({ heading, subHeading }: { heading: string; subHeading: string }) => ( +
    + {heading} + {subHeading} +
    +); diff --git a/apps/admin/core/components/instance/instance-not-ready.tsx b/apps/admin/core/components/instance/instance-not-ready.tsx new file mode 100644 index 00000000..b01d938b --- /dev/null +++ b/apps/admin/core/components/instance/instance-not-ready.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { FC } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Button } from "@plane/propel/button"; +// assets +import PlaneTakeOffImage from "@/public/images/plane-takeoff.png"; + +export const InstanceNotReady: FC = () => ( +
    +
    +
    +

    Welcome aboard Plane!

    + Plane Logo +

    + Get started by setting up your instance and workspace +

    +
    + +
    + + + +
    +
    +
    +); diff --git a/apps/admin/core/components/instance/loading.tsx b/apps/admin/core/components/instance/loading.tsx new file mode 100644 index 00000000..27dc4ae6 --- /dev/null +++ b/apps/admin/core/components/instance/loading.tsx @@ -0,0 +1,17 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif"; +import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif"; + +export const InstanceLoading = () => { + const { resolvedTheme } = useTheme(); + + const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark; + + return ( +
    + logo +
    + ); +}; diff --git a/apps/admin/core/components/instance/setup-form.tsx b/apps/admin/core/components/instance/setup-form.tsx new file mode 100644 index 00000000..a4d59b68 --- /dev/null +++ b/apps/admin/core/components/instance/setup-form.tsx @@ -0,0 +1,355 @@ +"use client"; + +import type { FC } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// plane internal packages +import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { AuthService } from "@plane/services"; +import { Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; +import { getPasswordStrength } from "@plane/utils"; +// components +import { AuthHeader } from "@/app/(all)/(home)/auth-header"; +import { Banner } from "@/components/common/banner"; +import { FormHeader } from "@/components/instance/form-header"; + +// service initialization +const authService = new AuthService(); + +// error codes +enum EErrorCodes { + INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED", + ADMIN_ALREADY_EXIST = "ADMIN_ALREADY_EXIST", + REQUIRED_EMAIL_PASSWORD_FIRST_NAME = "REQUIRED_EMAIL_PASSWORD_FIRST_NAME", + INVALID_EMAIL = "INVALID_EMAIL", + INVALID_PASSWORD = "INVALID_PASSWORD", + USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS", +} + +type TError = { + type: EErrorCodes | undefined; + message: string | undefined; +}; + +// form data +type TFormData = { + first_name: string; + last_name: string; + email: string; + company_name: string; + password: string; + confirm_password?: string; + is_telemetry_enabled: boolean; +}; + +const defaultFromData: TFormData = { + first_name: "", + last_name: "", + email: "", + company_name: "", + password: "", + is_telemetry_enabled: true, +}; + +export const InstanceSetupForm: FC = (props) => { + const {} = props; + // search params + const searchParams = useSearchParams(); + const firstNameParam = searchParams.get("first_name") || undefined; + const lastNameParam = searchParams.get("last_name") || undefined; + const companyParam = searchParams.get("company") || undefined; + const emailParam = searchParams.get("email") || undefined; + const isTelemetryEnabledParam = (searchParams.get("is_telemetry_enabled") === "True" ? true : false) || true; + const errorCode = searchParams.get("error_code") || undefined; + const errorMessage = searchParams.get("error_message") || undefined; + // state + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [csrfToken, setCsrfToken] = useState(undefined); + const [formData, setFormData] = useState(defaultFromData); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TFormData, value: string | boolean) => + setFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + useEffect(() => { + if (firstNameParam) setFormData((prev) => ({ ...prev, first_name: firstNameParam })); + if (lastNameParam) setFormData((prev) => ({ ...prev, last_name: lastNameParam })); + if (companyParam) setFormData((prev) => ({ ...prev, company_name: companyParam })); + if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam })); + if (isTelemetryEnabledParam) setFormData((prev) => ({ ...prev, is_telemetry_enabled: isTelemetryEnabledParam })); + }, [firstNameParam, lastNameParam, companyParam, emailParam, isTelemetryEnabledParam]); + + // derived values + const errorData: TError = useMemo(() => { + if (errorCode && errorMessage) { + switch (errorCode) { + case EErrorCodes.INSTANCE_NOT_CONFIGURED: + return { type: EErrorCodes.INSTANCE_NOT_CONFIGURED, message: errorMessage }; + case EErrorCodes.ADMIN_ALREADY_EXIST: + return { type: EErrorCodes.ADMIN_ALREADY_EXIST, message: errorMessage }; + case EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME: + return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME, message: errorMessage }; + case EErrorCodes.INVALID_EMAIL: + return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage }; + case EErrorCodes.INVALID_PASSWORD: + return { type: EErrorCodes.INVALID_PASSWORD, message: errorMessage }; + case EErrorCodes.USER_ALREADY_EXISTS: + return { type: EErrorCodes.USER_ALREADY_EXISTS, message: errorMessage }; + default: + return { type: undefined, message: undefined }; + } + } else return { type: undefined, message: undefined }; + }, [errorCode, errorMessage]); + + const isButtonDisabled = useMemo( + () => + !isSubmitting && + formData.first_name && + formData.email && + formData.password && + getPasswordStrength(formData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID && + formData.password === formData.confirm_password + ? false + : true, + [formData.confirm_password, formData.email, formData.first_name, formData.password, isSubmitting] + ); + + const password = formData?.password ?? ""; + const confirmPassword = formData?.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + return ( + <> + +
    +
    + + {errorData.type && + errorData?.message && + ![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && ( + + )} +
    setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + + + +
    +
    + + handleFormChange("first_name", e.target.value)} + autoComplete="on" + autoFocus + /> +
    +
    + + handleFormChange("last_name", e.target.value)} + autoComplete="on" + /> +
    +
    + +
    + + handleFormChange("email", e.target.value)} + hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false} + autoComplete="on" + /> + {errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && ( +

    {errorData.message}

    + )} +
    + +
    + + handleFormChange("company_name", e.target.value)} + /> +
    + +
    + +
    + handleFormChange("password", e.target.value)} + hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="on" + /> + {showPassword.password ? ( + + ) : ( + + )} +
    + {errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && ( +

    {errorData.message}

    + )} + +
    + +
    + +
    + handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400" + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + {showPassword.retypePassword ? ( + + ) : ( + + )} +
    + {!!formData.confirm_password && + formData.password !== formData.confirm_password && + renderPasswordMatchError && Passwords don{"'"}t match} +
    + +
    +
    + handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} + checked={formData.is_telemetry_enabled} + /> +
    + +
    + +
    + +
    +
    +
    +
    + + ); +}; diff --git a/apps/admin/core/components/new-user-popup.tsx b/apps/admin/core/components/new-user-popup.tsx new file mode 100644 index 00000000..4f0e0236 --- /dev/null +++ b/apps/admin/core/components/new-user-popup.tsx @@ -0,0 +1,53 @@ +"use client"; + +import React from "react"; +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/propel/button"; +import { resolveGeneralTheme } from "@plane/utils"; +// hooks +import { useTheme } from "@/hooks/store"; +// icons +import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg"; +import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg"; + +export const NewUserPopup: React.FC = observer(() => { + // hooks + const { isNewUserPopup, toggleNewUserPopup } = useTheme(); + // theme + const { resolvedTheme } = nextUseTheme(); + + if (!isNewUserPopup) return <>; + return ( +
    +
    +
    +
    Create workspace
    +
    + Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first + workspace. +
    +
    + + Create workspace + + +
    +
    +
    + Plane icon +
    +
    +
    + ); +}); diff --git a/apps/admin/core/components/workspace/list-item.tsx b/apps/admin/core/components/workspace/list-item.tsx new file mode 100644 index 00000000..85a2b3c6 --- /dev/null +++ b/apps/admin/core/components/workspace/list-item.tsx @@ -0,0 +1,81 @@ +import { observer } from "mobx-react"; +import { ExternalLink } from "lucide-react"; +// plane internal packages +import { WEB_BASE_URL } from "@plane/constants"; +import { Tooltip } from "@plane/propel/tooltip"; +import { getFileURL } from "@plane/utils"; +// 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/apps/admin/core/hooks/store/index.ts b/apps/admin/core/hooks/store/index.ts new file mode 100644 index 00000000..ed178129 --- /dev/null +++ b/apps/admin/core/hooks/store/index.ts @@ -0,0 +1,4 @@ +export * from "./use-theme"; +export * from "./use-instance"; +export * from "./use-user"; +export * from "./use-workspace"; diff --git a/apps/admin/core/hooks/store/use-instance.tsx b/apps/admin/core/hooks/store/use-instance.tsx new file mode 100644 index 00000000..5917df3f --- /dev/null +++ b/apps/admin/core/hooks/store/use-instance.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/app/(all)/store.provider"; +import type { IInstanceStore } from "@/store/instance.store"; + +export const useInstance = (): IInstanceStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useInstance must be used within StoreProvider"); + return context.instance; +}; diff --git a/apps/admin/core/hooks/store/use-theme.tsx b/apps/admin/core/hooks/store/use-theme.tsx new file mode 100644 index 00000000..d5a1e820 --- /dev/null +++ b/apps/admin/core/hooks/store/use-theme.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/app/(all)/store.provider"; +import type { IThemeStore } from "@/store/theme.store"; + +export const useTheme = (): IThemeStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useTheme must be used within StoreProvider"); + return context.theme; +}; diff --git a/apps/admin/core/hooks/store/use-user.tsx b/apps/admin/core/hooks/store/use-user.tsx new file mode 100644 index 00000000..56b988eb --- /dev/null +++ b/apps/admin/core/hooks/store/use-user.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/app/(all)/store.provider"; +import type { IUserStore } from "@/store/user.store"; + +export const useUser = (): IUserStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUser must be used within StoreProvider"); + return context.user; +}; diff --git a/apps/admin/core/hooks/store/use-workspace.tsx b/apps/admin/core/hooks/store/use-workspace.tsx new file mode 100644 index 00000000..c4578c91 --- /dev/null +++ b/apps/admin/core/hooks/store/use-workspace.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/app/(all)/store.provider"; +import type { 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/apps/admin/core/store/instance.store.ts b/apps/admin/core/store/instance.store.ts new file mode 100644 index 00000000..ec892292 --- /dev/null +++ b/apps/admin/core/store/instance.store.ts @@ -0,0 +1,218 @@ +import { set } from "lodash-es"; +import { observable, action, computed, makeObservable, runInAction } from "mobx"; +// plane internal packages +import type { TInstanceStatus } from "@plane/constants"; +import { EInstanceStatus } from "@plane/constants"; +import { InstanceService } from "@plane/services"; +import type { + IInstance, + IInstanceAdmin, + IInstanceConfiguration, + IFormattedInstanceConfiguration, + IInstanceInfo, + IInstanceConfig, +} from "@plane/types"; +// root store +import type { CoreRootStore } from "@/store/root.store"; + +export interface IInstanceStore { + // issues + isLoading: boolean; + error: any; + instanceStatus: TInstanceStatus | undefined; + instance: IInstance | undefined; + config: IInstanceConfig | undefined; + instanceAdmins: IInstanceAdmin[] | undefined; + instanceConfigurations: IInstanceConfiguration[] | undefined; + // computed + formattedConfig: IFormattedInstanceConfiguration | undefined; + // action + hydrate: (data: IInstanceInfo) => void; + fetchInstanceInfo: () => Promise; + updateInstanceInfo: (data: Partial) => Promise; + fetchInstanceAdmins: () => Promise; + fetchInstanceConfigurations: () => Promise; + updateInstanceConfigurations: (data: Partial) => Promise; + disableEmail: () => Promise; +} + +export class InstanceStore implements IInstanceStore { + isLoading: boolean = true; + error: any = undefined; + instanceStatus: TInstanceStatus | undefined = undefined; + instance: IInstance | undefined = undefined; + config: IInstanceConfig | undefined = undefined; + instanceAdmins: IInstanceAdmin[] | undefined = undefined; + instanceConfigurations: IInstanceConfiguration[] | undefined = undefined; + // service + instanceService; + + constructor(private store: CoreRootStore) { + makeObservable(this, { + // observable + isLoading: observable.ref, + error: observable.ref, + instanceStatus: observable, + instance: observable, + instanceAdmins: observable, + instanceConfigurations: observable, + // computed + formattedConfig: computed, + // actions + hydrate: action, + fetchInstanceInfo: action, + fetchInstanceAdmins: action, + updateInstanceInfo: action, + fetchInstanceConfigurations: action, + updateInstanceConfigurations: action, + }); + + this.instanceService = new InstanceService(); + } + + hydrate = (data: IInstanceInfo) => { + if (data) { + this.instance = data.instance; + this.config = data.config; + } + }; + + /** + * computed value for instance configurations data for forms. + * @returns configurations in the form of {key, value} pair. + */ + get formattedConfig() { + if (!this.instanceConfigurations) return undefined; + return this.instanceConfigurations?.reduce((formData: IFormattedInstanceConfiguration, config) => { + formData[config.key] = config.value; + return formData; + }, {} as IFormattedInstanceConfiguration); + } + + /** + * @description fetching instance configuration + * @returns {IInstance} instance + */ + fetchInstanceInfo = async () => { + try { + if (this.instance === undefined) this.isLoading = true; + this.error = undefined; + const instanceInfo = await this.instanceService.info(); + // handling the new user popup toggle + if (this.instance === undefined && !instanceInfo?.instance?.workspaces_exist) + this.store.theme.toggleNewUserPopup(); + runInAction(() => { + // console.log("instanceInfo: ", instanceInfo); + this.isLoading = false; + this.instance = instanceInfo.instance; + this.config = instanceInfo.config; + }); + return instanceInfo; + } catch (error) { + console.error("Error fetching the instance info"); + this.isLoading = false; + this.error = { message: "Failed to fetch the instance info" }; + this.instanceStatus = { + status: EInstanceStatus.ERROR, + }; + throw error; + } + }; + + /** + * @description updating instance information + * @param {Partial} data + * @returns void + */ + updateInstanceInfo = async (data: Partial) => { + try { + const instanceResponse = await this.instanceService.update(data); + if (instanceResponse) { + runInAction(() => { + if (this.instance) set(this.instance, "instance", instanceResponse); + }); + } + return instanceResponse; + } catch (error) { + console.error("Error updating the instance info"); + throw error; + } + }; + + /** + * @description fetching instance admins + * @return {IInstanceAdmin[]} instanceAdmins + */ + fetchInstanceAdmins = async () => { + try { + const instanceAdmins = await this.instanceService.admins(); + if (instanceAdmins) runInAction(() => (this.instanceAdmins = instanceAdmins)); + return instanceAdmins; + } catch (error) { + console.error("Error fetching the instance admins"); + throw error; + } + }; + + /** + * @description fetching instance configurations + * @return {IInstanceAdmin[]} instanceConfigurations + */ + fetchInstanceConfigurations = async () => { + try { + const instanceConfigurations = await this.instanceService.configurations(); + if (instanceConfigurations) runInAction(() => (this.instanceConfigurations = instanceConfigurations)); + return instanceConfigurations; + } catch (error) { + console.error("Error fetching the instance configurations"); + throw error; + } + }; + + /** + * @description updating instance configurations + * @param data + */ + updateInstanceConfigurations = async (data: Partial) => { + try { + const response = await this.instanceService.updateConfigurations(data); + runInAction(() => { + this.instanceConfigurations = this.instanceConfigurations?.map((config) => { + const item = response.find((item) => item.key === config.key); + if (item) return item; + return config; + }); + }); + return response; + } catch (error) { + console.error("Error updating the instance configurations"); + throw error; + } + }; + + disableEmail = async () => { + const instanceConfigurations = this.instanceConfigurations; + try { + runInAction(() => { + this.instanceConfigurations = this.instanceConfigurations?.map((config) => { + if ( + [ + "EMAIL_HOST", + "EMAIL_PORT", + "EMAIL_HOST_USER", + "EMAIL_HOST_PASSWORD", + "EMAIL_FROM", + "ENABLE_SMTP", + ].includes(config.key) + ) + return { ...config, value: "" }; + return config; + }); + }); + await this.instanceService.disableEmail(); + } catch (_error) { + console.error("Error disabling the email"); + this.instanceConfigurations = instanceConfigurations; + } + }; +} diff --git a/apps/admin/core/store/root.store.ts b/apps/admin/core/store/root.store.ts new file mode 100644 index 00000000..68d11885 --- /dev/null +++ b/apps/admin/core/store/root.store.ts @@ -0,0 +1,41 @@ +import { enableStaticRendering } from "mobx-react"; +// stores +import type { IInstanceStore } from "./instance.store"; +import { InstanceStore } from "./instance.store"; +import type { IThemeStore } from "./theme.store"; +import { ThemeStore } from "./theme.store"; +import type { IUserStore } from "./user.store"; +import { UserStore } from "./user.store"; +import type { IWorkspaceStore } from "./workspace.store"; +import { WorkspaceStore } from "./workspace.store"; + +enableStaticRendering(typeof window === "undefined"); + +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() { + localStorage.setItem("theme", "system"); + this.instance = new InstanceStore(this); + this.user = new UserStore(this); + this.theme = new ThemeStore(this); + this.workspace = new WorkspaceStore(this); + } +} diff --git a/apps/admin/core/store/theme.store.ts b/apps/admin/core/store/theme.store.ts new file mode 100644 index 00000000..4512facd --- /dev/null +++ b/apps/admin/core/store/theme.store.ts @@ -0,0 +1,68 @@ +import { action, observable, makeObservable } from "mobx"; +// root store +import type { CoreRootStore } from "@/store/root.store"; + +type TTheme = "dark" | "light"; +export interface IThemeStore { + // observables + isNewUserPopup: boolean; + theme: string | undefined; + isSidebarCollapsed: boolean | undefined; + // actions + hydrate: (data: any) => void; + toggleNewUserPopup: () => void; + toggleSidebar: (collapsed: boolean) => void; + setTheme: (currentTheme: TTheme) => void; +} + +export class ThemeStore implements IThemeStore { + // observables + isNewUserPopup: boolean = false; + isSidebarCollapsed: boolean | undefined = undefined; + theme: string | undefined = undefined; + + constructor(private store: CoreRootStore) { + makeObservable(this, { + // observables + isNewUserPopup: observable.ref, + isSidebarCollapsed: observable.ref, + theme: observable.ref, + // action + toggleNewUserPopup: action, + toggleSidebar: action, + setTheme: action, + }); + } + + hydrate = (data: any) => { + if (data) this.theme = data; + }; + + /** + * @description Toggle the new user popup modal + */ + toggleNewUserPopup = () => (this.isNewUserPopup = !this.isNewUserPopup); + + /** + * @description Toggle the sidebar collapsed state + * @param isCollapsed + */ + toggleSidebar = (isCollapsed: boolean) => { + if (isCollapsed === undefined) this.isSidebarCollapsed = !this.isSidebarCollapsed; + else this.isSidebarCollapsed = isCollapsed; + localStorage.setItem("god_mode_sidebar_collapsed", isCollapsed.toString()); + }; + + /** + * @description Sets the user theme and applies it to the platform + * @param currentTheme + */ + setTheme = async (currentTheme: TTheme) => { + try { + localStorage.setItem("theme", currentTheme); + this.theme = currentTheme; + } catch (error) { + console.error("setting user theme error", error); + } + }; +} diff --git a/apps/admin/core/store/user.store.ts b/apps/admin/core/store/user.store.ts new file mode 100644 index 00000000..1187355a --- /dev/null +++ b/apps/admin/core/store/user.store.ts @@ -0,0 +1,103 @@ +import { action, observable, runInAction, makeObservable } from "mobx"; +// plane internal packages +import type { TUserStatus } from "@plane/constants"; +import { EUserStatus } from "@plane/constants"; +import { AuthService, UserService } from "@plane/services"; +import type { IUser } from "@plane/types"; +// root store +import type { CoreRootStore } from "@/store/root.store"; + +export interface IUserStore { + // observables + isLoading: boolean; + userStatus: TUserStatus | undefined; + isUserLoggedIn: boolean | undefined; + currentUser: IUser | undefined; + // fetch actions + hydrate: (data: any) => void; + fetchCurrentUser: () => Promise; + reset: () => void; + signOut: () => void; +} + +export class UserStore implements IUserStore { + // observables + isLoading: boolean = true; + userStatus: TUserStatus | undefined = undefined; + isUserLoggedIn: boolean | undefined = undefined; + currentUser: IUser | undefined = undefined; + // services + userService; + authService; + + constructor(private store: CoreRootStore) { + makeObservable(this, { + // observables + isLoading: observable.ref, + userStatus: observable, + isUserLoggedIn: observable.ref, + currentUser: observable, + // action + fetchCurrentUser: action, + reset: action, + signOut: action, + }); + this.userService = new UserService(); + this.authService = new AuthService(); + } + + hydrate = (data: any) => { + if (data) this.currentUser = data; + }; + + /** + * @description Fetches the current user + * @returns Promise + */ + fetchCurrentUser = async () => { + try { + if (this.currentUser === undefined) this.isLoading = true; + const currentUser = await this.userService.adminDetails(); + if (currentUser) { + await this.store.instance.fetchInstanceAdmins(); + runInAction(() => { + this.isUserLoggedIn = true; + this.currentUser = currentUser; + this.isLoading = false; + }); + } else { + runInAction(() => { + this.isUserLoggedIn = false; + this.currentUser = undefined; + this.isLoading = false; + }); + } + return currentUser; + } catch (error: any) { + this.isLoading = false; + this.isUserLoggedIn = false; + if (error.status === 403) + this.userStatus = { + status: EUserStatus.AUTHENTICATION_NOT_DONE, + message: error?.message || "", + }; + else + this.userStatus = { + status: EUserStatus.ERROR, + message: error?.message || "", + }; + throw error; + } + }; + + reset = async () => { + this.isUserLoggedIn = false; + this.currentUser = undefined; + this.isLoading = false; + this.userStatus = undefined; + }; + + signOut = async () => { + this.store.resetOnSignOut(); + }; +} diff --git a/apps/admin/core/store/workspace.store.ts b/apps/admin/core/store/workspace.store.ts new file mode 100644 index 00000000..f9203ed4 --- /dev/null +++ b/apps/admin/core/store/workspace.store.ts @@ -0,0 +1,150 @@ +import { set } from "lodash-es"; +import { action, observable, runInAction, makeObservable, computed } from "mobx"; +// plane imports +import { InstanceWorkspaceService } from "@plane/services"; +import type { IWorkspace, TLoader, TPaginationInfo } from "@plane/types"; +// root store +import type { 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 + instanceWorkspaceService; + + 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.instanceWorkspaceService = new InstanceWorkspaceService(); + } + + // 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.instanceWorkspaceService.list(); + 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.instanceWorkspaceService.list(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.instanceWorkspaceService.create(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/apps/admin/ee/components/authentication/authentication-modes.tsx b/apps/admin/ee/components/authentication/authentication-modes.tsx new file mode 100644 index 00000000..4e3b05a5 --- /dev/null +++ b/apps/admin/ee/components/authentication/authentication-modes.tsx @@ -0,0 +1 @@ +export * from "ce/components/authentication/authentication-modes"; diff --git a/apps/admin/ee/components/authentication/index.ts b/apps/admin/ee/components/authentication/index.ts new file mode 100644 index 00000000..d2aa7485 --- /dev/null +++ b/apps/admin/ee/components/authentication/index.ts @@ -0,0 +1 @@ +export * from "./authentication-modes"; diff --git a/apps/admin/ee/components/common/index.ts b/apps/admin/ee/components/common/index.ts new file mode 100644 index 00000000..60441ee2 --- /dev/null +++ b/apps/admin/ee/components/common/index.ts @@ -0,0 +1 @@ +export * from "ce/components/common"; diff --git a/apps/admin/ee/store/root.store.ts b/apps/admin/ee/store/root.store.ts new file mode 100644 index 00000000..c514c4c2 --- /dev/null +++ b/apps/admin/ee/store/root.store.ts @@ -0,0 +1 @@ +export * from "ce/store/root.store"; diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 00000000..40c3d680 --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js new file mode 100644 index 00000000..c848e0b9 --- /dev/null +++ b/apps/admin/next.config.js @@ -0,0 +1,29 @@ +/** @type {import('next').NextConfig} */ + +const nextConfig = { + trailingSlash: true, + reactStrictMode: false, + swcMinify: true, + output: "standalone", + images: { + unoptimized: true, + }, + basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "", + experimental: { + optimizePackageImports: [ + "@plane/constants", + "@plane/editor", + "@plane/hooks", + "@plane/i18n", + "@plane/logger", + "@plane/propel", + "@plane/services", + "@plane/shared-state", + "@plane/types", + "@plane/ui", + "@plane/utils", + ], + }, +}; + +module.exports = nextConfig; diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 00000000..dfa57a7c --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,54 @@ +{ + "name": "admin", + "description": "Admin UI for Plane", + "version": "1.1.0", + "license": "AGPL-3.0", + "private": true, + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "preview": "next build && next start", + "start": "next start", + "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", + "check:lint": "eslint . --max-warnings 19", + "check:types": "tsc --noEmit", + "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", + "fix:lint": "eslint . --fix", + "fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"" + }, + "dependencies": { + "@headlessui/react": "^1.7.19", + "@plane/constants": "workspace:*", + "@plane/hooks": "workspace:*", + "@plane/propel": "workspace:*", + "@plane/services": "workspace:*", + "@plane/types": "workspace:*", + "@plane/ui": "workspace:*", + "@plane/utils": "workspace:*", + "autoprefixer": "10.4.14", + "axios": "catalog:", + "lodash-es": "catalog:", + "lucide-react": "catalog:", + "mobx": "catalog:", + "mobx-react": "catalog:", + "next": "catalog:", + "next-themes": "^0.2.1", + "postcss": "^8.4.49", + "react": "catalog:", + "react-dom": "catalog:", + "react-hook-form": "7.51.5", + "sharp": "catalog:", + "swr": "catalog:", + "uuid": "catalog:" + }, + "devDependencies": { + "@plane/eslint-config": "workspace:*", + "@plane/tailwind-config": "workspace:*", + "@plane/typescript-config": "workspace:*", + "@types/lodash-es": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "typescript": "catalog:" + } +} diff --git a/apps/admin/postcss.config.js b/apps/admin/postcss.config.js new file mode 100644 index 00000000..9b1e55fc --- /dev/null +++ b/apps/admin/postcss.config.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = require("@plane/tailwind-config/postcss.config.js"); diff --git a/apps/admin/public/auth/background-pattern-dark.svg b/apps/admin/public/auth/background-pattern-dark.svg new file mode 100644 index 00000000..c258cbab --- /dev/null +++ b/apps/admin/public/auth/background-pattern-dark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/admin/public/auth/background-pattern.svg b/apps/admin/public/auth/background-pattern.svg new file mode 100644 index 00000000..5fcbeec2 --- /dev/null +++ b/apps/admin/public/auth/background-pattern.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/admin/public/favicon/android-chrome-192x192.png b/apps/admin/public/favicon/android-chrome-192x192.png new file mode 100644 index 00000000..4a005e54 Binary files /dev/null and b/apps/admin/public/favicon/android-chrome-192x192.png differ diff --git a/apps/admin/public/favicon/android-chrome-512x512.png b/apps/admin/public/favicon/android-chrome-512x512.png new file mode 100644 index 00000000..27fafe82 Binary files /dev/null and b/apps/admin/public/favicon/android-chrome-512x512.png differ diff --git a/apps/admin/public/favicon/apple-touch-icon.png b/apps/admin/public/favicon/apple-touch-icon.png new file mode 100644 index 00000000..a6312678 Binary files /dev/null and b/apps/admin/public/favicon/apple-touch-icon.png differ diff --git a/apps/admin/public/favicon/favicon-16x16.png b/apps/admin/public/favicon/favicon-16x16.png new file mode 100644 index 00000000..af59ef01 Binary files /dev/null and b/apps/admin/public/favicon/favicon-16x16.png differ diff --git a/apps/admin/public/favicon/favicon-32x32.png b/apps/admin/public/favicon/favicon-32x32.png new file mode 100644 index 00000000..16a1271a Binary files /dev/null and b/apps/admin/public/favicon/favicon-32x32.png differ diff --git a/apps/admin/public/favicon/favicon.ico b/apps/admin/public/favicon/favicon.ico new file mode 100644 index 00000000..613b1a31 Binary files /dev/null and b/apps/admin/public/favicon/favicon.ico differ diff --git a/apps/admin/public/favicon/site.webmanifest b/apps/admin/public/favicon/site.webmanifest new file mode 100644 index 00000000..1d410578 --- /dev/null +++ b/apps/admin/public/favicon/site.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/apps/admin/public/images/logo-spinner-dark.gif b/apps/admin/public/images/logo-spinner-dark.gif new file mode 100644 index 00000000..8bd08325 Binary files /dev/null and b/apps/admin/public/images/logo-spinner-dark.gif differ diff --git a/apps/admin/public/images/logo-spinner-light.gif b/apps/admin/public/images/logo-spinner-light.gif new file mode 100644 index 00000000..8b571031 Binary files /dev/null and b/apps/admin/public/images/logo-spinner-light.gif differ diff --git a/apps/admin/public/images/plane-takeoff.png b/apps/admin/public/images/plane-takeoff.png new file mode 100644 index 00000000..417ff829 Binary files /dev/null and b/apps/admin/public/images/plane-takeoff.png differ diff --git a/apps/admin/public/instance/instance-failure-dark.svg b/apps/admin/public/instance/instance-failure-dark.svg new file mode 100644 index 00000000..58d69170 --- /dev/null +++ b/apps/admin/public/instance/instance-failure-dark.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/admin/public/instance/instance-failure.svg b/apps/admin/public/instance/instance-failure.svg new file mode 100644 index 00000000..a5986228 --- /dev/null +++ b/apps/admin/public/instance/instance-failure.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/admin/public/instance/plane-takeoff.png b/apps/admin/public/instance/plane-takeoff.png new file mode 100644 index 00000000..417ff829 Binary files /dev/null and b/apps/admin/public/instance/plane-takeoff.png differ diff --git a/apps/admin/public/logos/github-black.png b/apps/admin/public/logos/github-black.png new file mode 100644 index 00000000..7a7a8247 Binary files /dev/null and b/apps/admin/public/logos/github-black.png differ diff --git a/apps/admin/public/logos/github-white.png b/apps/admin/public/logos/github-white.png new file mode 100644 index 00000000..dbb2b578 Binary files /dev/null and b/apps/admin/public/logos/github-white.png differ diff --git a/apps/admin/public/logos/gitlab-logo.svg b/apps/admin/public/logos/gitlab-logo.svg new file mode 100644 index 00000000..dab4d8b7 --- /dev/null +++ b/apps/admin/public/logos/gitlab-logo.svg @@ -0,0 +1 @@ + diff --git a/apps/admin/public/logos/google-logo.svg b/apps/admin/public/logos/google-logo.svg new file mode 100644 index 00000000..088288fa --- /dev/null +++ b/apps/admin/public/logos/google-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/admin/public/logos/oidc-logo.svg b/apps/admin/public/logos/oidc-logo.svg new file mode 100644 index 00000000..68bc72d0 --- /dev/null +++ b/apps/admin/public/logos/oidc-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/admin/public/logos/saml-logo.svg b/apps/admin/public/logos/saml-logo.svg new file mode 100644 index 00000000..4cbb4f81 --- /dev/null +++ b/apps/admin/public/logos/saml-logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/admin/public/logos/takeoff-icon-dark.svg b/apps/admin/public/logos/takeoff-icon-dark.svg new file mode 100644 index 00000000..d3ef1911 --- /dev/null +++ b/apps/admin/public/logos/takeoff-icon-dark.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/admin/public/logos/takeoff-icon-light.svg b/apps/admin/public/logos/takeoff-icon-light.svg new file mode 100644 index 00000000..97cf43fe --- /dev/null +++ b/apps/admin/public/logos/takeoff-icon-light.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/admin/public/site.webmanifest.json b/apps/admin/public/site.webmanifest.json new file mode 100644 index 00000000..6e5e438f --- /dev/null +++ b/apps/admin/public/site.webmanifest.json @@ -0,0 +1,13 @@ +{ + "name": "Plane God Mode", + "short_name": "Plane God Mode", + "description": "Plane helps you plan your issues, cycles, and product modules.", + "start_url": ".", + "display": "standalone", + "background_color": "#f9fafb", + "theme_color": "#3f76ff", + "icons": [ + { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/apps/admin/styles/globals.css b/apps/admin/styles/globals.css new file mode 100644 index 00000000..86a0b851 --- /dev/null +++ b/apps/admin/styles/globals.css @@ -0,0 +1,396 @@ +@import "@plane/propel/styles/fonts"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + .text-1\.5xl { + font-size: 1.375rem; + line-height: 1.875rem; + } + + .text-2\.5xl { + font-size: 1.75rem; + line-height: 2.25rem; + } +} + +@layer base { + html { + font-family: "Inter", sans-serif; + } + + :root { + color-scheme: light !important; + + --color-primary-10: 229, 243, 250; + --color-primary-20: 216, 237, 248; + --color-primary-30: 199, 229, 244; + --color-primary-40: 169, 214, 239; + --color-primary-50: 144, 202, 234; + --color-primary-60: 109, 186, 227; + --color-primary-70: 75, 170, 221; + --color-primary-80: 41, 154, 214; + --color-primary-90: 34, 129, 180; + --color-primary-100: 0, 99, 153; + --color-primary-200: 0, 92, 143; + --color-primary-300: 0, 86, 133; + --color-primary-400: 0, 77, 117; + --color-primary-500: 0, 66, 102; + --color-primary-600: 0, 53, 82; + --color-primary-700: 0, 43, 66; + --color-primary-800: 0, 33, 51; + --color-primary-900: 0, 23, 36; + + --color-background-100: 255, 255, 255; /* primary bg */ + --color-background-90: 247, 247, 247; /* secondary bg */ + --color-background-80: 232, 232, 232; /* tertiary bg */ + + --color-text-100: 23, 23, 23; /* primary text */ + --color-text-200: 58, 58, 58; /* secondary text */ + --color-text-300: 82, 82, 82; /* tertiary text */ + --color-text-400: 163, 163, 163; /* placeholder text */ + + --color-scrollbar: 163, 163, 163; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + + --color-shadow-2xs: + 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06), + 0px 1px 2px 0px rgba(23, 23, 23, 0.14); + --color-shadow-xs: + 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12), + 0px 1px 8px -1px rgba(16, 24, 40, 0.1); + --color-shadow-sm: + 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12); + --color-shadow-rg: + 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08), + 0px 1px 12px 0px rgba(16, 24, 40, 0.04); + --color-shadow-md: + 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), + 0px 1px 16px 0px rgba(16, 24, 40, 0.12); + --color-shadow-lg: + 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12), + 0px 1px 24px 0px rgba(16, 24, 40, 0.12); + --color-shadow-xl: + 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16), + 0px 0px 52px 0px rgba(16, 24, 40, 0.16); + --color-shadow-2xl: + 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12), + 0px 1px 32px 0px rgba(16, 24, 40, 0.12); + --color-shadow-3xl: + 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), + 0px 1px 48px 0px rgba(16, 24, 40, 0.12); + --color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05); + + --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ + --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ + --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */ + + --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */ + --color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */ + --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */ + --color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */ + + --color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */ + --color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */ + --color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */ + --color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */ + + --color-sidebar-shadow-2xs: var(--color-shadow-2xs); + --color-sidebar-shadow-xs: var(--color-shadow-xs); + --color-sidebar-shadow-sm: var(--color-shadow-sm); + --color-sidebar-shadow-rg: var(--color-shadow-rg); + --color-sidebar-shadow-md: var(--color-shadow-md); + --color-sidebar-shadow-lg: var(--color-shadow-lg); + --color-sidebar-shadow-xl: var(--color-shadow-xl); + --color-sidebar-shadow-2xl: var(--color-shadow-2xl); + --color-sidebar-shadow-3xl: var(--color-shadow-3xl); + --color-sidebar-shadow-4xl: var(--color-shadow-4xl); + + /* toast theme */ + --color-toast-success-text: 178, 221, 181; + --color-toast-error-text: 206, 44, 49; + --color-toast-warning-text: 255, 186, 24; + --color-toast-info-text: 141, 164, 239; + --color-toast-loading-text: 255, 255, 255; + --color-toast-secondary-text: 185, 187, 198; + --color-toast-tertiary-text: 139, 141, 152; + + --color-toast-success-background: 46, 46, 46; + --color-toast-error-background: 46, 46, 46; + --color-toast-warning-background: 46, 46, 46; + --color-toast-info-background: 46, 46, 46; + --color-toast-loading-background: 46, 46, 46; + + --color-toast-success-border: 42, 126, 59; + --color-toast-error-border: 100, 23, 35; + --color-toast-warning-border: 79, 52, 34; + --color-toast-info-border: 58, 91, 199; + --color-toast-loading-border: 96, 100, 108; + } + + [data-theme="light"], + [data-theme="light-contrast"] { + color-scheme: light !important; + + --color-background-100: 255, 255, 255; /* primary bg */ + --color-background-90: 247, 247, 247; /* secondary bg */ + --color-background-80: 232, 232, 232; /* tertiary bg */ + } + + [data-theme="light"] { + --color-text-100: 23, 23, 23; /* primary text */ + --color-text-200: 58, 58, 58; /* secondary text */ + --color-text-300: 82, 82, 82; /* tertiary text */ + --color-text-400: 163, 163, 163; /* placeholder text */ + + --color-scrollbar: 163, 163, 163; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + + /* toast theme */ + --color-toast-success-text: 62, 155, 79; + --color-toast-error-text: 220, 62, 66; + --color-toast-warning-text: 255, 186, 24; + --color-toast-info-text: 51, 88, 212; + --color-toast-loading-text: 28, 32, 36; + --color-toast-secondary-text: 128, 131, 141; + --color-toast-tertiary-text: 96, 100, 108; + + --color-toast-success-background: 253, 253, 254; + --color-toast-error-background: 255, 252, 252; + --color-toast-warning-background: 254, 253, 251; + --color-toast-info-background: 253, 253, 254; + --color-toast-loading-background: 253, 253, 254; + + --color-toast-success-border: 218, 241, 219; + --color-toast-error-border: 255, 219, 220; + --color-toast-warning-border: 255, 247, 194; + --color-toast-info-border: 210, 222, 255; + --color-toast-loading-border: 224, 225, 230; + } + + [data-theme="light-contrast"] { + --color-text-100: 11, 11, 11; /* primary text */ + --color-text-200: 38, 38, 38; /* secondary text */ + --color-text-300: 58, 58, 58; /* tertiary text */ + --color-text-400: 115, 115, 115; /* placeholder text */ + + --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ + + --color-border-100: 34, 34, 34; /* subtle border= 1 */ + --color-border-200: 38, 38, 38; /* subtle border- 2 */ + --color-border-300: 46, 46, 46; /* strong border- 1 */ + --color-border-400: 58, 58, 58; /* strong border- 2 */ + } + + [data-theme="dark"], + [data-theme="dark-contrast"] { + color-scheme: dark !important; + + --color-primary-10: 8, 31, 43; + --color-primary-20: 10, 37, 51; + --color-primary-30: 13, 49, 69; + --color-primary-40: 16, 58, 81; + --color-primary-50: 18, 68, 94; + --color-primary-60: 23, 86, 120; + --color-primary-70: 28, 104, 146; + --color-primary-80: 31, 116, 163; + --color-primary-90: 34, 129, 180; + --color-primary-100: 40, 146, 204; + --color-primary-200: 41, 154, 214; + --color-primary-300: 75, 170, 221; + --color-primary-400: 109, 186, 227; + --color-primary-500: 144, 202, 234; + --color-primary-600: 169, 214, 239; + --color-primary-700: 199, 229, 244; + --color-primary-800: 216, 237, 248; + --color-primary-900: 229, 243, 250; + + --color-background-100: 25, 25, 25; /* primary bg */ + --color-background-90: 32, 32, 32; /* secondary bg */ + --color-background-80: 44, 44, 44; /* tertiary bg */ + + --color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.5); + --color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), 0px 2px 4px 0px rgba(0, 0, 0, 0.5); + --color-shadow-sm: 0px 0px 4px 0px rgba(0, 0, 0, 0.2), 0px 2px 6px 0px rgba(0, 0, 0, 0.5); + --color-shadow-rg: 0px 0px 6px 0px rgba(0, 0, 0, 0.2), 0px 4px 6px 0px rgba(0, 0, 0, 0.5); + --color-shadow-md: 0px 2px 8px 0px rgba(0, 0, 0, 0.2), 0px 4px 8px 0px rgba(0, 0, 0, 0.5); + --color-shadow-lg: 0px 4px 12px 0px rgba(0, 0, 0, 0.25), 0px 4px 10px 0px rgba(0, 0, 0, 0.55); + --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); + } + + [data-theme="dark"] { + --color-text-100: 229, 229, 229; /* primary text */ + --color-text-200: 163, 163, 163; /* secondary text */ + --color-text-300: 115, 115, 115; /* tertiary text */ + --color-text-400: 82, 82, 82; /* placeholder text */ + + --color-scrollbar: 82, 82, 82; /* scrollbar thumb */ + + --color-border-100: 34, 34, 34; /* subtle border= 1 */ + --color-border-200: 38, 38, 38; /* subtle border- 2 */ + --color-border-300: 46, 46, 46; /* strong border- 1 */ + --color-border-400: 58, 58, 58; /* strong border- 2 */ + } + + [data-theme="dark-contrast"] { + --color-text-100: 250, 250, 250; /* primary text */ + --color-text-200: 241, 241, 241; /* secondary text */ + --color-text-300: 212, 212, 212; /* tertiary text */ + --color-text-400: 115, 115, 115; /* placeholder text */ + + --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + } + + [data-theme="light"], + [data-theme="dark"], + [data-theme="light-contrast"], + [data-theme="dark-contrast"] { + --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ + --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ + --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */ + + --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */ + --color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */ + --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */ + --color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */ + + --color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */ + --color-sidebar-border-200: var(--color-border-200); /* subtle sidebar border- 2 */ + --color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */ + --color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */ + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + font-variant-ligatures: none; + -webkit-font-variant-ligatures: none; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; +} + +body { + color: rgba(var(--color-text-100)); +} + +/* scrollbar style */ +@-moz-document url-prefix() { + * { + scrollbar-width: none; + } + .vertical-scrollbar, + .horizontal-scrollbar { + scrollbar-width: initial; + scrollbar-color: rgba(96, 100, 108, 0.1) transparent; + } + .vertical-scrollbar:hover, + .horizontal-scrollbar:hover { + scrollbar-color: rgba(96, 100, 108, 0.25) transparent; + } + .vertical-scrollbar:active, + .horizontal-scrollbar:active { + scrollbar-color: rgba(96, 100, 108, 0.7) transparent; + } +} + +.vertical-scrollbar { + overflow-y: auto; +} +.horizontal-scrollbar { + overflow-x: auto; +} +.vertical-scrollbar::-webkit-scrollbar, +.horizontal-scrollbar::-webkit-scrollbar { + display: block; +} +.vertical-scrollbar::-webkit-scrollbar-track, +.horizontal-scrollbar::-webkit-scrollbar-track { + background-color: transparent; + border-radius: 9999px; +} +.vertical-scrollbar::-webkit-scrollbar-thumb, +.horizontal-scrollbar::-webkit-scrollbar-thumb { + background-clip: padding-box; + background-color: rgba(96, 100, 108, 0.1); + border-radius: 9999px; +} +.vertical-scrollbar:hover::-webkit-scrollbar-thumb, +.horizontal-scrollbar:hover::-webkit-scrollbar-thumb { + background-color: rgba(96, 100, 108, 0.25); +} +.vertical-scrollbar::-webkit-scrollbar-thumb:hover, +.horizontal-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(96, 100, 108, 0.5); +} +.vertical-scrollbar::-webkit-scrollbar-thumb:active, +.horizontal-scrollbar::-webkit-scrollbar-thumb:active { + background-color: rgba(96, 100, 108, 0.7); +} +.vertical-scrollbar::-webkit-scrollbar-corner, +.horizontal-scrollbar::-webkit-scrollbar-corner { + background-color: transparent; +} +.vertical-scrollbar-margin-top-md::-webkit-scrollbar-track { + margin-top: 44px; +} + +/* scrollbar sm size */ +.scrollbar-sm::-webkit-scrollbar { + height: 12px; + width: 12px; +} +.scrollbar-sm::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); +} +/* scrollbar md size */ +.scrollbar-md::-webkit-scrollbar { + height: 14px; + width: 14px; +} +.scrollbar-md::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); +} +/* scrollbar lg size */ + +.scrollbar-lg::-webkit-scrollbar { + height: 16px; + width: 16px; +} +.scrollbar-lg::-webkit-scrollbar-thumb { + border: 4px solid rgba(0, 0, 0, 0); +} +/* end scrollbar style */ + +/* progress bar */ +.progress-bar { + fill: currentColor; + color: rgba(var(--color-sidebar-background-100)); +} + +::-webkit-input-placeholder, +::placeholder, +:-ms-input-placeholder { + color: rgb(var(--color-text-400)); +} diff --git a/apps/admin/tailwind.config.js b/apps/admin/tailwind.config.js new file mode 100644 index 00000000..a05d9dcd --- /dev/null +++ b/apps/admin/tailwind.config.js @@ -0,0 +1,6 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +const sharedConfig = require("@plane/tailwind-config/tailwind.config.js"); + +module.exports = { + presets: [sharedConfig], +}; diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 00000000..d85abf2c --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@plane/typescript-config/nextjs.json", + "compilerOptions": { + "plugins": [ + { + "name": "next" + } + ], + "baseUrl": ".", + "paths": { + "@/app/*": ["app/*"], + "@/*": ["core/*"], + "@/public/*": ["public/*"], + "@/plane-admin/*": ["ce/*"], + "@/styles/*": ["styles/*"] + }, + "strictNullChecks": true + }, + "include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/api/.coveragerc b/apps/api/.coveragerc new file mode 100644 index 00000000..bd829d14 --- /dev/null +++ b/apps/api/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = plane +omit = + */tests/* + */migrations/* + */settings/* + */wsgi.py + */asgi.py + */urls.py + manage.py + */admin.py + */apps.py + +[report] +exclude_lines = + pragma: no cover + def __repr__ + if self.debug: + raise NotImplementedError + if __name__ == .__main__. + pass + raise ImportError + +[html] +directory = htmlcov \ No newline at end of file diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 00000000..f158e3d7 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,72 @@ +# Backend +# Debug value for api server use it as 0 for production use +DEBUG=0 +CORS_ALLOWED_ORIGINS="http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3100" + +# Database Settings +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_HOST="plane-db" +POSTGRES_DB="plane" +POSTGRES_PORT=5432 +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + +# Redis Settings +REDIS_HOST="plane-redis" +REDIS_PORT="6379" +REDIS_URL="redis://${REDIS_HOST}:6379/" + +# RabbitMQ Settings +RABBITMQ_HOST="plane-mq" +RABBITMQ_PORT="5672" +RABBITMQ_USER="plane" +RABBITMQ_PASSWORD="plane" +RABBITMQ_VHOST="plane" + +# AWS Settings +AWS_REGION="" +AWS_ACCESS_KEY_ID="access-key" +AWS_SECRET_ACCESS_KEY="secret-key" +AWS_S3_ENDPOINT_URL="http://localhost:9000" +# Changing this requires change in the proxy config for uploads if using minio setup +AWS_S3_BUCKET_NAME="uploads" +# Maximum file upload limit +FILE_SIZE_LIMIT=5242880 + +# Settings related to Docker +DOCKERIZED=1 # deprecated + +# set to 1 If using the pre-configured minio setup +USE_MINIO=0 + + + +# Email redirections and minio domain settings +WEB_URL="http://localhost:8000" + +# Gunicorn Workers +GUNICORN_WORKERS=2 + +# Base URLs +ADMIN_BASE_URL="http://localhost:3001" +ADMIN_BASE_PATH="/god-mode" + +SPACE_BASE_URL="http://localhost:3002" +SPACE_BASE_PATH="/spaces" + +APP_BASE_URL="http://localhost:3000" +APP_BASE_PATH="" + +LIVE_BASE_URL="http://localhost:3100" +LIVE_BASE_PATH="/live" + +LIVE_SERVER_SECRET_KEY="secret-key" + +# Hard delete files after days +HARD_DELETE_AFTER_DAYS=60 + +# Force HTTPS for handling SSL Termination +MINIO_ENDPOINT_SSL=0 + +# API key rate limit +API_KEY_RATE_LIMIT="60/minute" diff --git a/apps/api/Dockerfile.api b/apps/api/Dockerfile.api new file mode 100644 index 00000000..13251481 --- /dev/null +++ b/apps/api/Dockerfile.api @@ -0,0 +1,58 @@ +FROM python:3.12.10-alpine + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 +ENV INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/ + +# Update system packages for security +RUN apk update && apk upgrade + +WORKDIR /code + +RUN apk add --no-cache --upgrade \ + "libpq" \ + "libxslt" \ + "xmlsec" \ + "ca-certificates" \ + "openssl" + +COPY requirements.txt ./ +COPY requirements ./requirements +RUN apk add --no-cache libffi-dev +RUN apk add --no-cache --virtual .build-deps \ + "bash~=5.2" \ + "g++" \ + "gcc" \ + "cargo" \ + "git" \ + "make" \ + "postgresql-dev" \ + "libc-dev" \ + "linux-headers" \ + && \ + pip install -r requirements.txt --compile --no-cache-dir \ + && \ + apk del .build-deps \ + && \ + rm -rf /var/cache/apk/* + + +# Add in Django deps and generate Django's static files +COPY manage.py manage.py +COPY plane plane/ +COPY templates templates/ +COPY package.json package.json + +RUN apk --no-cache add "bash~=5.2" +COPY ./bin ./bin/ + +RUN mkdir -p /code/plane/logs +RUN chmod +x ./bin/* +RUN chmod -R 777 /code + +# Expose container port and run entry point script +EXPOSE 8000 + +CMD ["./bin/docker-entrypoint-api.sh"] \ No newline at end of file diff --git a/apps/api/Dockerfile.dev b/apps/api/Dockerfile.dev new file mode 100644 index 00000000..3ec8c634 --- /dev/null +++ b/apps/api/Dockerfile.dev @@ -0,0 +1,46 @@ +FROM python:3.12.5-alpine AS backend + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 +ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/ + +RUN apk --no-cache add \ + "bash~=5.2" \ + "libpq" \ + "libxslt" \ + "nodejs-current" \ + "xmlsec" \ + "libffi-dev" \ + "bash~=5.2" \ + "g++" \ + "gcc" \ + "cargo" \ + "git" \ + "make" \ + "postgresql-dev" \ + "libc-dev" \ + "linux-headers" + +WORKDIR /code + +COPY requirements.txt ./requirements.txt +ADD requirements ./requirements + +# Install the local development settings +RUN pip install -r requirements/local.txt --compile --no-cache-dir + + +COPY . . + +RUN mkdir -p /code/plane/logs +RUN chmod -R +x /code/bin +RUN chmod -R 777 /code + + +# Expose container port and run entry point script +EXPOSE 8000 + +CMD [ "./bin/docker-entrypoint-api-local.sh" ] + diff --git a/apps/api/bin/docker-entrypoint-api-local.sh b/apps/api/bin/docker-entrypoint-api-local.sh new file mode 100755 index 00000000..b5489b46 --- /dev/null +++ b/apps/api/bin/docker-entrypoint-api-local.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e +python manage.py wait_for_db +# Wait for migrations +python manage.py wait_for_migrations + +# Create the default bucket +#!/bin/bash + +# Collect system information +HOSTNAME=$(hostname) +MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1) +CPU_INFO=$(cat /proc/cpuinfo) +MEMORY_INFO=$(free -h) +DISK_INFO=$(df -h) + +# Concatenate information and compute SHA-256 hash +SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}') + +# Export the variables +export MACHINE_SIGNATURE=$SIGNATURE + +# Register instance +python manage.py register_instance "$MACHINE_SIGNATURE" +# Load the configuration variable +python manage.py configure_instance + +# Create the default bucket +python manage.py create_bucket + +# Clear Cache before starting to remove stale values +python manage.py clear_cache + +python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local diff --git a/apps/api/bin/docker-entrypoint-api.sh b/apps/api/bin/docker-entrypoint-api.sh new file mode 100755 index 00000000..5a1da157 --- /dev/null +++ b/apps/api/bin/docker-entrypoint-api.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e +python manage.py wait_for_db +# Wait for migrations +python manage.py wait_for_migrations + +# Create the default bucket +#!/bin/bash + +# Collect system information +HOSTNAME=$(hostname) +MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1) +CPU_INFO=$(cat /proc/cpuinfo) +MEMORY_INFO=$(free -h) +DISK_INFO=$(df -h) + +# Concatenate information and compute SHA-256 hash +SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}') + +# Export the variables +export MACHINE_SIGNATURE=$SIGNATURE + +# Register instance +python manage.py register_instance "$MACHINE_SIGNATURE" + +# Load the configuration variable +python manage.py configure_instance + +# Create the default bucket +python manage.py create_bucket + +# Clear Cache before starting to remove stale values +python manage.py clear_cache + +exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile - diff --git a/apps/api/bin/docker-entrypoint-beat.sh b/apps/api/bin/docker-entrypoint-beat.sh new file mode 100755 index 00000000..3a9602a9 --- /dev/null +++ b/apps/api/bin/docker-entrypoint-beat.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +python manage.py wait_for_db +# Wait for migrations +python manage.py wait_for_migrations +# Run the processes +celery -A plane beat -l info \ No newline at end of file diff --git a/apps/api/bin/docker-entrypoint-migrator.sh b/apps/api/bin/docker-entrypoint-migrator.sh new file mode 100755 index 00000000..104b3902 --- /dev/null +++ b/apps/api/bin/docker-entrypoint-migrator.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +python manage.py wait_for_db $1 + +python manage.py migrate $1 \ No newline at end of file diff --git a/apps/api/bin/docker-entrypoint-worker.sh b/apps/api/bin/docker-entrypoint-worker.sh new file mode 100755 index 00000000..a70b5f77 --- /dev/null +++ b/apps/api/bin/docker-entrypoint-worker.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +python manage.py wait_for_db +# Wait for migrations +python manage.py wait_for_migrations +# Run the processes +celery -A plane worker -l info \ No newline at end of file diff --git a/apps/api/manage.py b/apps/api/manage.py new file mode 100644 index 00000000..97286946 --- /dev/null +++ b/apps/api/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 00000000..ffecb3a7 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,7 @@ +{ + "name": "plane-api", + "version": "1.1.0", + "license": "AGPL-3.0", + "private": true, + "description": "API server powering Plane's backend" +} diff --git a/apps/api/plane/__init__.py b/apps/api/plane/__init__.py new file mode 100644 index 00000000..53f4ccb1 --- /dev/null +++ b/apps/api/plane/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/apps/api/plane/analytics/__init__.py b/apps/api/plane/analytics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/analytics/apps.py b/apps/api/plane/analytics/apps.py new file mode 100644 index 00000000..52a59f31 --- /dev/null +++ b/apps/api/plane/analytics/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AnalyticsConfig(AppConfig): + name = "plane.analytics" diff --git a/apps/api/plane/api/__init__.py b/apps/api/plane/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/api/apps.py b/apps/api/plane/api/apps.py new file mode 100644 index 00000000..f1f53111 --- /dev/null +++ b/apps/api/plane/api/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = "plane.api" + + def ready(self): + # Import authentication extensions to register them with drf-spectacular + try: + import plane.utils.openapi.auth # noqa + except ImportError: + pass diff --git a/apps/api/plane/api/middleware/__init__.py b/apps/api/plane/api/middleware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/api/middleware/api_authentication.py b/apps/api/plane/api/middleware/api_authentication.py new file mode 100644 index 00000000..ddabb413 --- /dev/null +++ b/apps/api/plane/api/middleware/api_authentication.py @@ -0,0 +1,47 @@ +# Django imports +from django.utils import timezone +from django.db.models import Q + +# Third party imports +from rest_framework import authentication +from rest_framework.exceptions import AuthenticationFailed + +# Module imports +from plane.db.models import APIToken + + +class APIKeyAuthentication(authentication.BaseAuthentication): + """ + Authentication with an API Key + """ + + www_authenticate_realm = "api" + media_type = "application/json" + auth_header_name = "X-Api-Key" + + def get_api_token(self, request): + return request.headers.get(self.auth_header_name) + + def validate_api_token(self, token): + try: + api_token = APIToken.objects.get( + Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), + token=token, + is_active=True, + ) + except APIToken.DoesNotExist: + raise AuthenticationFailed("Given API token is not valid") + + # save api token last used + api_token.last_used = timezone.now() + api_token.save(update_fields=["last_used"]) + return (api_token.user, api_token.token) + + def authenticate(self, request): + token = self.get_api_token(request=request) + if not token: + return None + + # Validate the API token + user, token = self.validate_api_token(token) + return user, token diff --git a/apps/api/plane/api/rate_limit.py b/apps/api/plane/api/rate_limit.py new file mode 100644 index 00000000..0d266e98 --- /dev/null +++ b/apps/api/plane/api/rate_limit.py @@ -0,0 +1,87 @@ +# python imports +import os + +# Third party imports +from rest_framework.throttling import SimpleRateThrottle + + +class ApiKeyRateThrottle(SimpleRateThrottle): + scope = "api_key" + rate = os.environ.get("API_KEY_RATE_LIMIT", "60/minute") + + def get_cache_key(self, request, view): + # Retrieve the API key from the request header + api_key = request.headers.get("X-Api-Key") + if not api_key: + return None # Allow the request if there's no API key + + # Use the API key as part of the cache key + return f"{self.scope}:{api_key}" + + def allow_request(self, request, view): + allowed = super().allow_request(request, view) + + if allowed: + now = self.timer() + # Calculate the remaining limit and reset time + history = self.cache.get(self.key, []) + + # Remove old histories + while history and history[-1] <= now - self.duration: + history.pop() + + # Calculate the requests + num_requests = len(history) + + # Check available requests + available = self.num_requests - num_requests + + # Unix timestamp for when the rate limit will reset + reset_time = int(now + self.duration) + + # Add headers + request.META["X-RateLimit-Remaining"] = max(0, available) + request.META["X-RateLimit-Reset"] = reset_time + + return allowed + + +class ServiceTokenRateThrottle(SimpleRateThrottle): + scope = "service_token" + rate = "300/minute" + + def get_cache_key(self, request, view): + # Retrieve the API key from the request header + api_key = request.headers.get("X-Api-Key") + if not api_key: + return None # Allow the request if there's no API key + + # Use the API key as part of the cache key + return f"{self.scope}:{api_key}" + + def allow_request(self, request, view): + allowed = super().allow_request(request, view) + + if allowed: + now = self.timer() + # Calculate the remaining limit and reset time + history = self.cache.get(self.key, []) + + # Remove old histories + while history and history[-1] <= now - self.duration: + history.pop() + + # Calculate the requests + num_requests = len(history) + + # Check available requests + available = self.num_requests - num_requests + + # Unix timestamp for when the rate limit will reset + reset_time = int(now + self.duration) + + # Add headers + request.META["X-RateLimit-Remaining"] = max(0, available) + request.META["X-RateLimit-Reset"] = reset_time + + return allowed diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py new file mode 100644 index 00000000..7596915e --- /dev/null +++ b/apps/api/plane/api/serializers/__init__.py @@ -0,0 +1,55 @@ +from .user import UserLiteSerializer +from .workspace import WorkspaceLiteSerializer +from .project import ( + ProjectSerializer, + ProjectLiteSerializer, + ProjectCreateSerializer, + ProjectUpdateSerializer, +) +from .issue import ( + IssueSerializer, + LabelCreateUpdateSerializer, + LabelSerializer, + IssueLinkSerializer, + IssueCommentSerializer, + IssueAttachmentSerializer, + IssueActivitySerializer, + IssueExpandSerializer, + IssueLiteSerializer, + IssueAttachmentUploadSerializer, + IssueSearchSerializer, + IssueCommentCreateSerializer, + IssueLinkCreateSerializer, + IssueLinkUpdateSerializer, +) +from .state import StateLiteSerializer, StateSerializer +from .cycle import ( + CycleSerializer, + CycleIssueSerializer, + CycleLiteSerializer, + CycleIssueRequestSerializer, + TransferCycleIssueRequestSerializer, + CycleCreateSerializer, + CycleUpdateSerializer, +) +from .module import ( + ModuleSerializer, + ModuleIssueSerializer, + ModuleLiteSerializer, + ModuleIssueRequestSerializer, + ModuleCreateSerializer, + ModuleUpdateSerializer, +) +from .intake import ( + IntakeIssueSerializer, + IntakeIssueCreateSerializer, + IntakeIssueUpdateSerializer, +) +from .estimate import EstimatePointSerializer +from .asset import ( + UserAssetUploadSerializer, + AssetUpdateSerializer, + GenericAssetUploadSerializer, + GenericAssetUpdateSerializer, + FileAssetSerializer, +) diff --git a/apps/api/plane/api/serializers/asset.py b/apps/api/plane/api/serializers/asset.py new file mode 100644 index 00000000..6b74b375 --- /dev/null +++ b/apps/api/plane/api/serializers/asset.py @@ -0,0 +1,119 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from plane.db.models import FileAsset + + +class UserAssetUploadSerializer(serializers.Serializer): + """ + Serializer for user asset upload requests. + + This serializer validates the metadata required to generate a presigned URL + for uploading user profile assets (avatar or cover image) directly to S3 storage. + Supports JPEG, PNG, WebP, JPG, and GIF image formats with size validation. + """ + + name = serializers.CharField(help_text="Original filename of the asset") + type = serializers.ChoiceField( + choices=[ + ("image/jpeg", "JPEG"), + ("image/png", "PNG"), + ("image/webp", "WebP"), + ("image/jpg", "JPG"), + ("image/gif", "GIF"), + ], + default="image/jpeg", + help_text="MIME type of the file", + style={"placeholder": "image/jpeg"}, + ) + size = serializers.IntegerField(help_text="File size in bytes") + entity_type = serializers.ChoiceField( + choices=[ + (FileAsset.EntityTypeContext.USER_AVATAR, "User Avatar"), + (FileAsset.EntityTypeContext.USER_COVER, "User Cover"), + ], + help_text="Type of user asset", + ) + + +class AssetUpdateSerializer(serializers.Serializer): + """ + Serializer for asset status updates after successful upload completion. + + Handles post-upload asset metadata updates including attribute modifications + and upload confirmation for S3-based file storage workflows. + """ + + attributes = serializers.JSONField(required=False, help_text="Additional attributes to update for the asset") + + +class GenericAssetUploadSerializer(serializers.Serializer): + """ + Serializer for generic asset upload requests with project association. + + Validates metadata for generating presigned URLs for workspace assets including + project association, external system tracking, and file validation for + document management and content storage workflows. + """ + + name = serializers.CharField(help_text="Original filename of the asset") + type = serializers.CharField(required=False, help_text="MIME type of the file") + size = serializers.IntegerField(help_text="File size in bytes") + project_id = serializers.UUIDField( + required=False, + help_text="UUID of the project to associate with the asset", + style={"placeholder": "123e4567-e89b-12d3-a456-426614174000"}, + ) + external_id = serializers.CharField( + required=False, + help_text="External identifier for the asset (for integration tracking)", + ) + external_source = serializers.CharField( + required=False, help_text="External source system (for integration tracking)" + ) + + +class GenericAssetUpdateSerializer(serializers.Serializer): + """ + Serializer for generic asset upload confirmation and status management. + + Handles post-upload status updates for workspace assets including + upload completion marking and metadata finalization. + """ + + is_uploaded = serializers.BooleanField(default=True, help_text="Whether the asset has been successfully uploaded") + + +class FileAssetSerializer(BaseSerializer): + """ + Comprehensive file asset serializer with complete metadata and URL generation. + + Provides full file asset information including storage metadata, access URLs, + relationship data, and upload status for complete asset management workflows. + """ + + asset_url = serializers.CharField(read_only=True) + + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", + "workspace", + "project", + "issue", + "comment", + "page", + "draft_issue", + "user", + "is_deleted", + "deleted_at", + "storage_metadata", + "asset_url", + ] diff --git a/apps/api/plane/api/serializers/base.py b/apps/api/plane/api/serializers/base.py new file mode 100644 index 00000000..bc790f2c --- /dev/null +++ b/apps/api/plane/api/serializers/base.py @@ -0,0 +1,114 @@ +# Third party imports +from rest_framework import serializers + + +class BaseSerializer(serializers.ModelSerializer): + """ + Base serializer providing common functionality for all model serializers. + + Features field filtering, dynamic expansion of related fields, and standardized + primary key handling for consistent API responses across the application. + """ + + id = serializers.PrimaryKeyRelatedField(read_only=True) + + def __init__(self, *args, **kwargs): + # If 'fields' is provided in the arguments, remove it and store it separately. + # This is done so as not to pass this custom argument up to the superclass. + fields = kwargs.pop("fields", []) + self.expand = kwargs.pop("expand", []) or [] + + # Call the initialization of the superclass. + super().__init__(*args, **kwargs) + + # If 'fields' was provided, filter the fields of the serializer accordingly. + if fields: + self.fields = self._filter_fields(fields=fields) + + def _filter_fields(self, fields): + """ + Adjust the serializer's fields based on the provided 'fields' list. + + :param fields: List or dictionary specifying which + fields to include in the serializer. + :return: The updated fields for the serializer. + """ + # Check each field_name in the provided fields. + for field_name in fields: + # If the field is a dictionary (indicating nested fields), + # loop through its keys and values. + if isinstance(field_name, dict): + for key, value in field_name.items(): + # If the value of this nested field is a list, + # perform a recursive filter on it. + if isinstance(value, list): + self._filter_fields(self.fields[key], value) + + # Create a list to store allowed fields. + allowed = [] + for item in fields: + # If the item is a string, it directly represents a field's name. + if isinstance(item, str): + allowed.append(item) + # If the item is a dictionary, it represents a nested field. + # Add the key of this dictionary to the allowed list. + elif isinstance(item, dict): + allowed.append(list(item.keys())[0]) + + # Convert the current serializer's fields and the allowed fields to sets. + existing = set(self.fields) + allowed = set(allowed) + + # Remove fields from the serializer that aren't in the 'allowed' list. + for field_name in existing - allowed: + self.fields.pop(field_name) + + return self.fields + + def to_representation(self, instance): + response = super().to_representation(instance) + + # Ensure 'expand' is iterable before processing + if self.expand: + for expand in self.expand: + if expand in self.fields: + # Import all the expandable serializers + from . import ( + IssueSerializer, + IssueLiteSerializer, + ProjectLiteSerializer, + StateLiteSerializer, + UserLiteSerializer, + WorkspaceLiteSerializer, + EstimatePointSerializer, + ) + + # Expansion mapper + expansion = { + "user": UserLiteSerializer, + "workspace": WorkspaceLiteSerializer, + "project": ProjectLiteSerializer, + "default_assignee": UserLiteSerializer, + "project_lead": UserLiteSerializer, + "state": StateLiteSerializer, + "created_by": UserLiteSerializer, + "updated_by": UserLiteSerializer, + "issue": IssueSerializer, + "actor": UserLiteSerializer, + "owned_by": UserLiteSerializer, + "members": UserLiteSerializer, + "parent": IssueLiteSerializer, + "estimate_point": EstimatePointSerializer, + } + # Check if field in expansion then expand the field + if expand in expansion: + if isinstance(response.get(expand), list): + exp_serializer = expansion[expand](getattr(instance, expand), many=True) + else: + exp_serializer = expansion[expand](getattr(instance, expand)) + response[expand] = exp_serializer.data + else: + # You might need to handle this case differently + response[expand] = getattr(instance, f"{expand}_id", None) + + return response diff --git a/apps/api/plane/api/serializers/cycle.py b/apps/api/plane/api/serializers/cycle.py new file mode 100644 index 00000000..6b7bfa44 --- /dev/null +++ b/apps/api/plane/api/serializers/cycle.py @@ -0,0 +1,186 @@ +# Third party imports +import pytz +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from plane.db.models import Cycle, CycleIssue, User +from plane.utils.timezone_converter import convert_to_utc + + +class CycleCreateSerializer(BaseSerializer): + """ + Serializer for creating cycles with timezone handling and date validation. + + Manages cycle creation including project timezone conversion, date range validation, + and UTC normalization for time-bound iteration planning and sprint management. + """ + + owned_by = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), + required=False, + allow_null=True, + help_text="User who owns the cycle. If not provided, defaults to the current user.", + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + project = self.context.get("project") + if project and project.timezone: + project_timezone = pytz.timezone(project.timezone) + self.fields["start_date"].timezone = project_timezone + self.fields["end_date"].timezone = project_timezone + + class Meta: + model = Cycle + fields = [ + "name", + "description", + "start_date", + "end_date", + "owned_by", + "external_source", + "external_id", + "timezone", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "deleted_at", + ] + + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + and data.get("start_date", None) > data.get("end_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed end date") + + if data.get("start_date", None) is not None and data.get("end_date", None) is not None: + project_id = self.initial_data.get("project_id") or ( + self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None + ) + + if not project_id: + raise serializers.ValidationError("Project ID is required") + + data["start_date"] = convert_to_utc( + date=str(data.get("start_date").date()), + project_id=project_id, + is_start_date=True, + ) + data["end_date"] = convert_to_utc( + date=str(data.get("end_date", None).date()), + project_id=project_id, + ) + + if not data.get("owned_by"): + data["owned_by"] = self.context["request"].user + + return data + + +class CycleUpdateSerializer(CycleCreateSerializer): + """ + Serializer for updating cycles with enhanced ownership management. + + Extends cycle creation with update-specific features including ownership + assignment and modification tracking for cycle lifecycle management. + """ + + class Meta(CycleCreateSerializer.Meta): + model = Cycle + fields = CycleCreateSerializer.Meta.fields + [ + "owned_by", + ] + + +class CycleSerializer(BaseSerializer): + """ + Cycle serializer with comprehensive project metrics and time tracking. + + Provides cycle details including work item counts by status, progress estimates, + and time-bound iteration data for project management and sprint planning. + """ + + total_issues = serializers.IntegerField(read_only=True) + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) + total_estimates = serializers.FloatField(read_only=True) + completed_estimates = serializers.FloatField(read_only=True) + started_estimates = serializers.FloatField(read_only=True) + + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = [ + "id", + "created_at", + "updated_at", + "created_by", + "updated_by", + "workspace", + "project", + "owned_by", + "deleted_at", + ] + + +class CycleIssueSerializer(BaseSerializer): + """ + Serializer for cycle-issue relationships with sub-issue counting. + + Manages the association between cycles and work items, including + hierarchical issue tracking for nested work item structures. + """ + + sub_issues_count = serializers.IntegerField(read_only=True) + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = ["workspace", "project", "cycle"] + + +class CycleLiteSerializer(BaseSerializer): + """ + Lightweight cycle serializer for minimal data transfer. + + Provides essential cycle information without computed metrics, + optimized for list views and reference lookups. + """ + + class Meta: + model = Cycle + fields = "__all__" + + +class CycleIssueRequestSerializer(serializers.Serializer): + """ + Serializer for bulk work item assignment to cycles. + + Validates work item ID lists for batch operations including + cycle assignment and sprint planning workflows. + """ + + issues = serializers.ListField(child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle") + + +class TransferCycleIssueRequestSerializer(serializers.Serializer): + """ + Serializer for transferring work items between cycles. + + Handles work item migration between cycles including validation + and relationship updates for sprint reallocation workflows. + """ + + new_cycle_id = serializers.UUIDField(help_text="ID of the target cycle to transfer issues to") diff --git a/apps/api/plane/api/serializers/estimate.py b/apps/api/plane/api/serializers/estimate.py new file mode 100644 index 00000000..b670006d --- /dev/null +++ b/apps/api/plane/api/serializers/estimate.py @@ -0,0 +1,17 @@ +# Module imports +from plane.db.models import EstimatePoint +from .base import BaseSerializer + + +class EstimatePointSerializer(BaseSerializer): + """ + Serializer for project estimation points and story point values. + + Handles numeric estimation data for work item sizing and sprint planning, + providing standardized point values for project velocity calculations. + """ + + class Meta: + model = EstimatePoint + fields = ["id", "value"] + read_only_fields = fields diff --git a/apps/api/plane/api/serializers/intake.py b/apps/api/plane/api/serializers/intake.py new file mode 100644 index 00000000..fcfedcbd --- /dev/null +++ b/apps/api/plane/api/serializers/intake.py @@ -0,0 +1,134 @@ +# Module imports +from .base import BaseSerializer +from .issue import IssueExpandSerializer +from plane.db.models import IntakeIssue, Issue +from rest_framework import serializers + + +class IssueForIntakeSerializer(BaseSerializer): + """ + Serializer for work item data within intake submissions. + + Handles essential work item fields for intake processing including + content validation and priority assignment for triage workflows. + """ + + class Meta: + model = Issue + fields = [ + "name", + "description", + "description_html", + "priority", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IntakeIssueCreateSerializer(BaseSerializer): + """ + Serializer for creating intake work items with embedded issue data. + + Manages intake work item creation including nested issue creation, + status assignment, and source tracking for issue queue management. + """ + + issue = IssueForIntakeSerializer(help_text="Issue data for the intake issue") + + class Meta: + model = IntakeIssue + fields = [ + "issue", + "intake", + "status", + "snoozed_till", + "duplicate_to", + "source", + "source_email", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IntakeIssueSerializer(BaseSerializer): + """ + Comprehensive serializer for intake work items with expanded issue details. + + Provides full intake work item data including embedded issue information, + status tracking, and triage metadata for issue queue management. + """ + + issue_detail = IssueExpandSerializer(read_only=True, source="issue") + inbox = serializers.UUIDField(source="intake.id", read_only=True) + + class Meta: + model = IntakeIssue + fields = "__all__" + read_only_fields = [ + "id", + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IntakeIssueUpdateSerializer(BaseSerializer): + """ + Serializer for updating intake work items and their associated issues. + + Handles intake work item modifications including status changes, triage decisions, + and embedded issue updates for issue queue processing workflows. + """ + + issue = IssueForIntakeSerializer(required=False, help_text="Issue data to update in the intake issue") + + class Meta: + model = IntakeIssue + fields = [ + "status", + "snoozed_till", + "duplicate_to", + "source", + "source_email", + "issue", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueDataSerializer(serializers.Serializer): + """ + Serializer for nested work item data in intake request payloads. + + Validates core work item fields within intake requests including + content formatting, priority levels, and metadata for issue creation. + """ + + name = serializers.CharField(max_length=255, help_text="Issue name") + description_html = serializers.CharField(required=False, allow_null=True, help_text="Issue description HTML") + priority = serializers.ChoiceField(choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority") diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py new file mode 100644 index 00000000..d7fc3e91 --- /dev/null +++ b/apps/api/plane/api/serializers/issue.py @@ -0,0 +1,697 @@ +# Django imports +from django.utils import timezone +from lxml import html +from django.db import IntegrityError + +# Third party imports +from rest_framework import serializers + +# Module imports +from plane.db.models import ( + Issue, + IssueType, + IssueActivity, + IssueAssignee, + FileAsset, + IssueComment, + IssueLabel, + IssueLink, + Label, + ProjectMember, + State, + User, + EstimatePoint, +) +from plane.utils.content_validator import ( + validate_html_content, + validate_binary_data, +) + +from .base import BaseSerializer +from .cycle import CycleLiteSerializer, CycleSerializer +from .module import ModuleLiteSerializer, ModuleSerializer +from .state import StateLiteSerializer +from .user import UserLiteSerializer + +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator + + +class IssueSerializer(BaseSerializer): + """ + Comprehensive work item serializer with full relationship management. + + Handles complete work item lifecycle including assignees, labels, validation, + and related model updates. Supports dynamic field expansion and HTML content + processing. + """ + + assignees = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.values_list("id", flat=True)), + write_only=True, + required=False, + ) + + labels = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.values_list("id", flat=True)), + write_only=True, + required=False, + ) + type_id = serializers.PrimaryKeyRelatedField( + 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"] + + 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") + + try: + if data.get("description_html", None) is not None: + parsed = html.fromstring(data["description_html"]) + parsed_str = html.tostring(parsed, encoding="unicode") + data["description_html"] = parsed_str + + except Exception: + raise serializers.ValidationError("Invalid HTML passed") + + # Validate description content for security + if data.get("description_html"): + is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"]) + if not is_valid: + raise serializers.ValidationError({"error": "html content is not valid"}) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html + + if data.get("description_binary"): + is_valid, error_msg = validate_binary_data(data["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) + + # Validate assignees are from project + if data.get("assignees", []): + data["assignees"] = ProjectMember.objects.filter( + project_id=self.context.get("project_id"), + is_active=True, + role__gte=15, + member_id__in=data["assignees"], + ).values_list("member_id", flat=True) + + # Validate labels are from project + if data.get("labels", []): + data["labels"] = Label.objects.filter( + project_id=self.context.get("project_id"), id__in=data["labels"] + ).values_list("id", flat=True) + + # Check state is from the project only else raise validation error + if ( + data.get("state") + and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state").id).exists() + ): + raise serializers.ValidationError("State is not valid please pass a valid state_id") + + # Check parent issue is from workspace as it can be cross workspace + if ( + data.get("parent") + and not Issue.objects.filter( + workspace_id=self.context.get("workspace_id"), + project_id=self.context.get("project_id"), + pk=data.get("parent").id, + ).exists() + ): + raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id") + + if ( + data.get("estimate_point") + and not EstimatePoint.objects.filter( + workspace_id=self.context.get("workspace_id"), + project_id=self.context.get("project_id"), + pk=data.get("estimate_point").id, + ).exists() + ): + raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id") + + return data + + def create(self, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + project_id = self.context["project_id"] + workspace_id = self.context["workspace_id"] + default_assignee_id = self.context["default_assignee_id"] + + issue_type = validated_data.pop("type", None) + + if not issue_type: + # Get default issue type + issue_type = IssueType.objects.filter(project_issue_types__project_id=project_id, is_default=True).first() + issue_type = issue_type + + issue = Issue.objects.create(**validated_data, project_id=project_id, type=issue_type) + + # 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): + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee_id=assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ) + except IntegrityError: + pass + else: + try: + # Then assign it to default assignee, if it is a valid assignee + if ( + default_assignee_id is not None + and ProjectMember.objects.filter( + member_id=default_assignee_id, + project_id=project_id, + role__gte=15, + is_active=True, + ).exists() + ): + IssueAssignee.objects.create( + assignee_id=default_assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + except IntegrityError: + pass + + if labels is not None and len(labels): + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label_id=label_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label_id in labels + ], + batch_size=10, + ) + except IntegrityError: + pass + + return issue + + def update(self, instance, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + # Related models + project_id = instance.project_id + workspace_id = instance.workspace_id + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + + if assignees is not None: + IssueAssignee.objects.filter(issue=instance).delete() + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee_id=assignee_id, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass + + if labels is not None: + IssueLabel.objects.filter(issue=instance).delete() + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label_id=label_id, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label_id in labels + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass + + # Time updation occues even when other related models are updated + instance.updated_at = timezone.now() + return super().update(instance, validated_data) + + def to_representation(self, instance): + data = super().to_representation(instance) + if "assignees" in self.fields: + if "assignees" in self.expand: + from .user import UserLiteSerializer + + data["assignees"] = UserLiteSerializer( + User.objects.filter( + pk__in=IssueAssignee.objects.filter(issue=instance).values_list("assignee_id", flat=True) + ), + many=True, + ).data + else: + data["assignees"] = [ + str(assignee) + for assignee in IssueAssignee.objects.filter(issue=instance).values_list("assignee_id", flat=True) + ] + if "labels" in self.fields: + if "labels" in self.expand: + data["labels"] = LabelSerializer( + Label.objects.filter( + pk__in=IssueLabel.objects.filter(issue=instance).values_list("label_id", flat=True) + ), + many=True, + ).data + else: + data["labels"] = [ + str(label) for label in IssueLabel.objects.filter(issue=instance).values_list("label_id", flat=True) + ] + + return data + + +class IssueLiteSerializer(BaseSerializer): + """ + Lightweight work item serializer for minimal data transfer. + + Provides essential work item identifiers optimized for list views, + references, and performance-critical operations. + """ + + class Meta: + model = Issue + fields = ["id", "sequence_id", "project_id"] + read_only_fields = fields + + +class LabelCreateUpdateSerializer(BaseSerializer): + """ + Serializer for creating and updating work item labels. + + Manages label metadata including colors, descriptions, hierarchy, + and sorting for work item categorization and filtering. + """ + + class Meta: + model = Label + fields = [ + "name", + "color", + "description", + "external_source", + "external_id", + "parent", + "sort_order", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "deleted_at", + ] + + +class LabelSerializer(BaseSerializer): + """ + Full serializer for work item labels with complete metadata. + + Provides comprehensive label information including hierarchical relationships, + visual properties, and organizational data for work item tagging. + """ + + class Meta: + model = Label + fields = "__all__" + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "deleted_at", + ] + + +class IssueLinkCreateSerializer(BaseSerializer): + """ + Serializer for creating work item external links with validation. + + Handles URL validation, format checking, and duplicate prevention + for attaching external resources to work items. + """ + + class Meta: + model = IssueLink + fields = ["url", "issue_id"] + read_only_fields = [ + "id", + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def validate_url(self, value): + # Check URL format + validate_url = URLValidator() + try: + validate_url(value) + except ValidationError: + raise serializers.ValidationError("Invalid URL format.") + + # Check URL scheme + if not value.startswith(("http://", "https://")): + raise serializers.ValidationError("Invalid URL scheme.") + + return value + + # Validation if url already exists + def create(self, validated_data): + if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists(): + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) + return IssueLink.objects.create(**validated_data) + + +class IssueLinkUpdateSerializer(IssueLinkCreateSerializer): + """ + Serializer for updating work item external links. + + Extends link creation with update-specific validation to prevent + URL conflicts and maintain link integrity during modifications. + """ + + class Meta(IssueLinkCreateSerializer.Meta): + model = IssueLink + fields = IssueLinkCreateSerializer.Meta.fields + [ + "issue_id", + ] + read_only_fields = IssueLinkCreateSerializer.Meta.read_only_fields + + def update(self, instance, validated_data): + if ( + IssueLink.objects.filter(url=validated_data.get("url"), issue_id=instance.issue_id) + .exclude(pk=instance.id) + .exists() + ): + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) + + return super().update(instance, validated_data) + + +class IssueLinkSerializer(BaseSerializer): + """ + Full serializer for work item external links. + + Provides complete link information including metadata and timestamps + for managing external resource associations with work items. + """ + + class Meta: + model = IssueLink + fields = "__all__" + read_only_fields = [ + "id", + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueAttachmentSerializer(BaseSerializer): + """ + Serializer for work item file attachments. + + Manages file asset associations with work items including metadata, + storage information, and access control for document management. + """ + + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = [ + "id", + "workspace", + "project", + "issue", + "updated_by", + "updated_at", + ] + + +class IssueCommentCreateSerializer(BaseSerializer): + """ + Serializer for creating work item comments. + + Handles comment creation with JSON and HTML content support, + access control, and external integration tracking. + """ + + class Meta: + model = IssueComment + fields = [ + "comment_json", + "comment_html", + "access", + "external_source", + "external_id", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + "deleted_at", + "actor", + "comment_stripped", + "edited_at", + ] + + +class IssueCommentSerializer(BaseSerializer): + """ + Full serializer for work item comments with membership context. + + Provides complete comment data including member status, content formatting, + and edit tracking for collaborative work item discussions. + """ + + is_member = serializers.BooleanField(read_only=True) + + class Meta: + model = IssueComment + read_only_fields = [ + "id", + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + exclude = ["comment_stripped", "comment_json"] + + def validate(self, data): + try: + if data.get("comment_html", None) is not None: + parsed = html.fromstring(data["comment_html"]) + parsed_str = html.tostring(parsed, encoding="unicode") + data["comment_html"] = parsed_str + + except Exception: + raise serializers.ValidationError("Invalid HTML passed") + return data + + +class IssueActivitySerializer(BaseSerializer): + """ + Serializer for work item activity and change history. + + Tracks and represents work item modifications, state changes, + and user interactions for audit trails and activity feeds. + """ + + class Meta: + model = IssueActivity + exclude = ["created_by", "updated_by"] + + +class CycleIssueSerializer(BaseSerializer): + """ + Serializer for work items within cycles. + + Provides cycle context for work items including cycle metadata + and timing information for sprint and iteration management. + """ + + cycle = CycleSerializer(read_only=True) + + class Meta: + fields = ["cycle"] + + +class ModuleIssueSerializer(BaseSerializer): + """ + Serializer for work items within modules. + + Provides module context for work items including module metadata + and organizational information for feature-based work grouping. + """ + + module = ModuleSerializer(read_only=True) + + class Meta: + fields = ["module"] + + +class LabelLiteSerializer(BaseSerializer): + """ + Lightweight label serializer for minimal data transfer. + + Provides essential label information with visual properties, + optimized for UI display and performance-critical operations. + """ + + class Meta: + model = Label + fields = ["id", "name", "color"] + + +class IssueExpandSerializer(BaseSerializer): + """ + Extended work item serializer with full relationship expansion. + + Provides work items with expanded related data including cycles, modules, + labels, assignees, and states for comprehensive data representation. + """ + + cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True) + module = ModuleLiteSerializer(source="issue_module.module", read_only=True) + + labels = serializers.SerializerMethodField() + assignees = serializers.SerializerMethodField() + state = StateLiteSerializer(read_only=True) + + def get_labels(self, obj): + expand = self.context.get("expand", []) + if "labels" in expand: + # Use prefetched data + return LabelLiteSerializer([il.label for il in obj.label_issue.all()], many=True).data + return [il.label_id for il in obj.label_issue.all()] + + def get_assignees(self, obj): + expand = self.context.get("expand", []) + if "assignees" in expand: + return UserLiteSerializer([ia.assignee for ia in obj.issue_assignee.all()], many=True).data + return [ia.assignee_id for ia in obj.issue_assignee.all()] + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueAttachmentUploadSerializer(serializers.Serializer): + """ + Serializer for work item attachment upload request validation. + + Handles file upload metadata validation including size, type, and external + integration tracking for secure work item document attachment workflows. + """ + + name = serializers.CharField(help_text="Original filename of the asset") + type = serializers.CharField(required=False, help_text="MIME type of the file") + size = serializers.IntegerField(help_text="File size in bytes") + external_id = serializers.CharField( + required=False, + help_text="External identifier for the asset (for integration tracking)", + ) + external_source = serializers.CharField( + required=False, help_text="External source system (for integration tracking)" + ) + + +class IssueSearchSerializer(serializers.Serializer): + """ + Serializer for work item search result data formatting. + + Provides standardized search result structure including work item identifiers, + project context, and workspace information for search API responses. + """ + + id = serializers.CharField(required=True, help_text="Issue ID") + name = serializers.CharField(required=True, help_text="Issue name") + sequence_id = serializers.CharField(required=True, help_text="Issue sequence ID") + project__identifier = serializers.CharField(required=True, help_text="Project identifier") + project_id = serializers.CharField(required=True, help_text="Project ID") + workspace__slug = serializers.CharField(required=True, help_text="Workspace slug") diff --git a/apps/api/plane/api/serializers/module.py b/apps/api/plane/api/serializers/module.py new file mode 100644 index 00000000..77be453c --- /dev/null +++ b/apps/api/plane/api/serializers/module.py @@ -0,0 +1,272 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from plane.db.models import ( + User, + Module, + ModuleLink, + ModuleMember, + ModuleIssue, + ProjectMember, +) + + +class ModuleCreateSerializer(BaseSerializer): + """ + Serializer for creating modules with member validation and date checking. + + Handles module creation including member assignment validation, date range + verification, and duplicate name prevention for feature-based + project organization setup. + """ + + members = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Module + fields = [ + "name", + "description", + "start_date", + "target_date", + "status", + "lead", + "members", + "external_source", + "external_id", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "deleted_at", + ] + + 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") + + if data.get("members", []): + data["members"] = ProjectMember.objects.filter( + project_id=self.context.get("project_id"), member_id__in=data["members"] + ).values_list("member_id", flat=True) + + return data + + def create(self, validated_data): + members = validated_data.pop("members", None) + + project_id = self.context["project_id"] + workspace_id = self.context["workspace_id"] + + module_name = validated_data.get("name") + if module_name: + # Lookup for the module name in the module table for that project + module = Module.objects.filter(name=module_name, project_id=project_id).first() + if module: + raise serializers.ValidationError( + { + "id": str(module.id), + "code": "MODULE_NAME_ALREADY_EXISTS", + "error": "Module with this name already exists", + "message": "Module with this name already exists", + } + ) + + module = Module.objects.create(**validated_data, project_id=project_id) + if members is not None: + ModuleMember.objects.bulk_create( + [ + ModuleMember( + module=module, + member_id=str(member), + project_id=project_id, + workspace_id=workspace_id, + created_by=module.created_by, + updated_by=module.updated_by, + ) + for member in members + ], + batch_size=10, + ignore_conflicts=True, + ) + + return module + + +class ModuleUpdateSerializer(ModuleCreateSerializer): + """ + Serializer for updating modules with enhanced validation and member management. + + Extends module creation with update-specific validations including + member reassignment, name conflict checking, + and relationship management for module modifications. + """ + + class Meta(ModuleCreateSerializer.Meta): + model = Module + fields = ModuleCreateSerializer.Meta.fields + [ + "members", + ] + read_only_fields = ModuleCreateSerializer.Meta.read_only_fields + + def update(self, instance, validated_data): + members = validated_data.pop("members", None) + 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=instance.project).exclude(id=instance.id).exists(): + raise serializers.ValidationError({"error": "Module with this name already exists"}) + + if members is not None: + ModuleMember.objects.filter(module=instance).delete() + ModuleMember.objects.bulk_create( + [ + ModuleMember( + module=instance, + member_id=str(member), + project=instance.project, + workspace=instance.project.workspace, + created_by=instance.created_by, + updated_by=instance.updated_by, + ) + for member in members + ], + batch_size=10, + ignore_conflicts=True, + ) + + return super().update(instance, validated_data) + + +class ModuleSerializer(BaseSerializer): + """ + Comprehensive module serializer with work item metrics and member management. + + Provides complete module data including work item counts by status, member + relationships, and progress tracking for feature-based project organization. + """ + + members = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + total_issues = serializers.IntegerField(read_only=True) + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) + + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "deleted_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + data["members"] = [str(member.id) for member in instance.members.all()] + return data + + +class ModuleIssueSerializer(BaseSerializer): + """ + Serializer for module-work item relationships with sub-item counting. + + Manages the association between modules and work items, including + hierarchical issue tracking for nested work item structures. + """ + + sub_issues_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ModuleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "module", + ] + + +class ModuleLinkSerializer(BaseSerializer): + """ + Serializer for module external links with URL validation. + + Handles external resource associations with modules including + URL validation and duplicate prevention for reference management. + """ + + class Meta: + model = ModuleLink + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "module", + ] + + # Validation if url already exists + def create(self, validated_data): + if ModuleLink.objects.filter(url=validated_data.get("url"), module_id=validated_data.get("module_id")).exists(): + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) + return ModuleLink.objects.create(**validated_data) + + +class ModuleLiteSerializer(BaseSerializer): + """ + Lightweight module serializer for minimal data transfer. + + Provides essential module information without computed metrics, + optimized for list views and reference lookups. + """ + + class Meta: + model = Module + fields = "__all__" + + +class ModuleIssueRequestSerializer(serializers.Serializer): + """ + Serializer for bulk work item assignment to modules. + + Validates work item ID lists for batch operations including + module assignment and work item organization workflows. + """ + + issues = serializers.ListField( + child=serializers.UUIDField(), + help_text="List of issue IDs to add to the module", + ) diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py new file mode 100644 index 00000000..3228c5ad --- /dev/null +++ b/apps/api/plane/api/serializers/project.py @@ -0,0 +1,232 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from plane.db.models import ( + Project, + ProjectIdentifier, + WorkspaceMember, + State, + Estimate, +) + +from plane.utils.content_validator import ( + validate_html_content, +) +from .base import BaseSerializer + + +class ProjectCreateSerializer(BaseSerializer): + """ + Serializer for creating projects with workspace validation. + + Handles project creation including identifier validation, member verification, + and workspace association for new project initialization. + """ + + class Meta: + model = Project + fields = [ + "name", + "description", + "project_lead", + "default_assignee", + "identifier", + "icon_prop", + "emoji", + "cover_image", + "module_view", + "cycle_view", + "issue_views_view", + "page_view", + "intake_view", + "guest_view_all_features", + "archive_in", + "close_in", + "timezone", + "logo_props", + "external_source", + "external_id", + "is_issue_type_enabled", + ] + + read_only_fields = [ + "id", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + def validate(self, data): + if data.get("project_lead", None) is not None: + # Check if the project lead is a member of the workspace + if not WorkspaceMember.objects.filter( + workspace_id=self.context["workspace_id"], + member_id=data.get("project_lead"), + ).exists(): + raise serializers.ValidationError("Project lead should be a user in the workspace") + + if data.get("default_assignee", None) is not None: + # Check if the default assignee is a member of the workspace + if not WorkspaceMember.objects.filter( + workspace_id=self.context["workspace_id"], + member_id=data.get("default_assignee"), + ).exists(): + raise serializers.ValidationError("Default assignee should be a user in the workspace") + + return data + + def create(self, validated_data): + identifier = validated_data.get("identifier", "").strip().upper() + if identifier == "": + raise serializers.ValidationError(detail="Project Identifier is required") + + if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists(): + raise serializers.ValidationError(detail="Project Identifier is taken") + + project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"]) + return project + + +class ProjectUpdateSerializer(ProjectCreateSerializer): + """ + Serializer for updating projects with enhanced state and estimation management. + + Extends project creation with update-specific validations including default state + assignment, estimation configuration, and project setting modifications. + """ + + class Meta(ProjectCreateSerializer.Meta): + model = Project + fields = ProjectCreateSerializer.Meta.fields + [ + "default_state", + "estimate", + ] + + read_only_fields = ProjectCreateSerializer.Meta.read_only_fields + + def update(self, instance, validated_data): + """Update a project""" + if ( + validated_data.get("default_state", None) is not None + and not State.objects.filter(project=instance, id=validated_data.get("default_state")).exists() + ): + # Check if the default state is a state in the project + raise serializers.ValidationError("Default state should be a state in the project") + + if ( + validated_data.get("estimate", None) is not None + and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate")).exists() + ): + # Check if the estimate is a estimate in the project + raise serializers.ValidationError("Estimate should be a estimate in the project") + return super().update(instance, validated_data) + + +class ProjectSerializer(BaseSerializer): + """ + Comprehensive project serializer with metrics and member context. + + Provides complete project data including member counts, cycle/module totals, + deployment status, and user-specific context for project management. + """ + + total_members = serializers.IntegerField(read_only=True) + total_cycles = serializers.IntegerField(read_only=True) + total_modules = serializers.IntegerField(read_only=True) + is_member = serializers.BooleanField(read_only=True) + sort_order = serializers.FloatField(read_only=True) + member_role = serializers.IntegerField(read_only=True) + is_deployed = serializers.BooleanField(read_only=True) + cover_image_url = serializers.CharField(read_only=True) + + class Meta: + model = Project + fields = "__all__" + read_only_fields = [ + "id", + "emoji", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + "deleted_at", + "cover_image_url", + ] + + def validate(self, data): + # Check project lead should be a member of the workspace + if ( + data.get("project_lead", None) is not None + and not WorkspaceMember.objects.filter( + workspace_id=self.context["workspace_id"], + member_id=data.get("project_lead"), + ).exists() + ): + raise serializers.ValidationError("Project lead should be a user in the workspace") + + # Check default assignee should be a member of the workspace + if ( + data.get("default_assignee", None) is not None + and not WorkspaceMember.objects.filter( + workspace_id=self.context["workspace_id"], + member_id=data.get("default_assignee"), + ).exists() + ): + raise serializers.ValidationError("Default assignee should be a user in the workspace") + + # Validate description content for security + if "description_html" in data and data["description_html"]: + if isinstance(data["description_html"], dict): + is_valid, error_msg, sanitized_html = validate_html_content(str(data["description_html"])) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html + if not is_valid: + raise serializers.ValidationError({"error": "html content is not valid"}) + + return data + + def create(self, validated_data): + identifier = validated_data.get("identifier", "").strip().upper() + if identifier == "": + raise serializers.ValidationError(detail="Project Identifier is required") + + if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists(): + raise serializers.ValidationError(detail="Project Identifier is taken") + + project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"]) + _ = ProjectIdentifier.objects.create( + name=project.identifier, + project=project, + workspace_id=self.context["workspace_id"], + ) + return project + + +class ProjectLiteSerializer(BaseSerializer): + """ + Lightweight project serializer for minimal data transfer. + + Provides essential project information including identifiers, visual properties, + and basic metadata optimized for list views and references. + """ + + cover_image_url = serializers.CharField(read_only=True) + + class Meta: + model = Project + fields = [ + "id", + "identifier", + "name", + "cover_image", + "icon_prop", + "emoji", + "description", + "cover_image_url", + ] + read_only_fields = fields diff --git a/apps/api/plane/api/serializers/state.py b/apps/api/plane/api/serializers/state.py new file mode 100644 index 00000000..fc6aac15 --- /dev/null +++ b/apps/api/plane/api/serializers/state.py @@ -0,0 +1,47 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import State + + +class StateSerializer(BaseSerializer): + """ + Serializer for work item states with default state management. + + Handles state creation and updates including default state validation + and automatic default state switching for workflow management. + """ + + def validate(self, data): + # If the default is being provided then make all other states default False + if data.get("default", False): + State.objects.filter(project_id=self.context.get("project_id")).update(default=False) + return data + + class Meta: + model = State + fields = "__all__" + read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", + "workspace", + "project", + "deleted_at", + "slug", + ] + + +class StateLiteSerializer(BaseSerializer): + """ + Lightweight state serializer for minimal data transfer. + + Provides essential state information including visual properties + and grouping data optimized for UI display and filtering. + """ + + class Meta: + model = State + fields = ["id", "name", "color", "group"] + read_only_fields = fields diff --git a/apps/api/plane/api/serializers/user.py b/apps/api/plane/api/serializers/user.py new file mode 100644 index 00000000..805eb9fe --- /dev/null +++ b/apps/api/plane/api/serializers/user.py @@ -0,0 +1,34 @@ +from rest_framework import serializers + +# Module imports +from plane.db.models import User + +from .base import BaseSerializer + + +class UserLiteSerializer(BaseSerializer): + """ + Lightweight user serializer for minimal data transfer. + + Provides essential user information including names, avatar, and contact details + optimized for member lists, assignee displays, and user references. + """ + + avatar_url = serializers.CharField( + help_text="Avatar URL", + read_only=True, + ) + + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "email", + "avatar", + "avatar_url", + "display_name", + "email", + ] + read_only_fields = fields diff --git a/apps/api/plane/api/serializers/workspace.py b/apps/api/plane/api/serializers/workspace.py new file mode 100644 index 00000000..e98683c2 --- /dev/null +++ b/apps/api/plane/api/serializers/workspace.py @@ -0,0 +1,17 @@ +# Module imports +from plane.db.models import Workspace +from .base import BaseSerializer + + +class WorkspaceLiteSerializer(BaseSerializer): + """ + Lightweight workspace serializer for minimal data transfer. + + Provides essential workspace identifiers including name, slug, and ID + optimized for navigation, references, and performance-critical operations. + """ + + class Meta: + model = Workspace + fields = ["name", "slug", "id"] + read_only_fields = fields diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py new file mode 100644 index 00000000..10cad206 --- /dev/null +++ b/apps/api/plane/api/urls/__init__.py @@ -0,0 +1,23 @@ +from .asset import urlpatterns as asset_patterns +from .cycle import urlpatterns as cycle_patterns +from .intake import urlpatterns as intake_patterns +from .label import urlpatterns as label_patterns +from .member import urlpatterns as member_patterns +from .module import urlpatterns as module_patterns +from .project import urlpatterns as project_patterns +from .state import urlpatterns as state_patterns +from .user import urlpatterns as user_patterns +from .work_item import urlpatterns as work_item_patterns + +urlpatterns = [ + *asset_patterns, + *cycle_patterns, + *intake_patterns, + *label_patterns, + *member_patterns, + *module_patterns, + *project_patterns, + *state_patterns, + *user_patterns, + *work_item_patterns, +] diff --git a/apps/api/plane/api/urls/asset.py b/apps/api/plane/api/urls/asset.py new file mode 100644 index 00000000..5bdd4d91 --- /dev/null +++ b/apps/api/plane/api/urls/asset.py @@ -0,0 +1,40 @@ +from django.urls import path + +from plane.api.views import ( + UserAssetEndpoint, + UserServerAssetEndpoint, + GenericAssetEndpoint, +) + +urlpatterns = [ + path( + "assets/user-assets/", + UserAssetEndpoint.as_view(http_method_names=["post"]), + name="user-assets", + ), + path( + "assets/user-assets//", + UserAssetEndpoint.as_view(http_method_names=["patch", "delete"]), + name="user-assets-detail", + ), + path( + "assets/user-assets/server/", + UserServerAssetEndpoint.as_view(http_method_names=["post"]), + name="user-server-assets", + ), + path( + "assets/user-assets//server/", + UserServerAssetEndpoint.as_view(http_method_names=["patch", "delete"]), + name="user-server-assets-detail", + ), + path( + "workspaces//assets/", + GenericAssetEndpoint.as_view(http_method_names=["post"]), + name="generic-asset", + ), + path( + "workspaces//assets//", + GenericAssetEndpoint.as_view(http_method_names=["get", "patch"]), + name="generic-asset-detail", + ), +] diff --git a/apps/api/plane/api/urls/cycle.py b/apps/api/plane/api/urls/cycle.py new file mode 100644 index 00000000..bd7136aa --- /dev/null +++ b/apps/api/plane/api/urls/cycle.py @@ -0,0 +1,53 @@ +from django.urls import path + +from plane.api.views.cycle import ( + CycleListCreateAPIEndpoint, + CycleDetailAPIEndpoint, + CycleIssueListCreateAPIEndpoint, + CycleIssueDetailAPIEndpoint, + TransferCycleIssueAPIEndpoint, + CycleArchiveUnarchiveAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//cycles/", + CycleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="cycles", + ), + path( + "workspaces//projects//cycles//", + CycleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="cycles", + ), + path( + "workspaces//projects//cycles//cycle-issues/", + CycleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="cycle-issues", + ), + path( + "workspaces//projects//cycles//cycle-issues//", + CycleIssueDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]), + name="cycle-issues", + ), + path( + "workspaces//projects//cycles//transfer-issues/", + TransferCycleIssueAPIEndpoint.as_view(http_method_names=["post"]), + name="transfer-issues", + ), + path( + "workspaces//projects//cycles//archive/", + CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]), + name="cycle-archive-unarchive", + ), + path( + "workspaces//projects//archived-cycles/", + CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]), + name="cycle-archive-unarchive", + ), + path( + "workspaces//projects//archived-cycles//unarchive/", + CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]), + name="cycle-archive-unarchive", + ), +] diff --git a/apps/api/plane/api/urls/intake.py b/apps/api/plane/api/urls/intake.py new file mode 100644 index 00000000..5538467a --- /dev/null +++ b/apps/api/plane/api/urls/intake.py @@ -0,0 +1,20 @@ +from django.urls import path + +from plane.api.views import ( + IntakeIssueListCreateAPIEndpoint, + IntakeIssueDetailAPIEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//projects//intake-issues/", + IntakeIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="intake-issue", + ), + path( + "workspaces//projects//intake-issues//", + IntakeIssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="intake-issue", + ), +] diff --git a/apps/api/plane/api/urls/label.py b/apps/api/plane/api/urls/label.py new file mode 100644 index 00000000..f7ee57b1 --- /dev/null +++ b/apps/api/plane/api/urls/label.py @@ -0,0 +1,17 @@ +from django.urls import path + +from plane.api.views import LabelListCreateAPIEndpoint, LabelDetailAPIEndpoint + + +urlpatterns = [ + path( + "workspaces//projects//labels/", + LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="label", + ), + path( + "workspaces//projects//labels//", + LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="label", + ), +] diff --git a/apps/api/plane/api/urls/member.py b/apps/api/plane/api/urls/member.py new file mode 100644 index 00000000..a2b331ea --- /dev/null +++ b/apps/api/plane/api/urls/member.py @@ -0,0 +1,16 @@ +from django.urls import path + +from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint + +urlpatterns = [ + path( + "workspaces//projects//members/", + ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]), + name="project-members", + ), + path( + "workspaces//members/", + WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]), + name="workspace-members", + ), +] diff --git a/apps/api/plane/api/urls/module.py b/apps/api/plane/api/urls/module.py new file mode 100644 index 00000000..578f5c86 --- /dev/null +++ b/apps/api/plane/api/urls/module.py @@ -0,0 +1,47 @@ +from django.urls import path + +from plane.api.views import ( + ModuleListCreateAPIEndpoint, + ModuleDetailAPIEndpoint, + ModuleIssueListCreateAPIEndpoint, + ModuleIssueDetailAPIEndpoint, + ModuleArchiveUnarchiveAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//modules/", + ModuleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="modules", + ), + path( + "workspaces//projects//modules//", + ModuleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="modules-detail", + ), + path( + "workspaces//projects//modules//module-issues/", + ModuleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="module-issues", + ), + path( + "workspaces//projects//modules//module-issues//", + ModuleIssueDetailAPIEndpoint.as_view(http_method_names=["delete"]), + name="module-issues-detail", + ), + path( + "workspaces//projects//modules//archive/", + ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]), + name="module-archive", + ), + path( + "workspaces//projects//archived-modules/", + ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]), + name="module-archive-list", + ), + path( + "workspaces//projects//archived-modules//unarchive/", + ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]), + name="module-unarchive", + ), +] diff --git a/apps/api/plane/api/urls/project.py b/apps/api/plane/api/urls/project.py new file mode 100644 index 00000000..9cf9291a --- /dev/null +++ b/apps/api/plane/api/urls/project.py @@ -0,0 +1,25 @@ +from django.urls import path + +from plane.api.views import ( + ProjectListCreateAPIEndpoint, + ProjectDetailAPIEndpoint, + ProjectArchiveUnarchiveAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects/", + ProjectListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="project", + ), + path( + "workspaces//projects//", + ProjectDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="project", + ), + path( + "workspaces//projects//archive/", + ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]), + name="project-archive-unarchive", + ), +] diff --git a/apps/api/plane/api/urls/schema.py b/apps/api/plane/api/urls/schema.py new file mode 100644 index 00000000..781dbe9d --- /dev/null +++ b/apps/api/plane/api/urls/schema.py @@ -0,0 +1,20 @@ +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) +from django.urls import path + +urlpatterns = [ + path("schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "schema/swagger-ui/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path( + "schema/redoc/", + SpectacularRedocView.as_view(url_name="schema"), + name="redoc", + ), +] diff --git a/apps/api/plane/api/urls/state.py b/apps/api/plane/api/urls/state.py new file mode 100644 index 00000000..e35012a2 --- /dev/null +++ b/apps/api/plane/api/urls/state.py @@ -0,0 +1,19 @@ +from django.urls import path + +from plane.api.views import ( + StateListCreateAPIEndpoint, + StateDetailAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//states/", + StateListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="states", + ), + path( + "workspaces//projects//states//", + StateDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="states", + ), +] diff --git a/apps/api/plane/api/urls/user.py b/apps/api/plane/api/urls/user.py new file mode 100644 index 00000000..461b0833 --- /dev/null +++ b/apps/api/plane/api/urls/user.py @@ -0,0 +1,11 @@ +from django.urls import path + +from plane.api.views import UserEndpoint + +urlpatterns = [ + path( + "users/me/", + UserEndpoint.as_view(http_method_names=["get"]), + name="users", + ), +] diff --git a/apps/api/plane/api/urls/work_item.py b/apps/api/plane/api/urls/work_item.py new file mode 100644 index 00000000..7207df95 --- /dev/null +++ b/apps/api/plane/api/urls/work_item.py @@ -0,0 +1,146 @@ +from django.urls import path + +from plane.api.views import ( + IssueListCreateAPIEndpoint, + IssueDetailAPIEndpoint, + IssueLinkListCreateAPIEndpoint, + IssueLinkDetailAPIEndpoint, + IssueCommentListCreateAPIEndpoint, + IssueCommentDetailAPIEndpoint, + IssueActivityListAPIEndpoint, + IssueActivityDetailAPIEndpoint, + IssueAttachmentListCreateAPIEndpoint, + IssueAttachmentDetailAPIEndpoint, + WorkspaceIssueAPIEndpoint, + IssueSearchEndpoint, +) + +# Deprecated url patterns +old_url_patterns = [ + path( + "workspaces//issues/search/", + IssueSearchEndpoint.as_view(http_method_names=["get"]), + name="issue-search", + ), + path( + "workspaces//issues/-/", + WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]), + name="issue-by-identifier", + ), + path( + "workspaces//projects//issues/", + IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="issue", + ), + path( + "workspaces//projects//issues//", + IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="issue", + ), + path( + "workspaces//projects//issues//links/", + IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="link", + ), + path( + "workspaces//projects//issues//links//", + IssueLinkDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="link", + ), + path( + "workspaces//projects//issues//comments/", + IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="comment", + ), + path( + "workspaces//projects//issues//comments//", + IssueCommentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="comment", + ), + path( + "workspaces//projects//issues//activities/", + IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]), + name="activity", + ), + path( + "workspaces//projects//issues//activities//", + IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]), + name="activity", + ), + path( + "workspaces//projects//issues//issue-attachments/", + IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="attachment", + ), + path( + "workspaces//projects//issues//issue-attachments//", + IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="issue-attachment", + ), +] + +# New url patterns with work-items as the prefix +new_url_patterns = [ + path( + "workspaces//work-items/search/", + IssueSearchEndpoint.as_view(http_method_names=["get"]), + name="work-item-search", + ), + path( + "workspaces//work-items/-/", + WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]), + name="work-item-by-identifier", + ), + path( + "workspaces//projects//work-items/", + IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-list", + ), + path( + "workspaces//projects//work-items//", + IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="work-item-detail", + ), + path( + "workspaces//projects//work-items//links/", + IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-link-list", + ), + path( + "workspaces//projects//work-items//links//", + IssueLinkDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="work-item-link-detail", + ), + path( + "workspaces//projects//work-items//comments/", + IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-comment-list", + ), + path( + "workspaces//projects//work-items//comments//", + IssueCommentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="work-item-comment-detail", + ), + path( + "workspaces//projects//work-items//activities/", + IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]), + name="work-item-activity-list", + ), + path( + "workspaces//projects//work-items//activities//", + IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]), + name="work-item-activity-detail", + ), + path( + "workspaces//projects//work-items//attachments/", + IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-attachment-list", + ), + path( + "workspaces//projects//work-items//attachments//", + IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="work-item-attachment-detail", + ), +] + +urlpatterns = old_url_patterns + new_url_patterns diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py new file mode 100644 index 00000000..8535d485 --- /dev/null +++ b/apps/api/plane/api/views/__init__.py @@ -0,0 +1,55 @@ +from .project import ( + ProjectListCreateAPIEndpoint, + ProjectDetailAPIEndpoint, + ProjectArchiveUnarchiveAPIEndpoint, +) + +from .state import ( + StateListCreateAPIEndpoint, + StateDetailAPIEndpoint, +) + +from .issue import ( + WorkspaceIssueAPIEndpoint, + IssueListCreateAPIEndpoint, + IssueDetailAPIEndpoint, + LabelListCreateAPIEndpoint, + LabelDetailAPIEndpoint, + IssueLinkListCreateAPIEndpoint, + IssueLinkDetailAPIEndpoint, + IssueCommentListCreateAPIEndpoint, + IssueCommentDetailAPIEndpoint, + IssueActivityListAPIEndpoint, + IssueActivityDetailAPIEndpoint, + IssueAttachmentListCreateAPIEndpoint, + IssueAttachmentDetailAPIEndpoint, + IssueSearchEndpoint, +) + +from .cycle import ( + CycleListCreateAPIEndpoint, + CycleDetailAPIEndpoint, + CycleIssueListCreateAPIEndpoint, + CycleIssueDetailAPIEndpoint, + TransferCycleIssueAPIEndpoint, + CycleArchiveUnarchiveAPIEndpoint, +) + +from .module import ( + ModuleListCreateAPIEndpoint, + ModuleDetailAPIEndpoint, + ModuleIssueListCreateAPIEndpoint, + ModuleIssueDetailAPIEndpoint, + ModuleArchiveUnarchiveAPIEndpoint, +) + +from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint + +from .intake import ( + IntakeIssueListCreateAPIEndpoint, + IntakeIssueDetailAPIEndpoint, +) + +from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint + +from .user import UserEndpoint diff --git a/apps/api/plane/api/views/asset.py b/apps/api/plane/api/views/asset.py new file mode 100644 index 00000000..a91ebc88 --- /dev/null +++ b/apps/api/plane/api/views/asset.py @@ -0,0 +1,613 @@ +# Python Imports +import uuid + +# Django Imports +from django.utils import timezone +from django.conf import settings + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import OpenApiExample, OpenApiRequest + +# Module Imports +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata +from plane.settings.storage import S3Storage +from plane.db.models import FileAsset, User, Workspace +from plane.api.views.base import BaseAPIView +from plane.api.serializers import ( + UserAssetUploadSerializer, + AssetUpdateSerializer, + GenericAssetUploadSerializer, + GenericAssetUpdateSerializer, +) +from plane.utils.openapi import ( + ASSET_ID_PARAMETER, + WORKSPACE_SLUG_PARAMETER, + PRESIGNED_URL_SUCCESS_RESPONSE, + GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE, + GENERIC_ASSET_VALIDATION_ERROR_RESPONSE, + ASSET_CONFLICT_RESPONSE, + ASSET_DOWNLOAD_SUCCESS_RESPONSE, + ASSET_DOWNLOAD_ERROR_RESPONSE, + ASSET_UPDATED_RESPONSE, + ASSET_DELETED_RESPONSE, + VALIDATION_ERROR_RESPONSE, + ASSET_NOT_FOUND_RESPONSE, + NOT_FOUND_RESPONSE, + UNAUTHORIZED_RESPONSE, + asset_docs, +) +from plane.utils.exception_logger import log_exception + + +class UserAssetEndpoint(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_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() + 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() + return + return + + @asset_docs( + operation_id="create_user_asset_upload", + summary="Generate presigned URL for user asset upload", + description="Generate presigned URL for user asset upload", + request=OpenApiRequest( + request=UserAssetUploadSerializer, + examples=[ + OpenApiExample( + "User Avatar Upload", + value={ + "name": "profile.jpg", + "type": "image/jpeg", + "size": 1024000, + "entity_type": "USER_AVATAR", + }, + description="Example request for uploading a user avatar", + ), + OpenApiExample( + "User Cover Upload", + value={ + "name": "cover.jpg", + "type": "image/jpeg", + "size": 1024000, + "entity_type": "USER_COVER", + }, + description="Example request for uploading a user cover", + ), + ], + ), + responses={ + 200: PRESIGNED_URL_SUCCESS_RESPONSE, + 400: VALIDATION_ERROR_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + }, + ) + def post(self, request): + """Generate presigned URL for user asset upload. + + Create a presigned URL for uploading user profile assets (avatar or cover image). + This endpoint generates the necessary credentials for direct S3 upload. + """ + # 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", + "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, + ) + + # 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, + ) + + @asset_docs( + operation_id="update_user_asset", + summary="Mark user asset as uploaded", + description="Mark user asset as uploaded", + parameters=[ASSET_ID_PARAMETER], + request=OpenApiRequest( + request=AssetUpdateSerializer, + examples=[ + OpenApiExample( + "Update Asset Attributes", + value={ + "attributes": { + "name": "updated_profile.jpg", + "type": "image/jpeg", + "size": 1024000, + }, + "entity_type": "USER_AVATAR", + }, + description="Example request for updating asset attributes", + ), + ], + ), + responses={ + 204: ASSET_UPDATED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + ) + def patch(self, request, asset_id): + """Update user asset after upload completion. + + Update the asset status and attributes after the file has been uploaded to S3. + This endpoint should be called after completing the S3 upload to mark the asset as uploaded. + """ + # 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)) + # 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) + + @asset_docs( + operation_id="delete_user_asset", + summary="Delete user asset", + parameters=[ASSET_ID_PARAMETER], + responses={ + 204: ASSET_DELETED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, asset_id): + """Delete user asset. + + Delete a user profile asset (avatar or cover image) and remove its reference from the user profile. + This performs a soft delete by marking the asset as deleted and updating the user's profile. + """ + 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 UserServerAssetEndpoint(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_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() + 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() + return + return + + @asset_docs( + operation_id="create_user_server_asset_upload", + summary="Generate presigned URL for user server asset upload", + request=UserAssetUploadSerializer, + responses={ + 200: PRESIGNED_URL_SUCCESS_RESPONSE, + 400: VALIDATION_ERROR_RESPONSE, + }, + ) + def post(self, request): + """Generate presigned URL for user server asset upload. + + Create a presigned URL for uploading user profile assets + (avatar or cover image) using server credentials. This endpoint generates the + necessary credentials for direct S3 upload with server-side authentication. + """ + # 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", + "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, + ) + + # 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, is_server=True) + # 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, + ) + + @asset_docs( + operation_id="update_user_server_asset", + summary="Mark user server asset as uploaded", + parameters=[ASSET_ID_PARAMETER], + request=AssetUpdateSerializer, + responses={ + 204: ASSET_UPDATED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + ) + def patch(self, request, asset_id): + """Update user server asset after upload completion. + + Update the asset status and attributes after the file has been uploaded to S3 using server credentials. + This endpoint should be called after completing the S3 upload to mark the asset as uploaded. + """ + # 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)) + # 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) + + @asset_docs( + operation_id="delete_user_server_asset", + summary="Delete user server asset", + parameters=[ASSET_ID_PARAMETER], + responses={ + 204: ASSET_DELETED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, asset_id): + """Delete user server asset. + + Delete a user profile asset (avatar or cover image) using server credentials and + remove its reference from the user profile. This performs a soft delete by marking the + asset as deleted and updating the user's profile. + """ + 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 GenericAssetEndpoint(BaseAPIView): + """This endpoint is used to upload generic assets that can be later bound to entities.""" + + use_read_replica = True + + @asset_docs( + operation_id="get_generic_asset", + summary="Get presigned URL for asset download", + description="Get presigned URL for asset download", + parameters=[WORKSPACE_SLUG_PARAMETER], + responses={ + 200: ASSET_DOWNLOAD_SUCCESS_RESPONSE, + 400: ASSET_DOWNLOAD_ERROR_RESPONSE, + 404: ASSET_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, asset_id): + """Get presigned URL for asset download. + + Generate a presigned URL for downloading a generic asset. + The asset must be uploaded and associated with the specified workspace. + """ + try: + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # Get the asset + asset = FileAsset.objects.get(id=asset_id, workspace_id=workspace.id, is_deleted=False) + + # Check if the asset exists and is uploaded + if not asset.is_uploaded: + return Response( + {"error": "Asset not yet uploaded"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Generate presigned URL for GET + storage = S3Storage(request=request, is_server=True) + presigned_url = storage.generate_presigned_url( + object_name=asset.asset.name, filename=asset.attributes.get("name") + ) + + return Response( + { + "asset_id": str(asset.id), + "asset_url": presigned_url, + "asset_name": asset.attributes.get("name", ""), + "asset_type": asset.attributes.get("type", ""), + }, + status=status.HTTP_200_OK, + ) + + except Workspace.DoesNotExist: + return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND) + except FileAsset.DoesNotExist: + return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + log_exception(e) + return Response( + {"error": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + @asset_docs( + operation_id="create_generic_asset_upload", + summary="Generate presigned URL for generic asset upload", + description="Generate presigned URL for generic asset upload", + parameters=[WORKSPACE_SLUG_PARAMETER], + request=OpenApiRequest( + request=GenericAssetUploadSerializer, + examples=[ + OpenApiExample( + "GenericAssetUploadSerializer", + value={ + "name": "image.jpg", + "type": "image/jpeg", + "size": 1024000, + "project_id": "123e4567-e89b-12d3-a456-426614174000", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for uploading a generic asset", + ), + ], + ), + responses={ + 200: GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE, + 400: GENERIC_ASSET_VALIDATION_ERROR_RESPONSE, + 404: NOT_FOUND_RESPONSE, + 409: ASSET_CONFLICT_RESPONSE, + }, + ) + def post(self, request, slug): + """Generate presigned URL for generic asset upload. + + Create a presigned URL for uploading generic assets that can be bound to entities like work items. + Supports various file types and includes external source tracking for integrations. + """ + name = request.data.get("name") + type = request.data.get("type") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + project_id = request.data.get("project_id") + external_id = request.data.get("external_id") + external_source = request.data.get("external_source") + + # Check if the request is valid + if not name or not size: + return Response( + {"error": "Name and size are required fields.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file size is within the limit + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + # Check if the file type is allowed + 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}" + + # Check for existing asset with same external details if provided + if external_id and external_source: + existing_asset = FileAsset.objects.filter( + workspace__slug=slug, + external_source=external_source, + external_id=external_id, + is_deleted=False, + ).first() + + if existing_asset: + return Response( + { + "message": "Asset with same external id and source already exists", + "asset_id": str(existing_asset.id), + "asset_url": existing_asset.asset_url, + }, + status=status.HTTP_409_CONFLICT, + ) + + # 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, + project_id=project_id, + created_by=request.user, + external_id=external_id, + external_source=external_source, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues # noqa: E501 + ) + + # Get the presigned URL + storage = S3Storage(request=request, is_server=True) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) + + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + @asset_docs( + operation_id="update_generic_asset", + summary="Update generic asset after upload completion", + description="Update generic asset after upload completion", + parameters=[WORKSPACE_SLUG_PARAMETER, ASSET_ID_PARAMETER], + request=OpenApiRequest( + request=GenericAssetUpdateSerializer, + examples=[ + OpenApiExample( + "GenericAssetUpdateSerializer", + value={"is_uploaded": True}, + description="Example request for updating a generic asset", + ) + ], + ), + responses={ + 204: ASSET_UPDATED_RESPONSE, + 404: ASSET_NOT_FOUND_RESPONSE, + }, + ) + def patch(self, request, slug, asset_id): + """Update generic asset after upload completion. + + Update the asset status after the file has been uploaded to S3. + This endpoint should be called after completing the S3 upload to mark the asset as uploaded + and trigger metadata extraction. + """ + try: + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug, is_deleted=False) + + # Update is_uploaded status + asset.is_uploaded = request.data.get("is_uploaded", asset.is_uploaded) + + # Update storage metadata if not present + if not asset.storage_metadata: + get_asset_object_metadata.delay(asset_id=str(asset_id)) + + asset.save(update_fields=["is_uploaded"]) + + return Response(status=status.HTTP_204_NO_CONTENT) + except FileAsset.DoesNotExist: + return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) diff --git a/apps/api/plane/api/views/base.py b/apps/api/plane/api/views/base.py new file mode 100644 index 00000000..b3acbab3 --- /dev/null +++ b/apps/api/plane/api/views/base.py @@ -0,0 +1,154 @@ +# Python imports +import zoneinfo + +# Django imports +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import IntegrityError +from django.urls import resolve +from django.utils import timezone +from plane.db.models.api import APIToken +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +# Third party imports +from rest_framework.generics import GenericAPIView + +# Module imports +from plane.api.middleware.api_authentication import APIKeyAuthentication +from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle +from plane.utils.exception_logger import log_exception +from plane.utils.paginator import BasePaginator +from plane.utils.core.mixins import ReadReplicaControlMixin + + +class TimezoneMixin: + """ + This enables timezone conversion according + to the user set timezone + """ + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if request.user.is_authenticated: + timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone)) + else: + timezone.deactivate() + + +class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator): + authentication_classes = [APIKeyAuthentication] + + permission_classes = [IsAuthenticated] + + use_read_replica = False + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def get_throttles(self): + throttle_classes = [] + api_key = self.request.headers.get("X-Api-Key") + + if api_key: + service_token = APIToken.objects.filter(token=api_key, is_service=True).first() + + if service_token: + throttle_classes.append(ServiceTokenRateThrottle()) + return throttle_classes + + throttle_classes.append(ApiKeyRateThrottle()) + + return throttle_classes + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + return Response( + {"error": "The requested resource does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + return Response( + {"error": "The required key does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + log_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + if settings.DEBUG: + from django.db import connection + + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") + return response + except Exception as exc: + response = self.handle_exception(exc) + return exc + + def finalize_response(self, request, response, *args, **kwargs): + # Call super to get the default response + response = super().finalize_response(request, response, *args, **kwargs) + + # Add custom headers if they exist in the request META + ratelimit_remaining = request.META.get("X-RateLimit-Remaining") + if ratelimit_remaining is not None: + response["X-RateLimit-Remaining"] = ratelimit_remaining + + ratelimit_reset = request.META.get("X-RateLimit-Reset") + if ratelimit_reset is not None: + response["X-RateLimit-Reset"] = ratelimit_reset + + return response + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def project_id(self): + project_id = self.kwargs.get("project_id", None) + if project_id: + return project_id + + if resolve(self.request.path_info).url_name == "project": + return self.kwargs.get("pk", None) + + @property + def fields(self): + fields = [field for field in self.request.GET.get("fields", "").split(",") if field] + return fields if fields else None + + @property + def expand(self): + expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] + return expand if expand else None diff --git a/apps/api/plane/api/views/cycle.py b/apps/api/plane/api/views/cycle.py new file mode 100644 index 00000000..849dab34 --- /dev/null +++ b/apps/api/plane/api/views/cycle.py @@ -0,0 +1,1247 @@ +# Python imports +import json + +# Django imports +from django.core import serializers +from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + Count, + F, + Func, + OuterRef, + Q, + Sum, +) + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import OpenApiRequest, OpenApiResponse + +# Module imports +from plane.api.serializers import ( + CycleIssueSerializer, + CycleSerializer, + CycleIssueRequestSerializer, + TransferCycleIssueRequestSerializer, + CycleCreateSerializer, + CycleUpdateSerializer, + IssueSerializer, +) +from plane.app.permissions import ProjectEntityPermission +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import ( + Cycle, + CycleIssue, + Issue, + Project, + FileAsset, + IssueLink, + ProjectMember, + UserFavorite, +) +from plane.utils.cycle_transfer_issues import transfer_cycle_issues +from plane.utils.host import base_host +from .base import BaseAPIView +from plane.bgtasks.webhook_task import model_activity +from plane.utils.openapi.decorators import cycle_docs +from plane.utils.openapi import ( + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + CYCLE_VIEW_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + create_paginated_response, + # Request Examples + CYCLE_CREATE_EXAMPLE, + CYCLE_UPDATE_EXAMPLE, + CYCLE_ISSUE_REQUEST_EXAMPLE, + TRANSFER_CYCLE_ISSUE_EXAMPLE, + # Response Examples + CYCLE_EXAMPLE, + CYCLE_ISSUE_EXAMPLE, + TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE, + TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE, + TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE, + DELETED_RESPONSE, + ARCHIVED_RESPONSE, + CYCLE_CANNOT_ARCHIVE_RESPONSE, + UNARCHIVED_RESPONSE, + REQUIRED_FIELDS_RESPONSE, +) + + +class CycleListCreateAPIEndpoint(BaseAPIView): + """Cycle List and Create Endpoint""" + + serializer_class = CycleSerializer + model = Cycle + webhook_event = "cycle" + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @cycle_docs( + operation_id="list_cycles", + summary="List cycles", + description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.", # noqa: E501 + parameters=[ + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + CYCLE_VIEW_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + CycleSerializer, + "PaginatedCycleResponse", + "Paginated list of cycles", + "Paginated Cycles", + ), + }, + ) + def get(self, request, slug, project_id): + """List cycles + + Retrieve all cycles in a project. + Supports filtering by cycle status like current, upcoming, completed, or draft. + """ + project = Project.objects.get(workspace__slug=slug, pk=project_id) + 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() + ) + data = CycleSerializer( + queryset, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data + return Response(data, status=status.HTTP_200_OK) + + # Upcoming Cycles + if cycle_view == "upcoming": + queryset = queryset.filter(start_date__gt=timezone.now()) + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data, + ) + + # Completed Cycles + if cycle_view == "completed": + queryset = queryset.filter(end_date__lt=timezone.now()) + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data, + ) + + # Draft Cycles + if cycle_view == "draft": + 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, + context={"project": project}, + ).data, + ) + + # Incomplete Cycles + if cycle_view == "incomplete": + queryset = queryset.filter( + 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, + context={"project": project}, + ).data, + ) + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data, + ) + + @cycle_docs( + operation_id="create_cycle", + summary="Create cycle", + description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.", # noqa: E501 + request=OpenApiRequest( + request=CycleCreateSerializer, + examples=[CYCLE_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Cycle created", + response=CycleSerializer, + examples=[CYCLE_EXAMPLE], + ), + }, + ) + def post(self, request, slug, project_id): + """Create cycle + + Create a new development cycle with specified name, description, and date range. + Supports external ID tracking for integration purposes. + """ + if ( + request.data.get("start_date", None) is None + and request.data.get("end_date", None) is None + ) or ( + request.data.get("start_date", None) is not None + and request.data.get("end_date", None) is not None + ): + + serializer = CycleCreateSerializer( + data=request.data, context={"request": request} + ) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Cycle.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + cycle = Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).first() + return Response( + { + "error": "Cycle with the same external id and external source already exists", + "id": str(cycle.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save(project_id=project_id) + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(serializer.instance.id), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + + cycle = Cycle.objects.get(pk=serializer.instance.id) + serializer = CycleSerializer(cycle) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response( + { + "error": "Both start date and end date are either required or are to be null" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class CycleDetailAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `retrieve`, `update` and `destroy` actions related to cycle. + """ + + serializer_class = CycleSerializer + model = Cycle + webhook_event = "cycle" + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @cycle_docs( + operation_id="retrieve_cycle", + summary="Retrieve cycle", + description="Retrieve details of a specific cycle by its ID. Supports cycle status filtering.", + responses={ + 200: OpenApiResponse( + description="Cycles", + response=CycleSerializer, + examples=[CYCLE_EXAMPLE], + ), + }, + ) + def get(self, request, slug, project_id, pk): + """List or retrieve cycles + + Retrieve all cycles in a project or get details of a specific cycle. + Supports filtering by cycle status like current, upcoming, completed, or draft. + """ + project = Project.objects.get(workspace__slug=slug, pk=project_id) + queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) + data = CycleSerializer( + queryset, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data + return Response(data, status=status.HTTP_200_OK) + + @cycle_docs( + operation_id="update_cycle", + summary="Update cycle", + description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.", # noqa: E501 + request=OpenApiRequest( + request=CycleUpdateSerializer, + examples=[CYCLE_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Cycle updated", + response=CycleSerializer, + examples=[CYCLE_EXAMPLE], + ), + }, + ) + def patch(self, request, slug, project_id, pk): + """Update cycle + + Modify an existing cycle's properties like name, description, or date range. + Completed cycles can only have their sort order changed. + """ + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + + current_instance = json.dumps( + CycleSerializer(cycle).data, cls=DjangoJSONEncoder + ) + + if cycle.archived_at: + return Response( + {"error": "Archived cycle cannot be edited"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + request_data = request.data + + 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) + } + else: + return Response( + { + "error": "The Cycle has already been completed so it cannot be edited" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = CycleUpdateSerializer( + cycle, data=request.data, partial=True, context={"request": request} + ) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and (cycle.external_id != request.data.get("external_id")) + and Cycle.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", cycle.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Cycle with the same external id and external source already exists", + "id": str(cycle.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() + + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(serializer.instance.id), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + cycle = Cycle.objects.get(pk=serializer.instance.id) + serializer = CycleSerializer(cycle) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @cycle_docs( + operation_id="delete_cycle", + summary="Delete cycle", + description="Permanently remove a cycle and all its associated issue relationships", + responses={ + 204: DELETED_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, pk): + """Delete cycle + + Permanently remove a cycle and all its associated issue relationships. + Only admins or the cycle creator can perform this action. + """ + 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, + member=request.user, + role=20, + project_id=project_id, + is_active=True, + ).exists() + ): + return Response( + {"error": "Only admin or creator can delete the cycle"}, + status=status.HTTP_403_FORBIDDEN, + ) + + cycle_issues = list( + CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( + "issue", flat=True + ) + ) + + issue_activity.delay( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(pk), + "cycle_name": str(cycle.name), + "issues": [str(issue_id) for issue_id in cycle_issues], + } + ), + actor_id=str(request.user.id), + issue_id=None, + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + # Delete the cycle + cycle.delete() + # Delete the user favorite cycle + UserFavorite.objects.filter( + entity_type="cycle", entity_identifier=pk, project_id=project_id + ).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): + """Cycle Archive and Unarchive Endpoint""" + + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(archived_at__isnull=False) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @cycle_docs( + operation_id="list_archived_cycles", + description="Retrieve all cycles that have been archived in the project.", + summary="List archived cycles", + parameters=[CURSOR_PARAMETER, PER_PAGE_PARAMETER], + request={}, + responses={ + 200: create_paginated_response( + CycleSerializer, + "PaginatedArchivedCycleResponse", + "Paginated list of archived cycles", + "Paginated Archived Cycles", + ), + }, + ) + def get(self, request, slug, project_id): + """List archived cycles + + Retrieve all cycles that have been archived in the project. + Returns paginated results with cycle statistics and completion data. + """ + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda cycles: CycleSerializer( + cycles, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + @cycle_docs( + operation_id="archive_cycle", + summary="Archive cycle", + description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.", # noqa: E501 + request={}, + responses={ + 204: ARCHIVED_RESPONSE, + 400: CYCLE_CANNOT_ARCHIVE_RESPONSE, + }, + ) + def post(self, request, slug, project_id, cycle_id): + """Archive cycle + + Move a completed cycle to archived status for historical tracking. + Only cycles that have ended can be archived. + """ + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) + if cycle.end_date >= timezone.now(): + return Response( + {"error": "Only completed cycles can be archived"}, + status=status.HTTP_400_BAD_REQUEST, + ) + cycle.archived_at = timezone.now() + cycle.save() + UserFavorite.objects.filter( + entity_type="cycle", + entity_identifier=cycle_id, + project_id=project_id, + workspace__slug=slug, + ).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @cycle_docs( + operation_id="unarchive_cycle", + summary="Unarchive cycle", + description="Restore an archived cycle to active status, making it available for regular use.", + request={}, + responses={ + 204: UNARCHIVED_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, cycle_id): + """Unarchive cycle + + Restore an archived cycle to active status, making it available for regular use. + The cycle will reappear in active cycle lists. + """ + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) + cycle.archived_at = None + cycle.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CycleIssueListCreateAPIEndpoint(BaseAPIView): + """Cycle Issue List and Create Endpoint""" + + serializer_class = CycleIssueSerializer + model = CycleIssue + webhook_event = "cycle_issue" + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + CycleIssue.objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(cycle_id=self.kwargs.get("cycle_id")) + .select_related("project") + .select_related("workspace") + .select_related("cycle") + .select_related("issue", "issue__state", "issue__project") + .prefetch_related("issue__assignees", "issue__labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @cycle_docs( + operation_id="list_cycle_work_items", + summary="List cycle work items", + description="Retrieve all work items assigned to a cycle.", + parameters=[CURSOR_PARAMETER, PER_PAGE_PARAMETER], + request={}, + responses={ + 200: create_paginated_response( + CycleIssueSerializer, + "PaginatedCycleIssueResponse", + "Paginated list of cycle work items", + "Paginated Cycle Work Items", + ), + }, + ) + def get(self, request, slug, project_id, cycle_id): + """List or retrieve cycle work items + + Retrieve all work items assigned to a cycle or get details of a specific cycle work item. + Returns paginated results with work item details, assignees, and labels. + """ + # List + order_by = request.GET.get("order_by", "created_at") + issues = ( + 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")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate(bridge_id=F("issue_cycle__id")) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(order_by) + .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") + ) + ) + + return self.paginate( + request=request, + queryset=(issues), + on_results=lambda issues: IssueSerializer( + issues, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + @cycle_docs( + operation_id="add_cycle_work_items", + summary="Add Work Items to Cycle", + description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.", # noqa: E501 + request=OpenApiRequest( + request=CycleIssueRequestSerializer, + examples=[CYCLE_ISSUE_REQUEST_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Cycle work items added", + response=CycleIssueSerializer, + examples=[CYCLE_ISSUE_EXAMPLE], + ), + 400: REQUIRED_FIELDS_RESPONSE, + }, + ) + def post(self, request, slug, project_id, cycle_id): + """Add cycle issues + + Assign multiple work items to a cycle or move them from another cycle. + Automatically handles bulk creation and updates with activity tracking. + """ + issues = request.data.get("issues", []) + + if not issues: + return Response( + {"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, + 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(): + return Response( + { + "code": "CYCLE_COMPLETED", + "message": "The Cycle has already been completed so no new issues can be added", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get all CycleWorkItems already created + cycle_issues = list( + 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 + if str(cycle_issue.issue_id) in issues + ] + new_issues = list(set(issues) - set(existing_issues)) + + # New issues to create + created_records = CycleIssue.objects.bulk_create( + [ + CycleIssue( + project_id=project_id, + workspace_id=cycle.workspace_id, + cycle_id=cycle_id, + issue_id=issue, + ) + for issue in new_issues + ], + ignore_conflicts=True, + batch_size=10, + ) + + # Updated Issues + updated_records = [] + update_cycle_issue_activity = [] + # Iterate over each cycle_issue in cycle_issues + for cycle_issue in cycle_issues: + old_cycle_id = cycle_issue.cycle_id + # Update the cycle_issue's cycle_id + cycle_issue.cycle_id = cycle_id + # Add the modified cycle_issue to the records_to_update list + updated_records.append(cycle_issue) + # Record the update activity + update_cycle_issue_activity.append( + { + "old_cycle_id": str(old_cycle_id), + "new_cycle_id": str(cycle_id), + "issue_id": str(cycle_issue.issue_id), + } + ) + + # Update the cycle issues + CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100) + + # Capture Issue Activity + issue_activity.delay( + type="cycle.activity.created", + requested_data=json.dumps({"cycles_list": issues}), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "updated_cycle_issues": update_cycle_issue_activity, + "created_cycle_issues": serializers.serialize( + "json", created_records + ), + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + # Return all Cycle Issues + return Response( + CycleIssueSerializer(self.get_queryset(), many=True).data, + status=status.HTTP_200_OK, + ) + + +class CycleIssueDetailAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, + and `destroy` actions related to cycle issues. + + """ + + serializer_class = CycleIssueSerializer + model = CycleIssue + webhook_event = "cycle_issue" + bulk = True + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + CycleIssue.objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(cycle_id=self.kwargs.get("cycle_id")) + .select_related("project") + .select_related("workspace") + .select_related("cycle") + .select_related("issue", "issue__state", "issue__project") + .prefetch_related("issue__assignees", "issue__labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @cycle_docs( + operation_id="retrieve_cycle_work_item", + summary="Retrieve cycle work item", + description="Retrieve details of a specific cycle work item.", + responses={ + 200: OpenApiResponse( + description="Cycle work items", + response=CycleIssueSerializer, + examples=[CYCLE_ISSUE_EXAMPLE], + ), + }, + ) + def get(self, request, slug, project_id, cycle_id, issue_id): + """Retrieve cycle work item + + Retrieve details of a specific cycle work item. + Returns paginated results with work item details, assignees, and labels. + """ + cycle_issue = CycleIssue.objects.get( + workspace__slug=slug, + project_id=project_id, + cycle_id=cycle_id, + issue_id=issue_id, + ) + serializer = CycleIssueSerializer( + cycle_issue, fields=self.fields, expand=self.expand + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + @cycle_docs( + operation_id="delete_cycle_work_item", + summary="Delete cycle work item", + description="Remove a work item from a cycle while keeping the work item in the project.", + responses={ + 204: DELETED_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, cycle_id, issue_id): + """Remove cycle work item + + Remove a work item from a cycle while keeping the work item in the project. + Records the removal activity for tracking purposes. + """ + cycle_issue = CycleIssue.objects.get( + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + cycle_id=cycle_id, + ) + issue_id = cycle_issue.issue_id + cycle_issue.delete() + issue_activity.delay( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(self.kwargs.get("cycle_id")), + "issues": [str(issue_id)], + } + ), + actor_id=str(self.request.user.id), + issue_id=str(issue_id), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TransferCycleIssueAPIEndpoint(BaseAPIView): + """ + This viewset provides `create` actions for transferring the issues into a particular cycle. + + """ + + permission_classes = [ProjectEntityPermission] + + @cycle_docs( + operation_id="transfer_cycle_work_items", + summary="Transfer cycle work items", + description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.", # noqa: E501 + request=OpenApiRequest( + request=TransferCycleIssueRequestSerializer, + examples=[TRANSFER_CYCLE_ISSUE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Work items transferred successfully", + response={ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Success message", + "example": "Success", + }, + }, + }, + examples=[TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE], + ), + 400: OpenApiResponse( + description="Bad request", + response={ + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message", + "example": "New Cycle Id is required", + }, + }, + }, + examples=[ + TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE, + TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE, + ], + ), + }, + ) + def post(self, request, slug, project_id, cycle_id): + """Transfer cycle issues + + Move incomplete issues from the current cycle to a new target cycle. + Captures progress snapshot and transfers only unfinished work items. + """ + new_cycle_id = request.data.get("new_cycle_id", False) + + if not new_cycle_id: + return Response( + {"error": "New Cycle Id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + old_cycle = Cycle.objects.get( + workspace__slug=slug, + project_id=project_id, + pk=cycle_id, + ) + # transfer work items only when cycle is completed (passed the end data) + if old_cycle.end_date is not None and old_cycle.end_date < timezone.now(): + return Response( + {"error": "The old cycle is not completed yet"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Call the utility function to handle the transfer + result = transfer_cycle_issues( + slug=slug, + project_id=project_id, + cycle_id=cycle_id, + new_cycle_id=new_cycle_id, + request=request, + user_id=self.request.user.id, + ) + + # Handle the result + if result.get("success"): + return Response({"message": "Success"}, status=status.HTTP_200_OK) + else: + return Response( + {"error": result.get("error")}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apps/api/plane/api/views/intake.py b/apps/api/plane/api/views/intake.py new file mode 100644 index 00000000..7a00fa43 --- /dev/null +++ b/apps/api/plane/api/views/intake.py @@ -0,0 +1,483 @@ +# Python imports +import json + +# Django imports +from django.core.serializers.json import DjangoJSONEncoder +from django.utils import timezone +from django.db.models import Q, Value, UUIDField +from django.db.models.functions import Coalesce +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import OpenApiResponse, OpenApiRequest + +# Module imports +from plane.api.serializers import ( + IntakeIssueSerializer, + IssueSerializer, + IntakeIssueCreateSerializer, + IntakeIssueUpdateSerializer, +) +from plane.app.permissions import ProjectLitePermission +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State +from plane.utils.host import base_host +from .base import BaseAPIView +from plane.db.models.intake import SourceType +from plane.utils.openapi import ( + intake_docs, + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + ISSUE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + create_paginated_response, + # Request Examples + INTAKE_ISSUE_CREATE_EXAMPLE, + INTAKE_ISSUE_UPDATE_EXAMPLE, + # Response Examples + INTAKE_ISSUE_EXAMPLE, + INVALID_REQUEST_RESPONSE, + DELETED_RESPONSE, +) + + +class IntakeIssueListCreateAPIEndpoint(BaseAPIView): + """Intake Work Item List and Create Endpoint""" + + serializer_class = IntakeIssueSerializer + + model = Intake + permission_classes = [ProjectLitePermission] + use_read_replica = True + + def get_queryset(self): + 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")) + + if intake is None or not project.intake_view: + return IntakeIssue.objects.none() + + return ( + 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"), + intake_id=intake.id, + ) + .select_related("issue", "workspace", "project") + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + @intake_docs( + operation_id="get_intake_work_items_list", + summary="List intake work items", + description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.", # noqa: E501 + parameters=[ + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IntakeIssueSerializer, + "PaginatedIntakeIssueResponse", + "Paginated list of intake work items", + "Paginated Intake Work Items", + ), + }, + ) + def get(self, request, slug, project_id): + """List intake work items + + Retrieve all work items in the project's intake queue. + Returns paginated results when listing all intake work items. + """ + issue_queryset = self.get_queryset() + return self.paginate( + request=request, + queryset=(issue_queryset), + on_results=lambda intake_issues: IntakeIssueSerializer( + intake_issues, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + @intake_docs( + operation_id="create_intake_work_item", + summary="Create intake work item", + description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.", # noqa: E501 + parameters=[ + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IntakeIssueCreateSerializer, + examples=[INTAKE_ISSUE_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Intake work item created", + response=IntakeIssueSerializer, + examples=[INTAKE_ISSUE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + }, + ) + def post(self, request, slug, project_id): + """Create intake work item + + Submit a new work item to the project's intake queue for review and triage. + Automatically creates the work item with default triage state and tracks activity. + """ + if not request.data.get("issue", {}).get("name", False): + return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST) + + intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() + + project = Project.objects.get(workspace__slug=slug, pk=project_id) + + # Intake view + if intake is None and not project.intake_view: + return Response( + {"error": "Intake is not enabled for this project enable it through the project's api"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check for valid priority + if request.data.get("issue", {}).get("priority", "none") not in [ + "low", + "medium", + "high", + "urgent", + "none", + ]: + return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST) + + # create an issue + issue = Issue.objects.create( + name=request.data.get("issue", {}).get("name"), + description=request.data.get("issue", {}).get("description", {}), + description_html=request.data.get("issue", {}).get("description_html", "

    "), + priority=request.data.get("issue", {}).get("priority", "none"), + project_id=project_id, + ) + + # create an intake issue + intake_issue = IntakeIssue.objects.create( + intake_id=intake.id, + project_id=project_id, + issue=issue, + source=SourceType.IN_APP, + ) + # Create an Issue Activity + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + intake=str(intake_issue.id), + ) + + serializer = IntakeIssueSerializer(intake_issue) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class IntakeIssueDetailAPIEndpoint(BaseAPIView): + """Intake Issue API Endpoint""" + + permission_classes = [ProjectLitePermission] + + serializer_class = IntakeIssueSerializer + model = IntakeIssue + use_read_replica = True + + filterset_fields = ["status"] + + def get_queryset(self): + 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")) + + if intake is None or not project.intake_view: + return IntakeIssue.objects.none() + + return ( + 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"), + intake_id=intake.id, + ) + .select_related("issue", "workspace", "project") + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + @intake_docs( + operation_id="retrieve_intake_work_item", + summary="Retrieve intake work item", + description="Retrieve details of a specific intake work item.", + parameters=[ + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + ISSUE_ID_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Intake work item", + response=IntakeIssueSerializer, + examples=[INTAKE_ISSUE_EXAMPLE], + ), + }, + ) + def get(self, request, slug, project_id, issue_id): + """Retrieve intake work item + + Retrieve details of a specific intake work item. + """ + 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(intake_issue_data, status=status.HTTP_200_OK) + + @intake_docs( + operation_id="update_intake_work_item", + summary="Update intake work item", + description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.", # noqa: E501 + parameters=[ + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + ISSUE_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IntakeIssueUpdateSerializer, + examples=[INTAKE_ISSUE_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Intake work item updated", + response=IntakeIssueSerializer, + examples=[INTAKE_ISSUE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + }, + ) + def patch(self, request, slug, project_id, issue_id): + """Update intake work item + + Modify an existing intake work item's properties or status for triage processing. + Supports status changes like accept, reject, or mark as duplicate. + """ + intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() + + project = Project.objects.get(workspace__slug=slug, pk=project_id) + + # Intake view + if intake is None and not project.intake_view: + return Response( + {"error": "Intake is not enabled for this project enable it through the project's api"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the intake issue + intake_issue = IntakeIssue.objects.get( + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + intake_id=intake.id, + ) + + # Get the project member + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) + + # Only project members admins and created_by users can access this endpoint + if project_member.role <= 5 and str(intake_issue.created_by_id) != str(request.user.id): + return Response( + {"error": "You cannot edit intake work items"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get issue data + issue_data = request.data.pop("issue", False) + + if bool(issue_data): + issue = Issue.objects.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())), + ), + ).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 = { + "name": issue_data.get("name", issue.name), + "description_html": issue_data.get("description_html", issue.description_html), + "description": issue_data.get("description", issue.description), + } + + issue_serializer = IssueSerializer(issue, data=issue_data, partial=True) + + if issue_serializer.is_valid(): + current_instance = issue + # Log all the updates + requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder) + if issue is not None: + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + intake=(intake_issue.id), + ) + issue_serializer.save() + else: + return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Only project admins and members can edit intake issue attributes + if project_member.role > 15: + serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True) + current_instance = json.dumps(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=issue_id, workspace__slug=slug, project_id=project_id) + state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first() + if state is not None: + issue.state = state + issue.save() + + # Update the issue state if it is accepted + if serializer.data["status"] in [1]: + issue = Issue.objects.get(pk=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).first() + if state is not None: + issue.state = state + issue.save() + + # create a activity for status change + issue_activity.delay( + 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), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=False, + origin=base_host(request=request, is_app=True), + intake=str(intake_issue.id), + ) + serializer = IntakeIssueSerializer(intake_issue) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK) + + @intake_docs( + operation_id="delete_intake_work_item", + summary="Delete intake work item", + description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.", # noqa: E501 + parameters=[ + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + ISSUE_ID_PARAMETER, + ], + responses={ + 204: DELETED_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, issue_id): + """Delete intake work item + + Permanently remove an intake work item from the triage queue. + Also deletes the underlying work item if it hasn't been accepted yet. + """ + intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() + + project = Project.objects.get(workspace__slug=slug, pk=project_id) + + # Intake view + if intake is None and not project.intake_view: + return Response( + {"error": "Intake is not enabled for this project enable it through the project's api"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the intake issue + intake_issue = IntakeIssue.objects.get( + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + intake_id=intake.id, + ) + + # Check the issue status + 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).first() + 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 work item"}, + status=status.HTTP_403_FORBIDDEN, + ) + issue.delete() + + intake_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py new file mode 100644 index 00000000..d3686cee --- /dev/null +++ b/apps/api/plane/api/views/issue.py @@ -0,0 +1,2214 @@ +# Python imports +import json +import uuid +import re + +# Django imports +from django.core.serializers.json import DjangoJSONEncoder +from django.http import HttpResponseRedirect +from django.db import IntegrityError +from django.db.models import ( + Case, + CharField, + Exists, + F, + Func, + Max, + OuterRef, + Q, + Value, + When, + Subquery, +) +from django.utils import timezone +from django.conf import settings + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# drf-spectacular imports +from drf_spectacular.utils import ( + extend_schema, + OpenApiResponse, + OpenApiExample, + OpenApiRequest, +) + +# Module imports +from plane.api.serializers import ( + IssueAttachmentSerializer, + IssueActivitySerializer, + IssueCommentSerializer, + IssueLinkSerializer, + IssueSerializer, + LabelSerializer, + IssueAttachmentUploadSerializer, + IssueSearchSerializer, + IssueCommentCreateSerializer, + IssueLinkCreateSerializer, + IssueLinkUpdateSerializer, + LabelCreateUpdateSerializer, +) +from plane.app.permissions import ( + ProjectEntityPermission, + ProjectLitePermission, + ProjectMemberPermission, +) +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import ( + Issue, + IssueActivity, + FileAsset, + IssueComment, + IssueLink, + Label, + Project, + ProjectMember, + CycleIssue, + Workspace, +) +from plane.settings.storage import S3Storage +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata +from .base import BaseAPIView +from plane.utils.host import base_host +from plane.bgtasks.webhook_task import model_activity +from plane.app.permissions import ROLE +from plane.utils.openapi import ( + work_item_docs, + label_docs, + issue_link_docs, + issue_comment_docs, + issue_activity_docs, + issue_attachment_docs, + WORKSPACE_SLUG_PARAMETER, + PROJECT_IDENTIFIER_PARAMETER, + ISSUE_IDENTIFIER_PARAMETER, + PROJECT_ID_PARAMETER, + ISSUE_ID_PARAMETER, + LABEL_ID_PARAMETER, + COMMENT_ID_PARAMETER, + LINK_ID_PARAMETER, + ATTACHMENT_ID_PARAMETER, + ACTIVITY_ID_PARAMETER, + PROJECT_ID_QUERY_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + EXTERNAL_ID_PARAMETER, + EXTERNAL_SOURCE_PARAMETER, + ORDER_BY_PARAMETER, + SEARCH_PARAMETER_REQUIRED, + LIMIT_PARAMETER, + WORKSPACE_SEARCH_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + create_paginated_response, + # Request Examples + ISSUE_CREATE_EXAMPLE, + ISSUE_UPDATE_EXAMPLE, + ISSUE_UPSERT_EXAMPLE, + LABEL_CREATE_EXAMPLE, + LABEL_UPDATE_EXAMPLE, + ISSUE_LINK_CREATE_EXAMPLE, + ISSUE_LINK_UPDATE_EXAMPLE, + ISSUE_COMMENT_CREATE_EXAMPLE, + ISSUE_COMMENT_UPDATE_EXAMPLE, + ISSUE_ATTACHMENT_UPLOAD_EXAMPLE, + ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE, + # Response Examples + ISSUE_EXAMPLE, + LABEL_EXAMPLE, + ISSUE_LINK_EXAMPLE, + ISSUE_COMMENT_EXAMPLE, + ISSUE_ATTACHMENT_EXAMPLE, + ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE, + ISSUE_SEARCH_EXAMPLE, + WORK_ITEM_NOT_FOUND_RESPONSE, + ISSUE_NOT_FOUND_RESPONSE, + PROJECT_NOT_FOUND_RESPONSE, + EXTERNAL_ID_EXISTS_RESPONSE, + DELETED_RESPONSE, + ADMIN_ONLY_RESPONSE, + LABEL_NOT_FOUND_RESPONSE, + LABEL_NAME_EXISTS_RESPONSE, + INVALID_REQUEST_RESPONSE, + LINK_NOT_FOUND_RESPONSE, + COMMENT_NOT_FOUND_RESPONSE, + ATTACHMENT_NOT_FOUND_RESPONSE, + BAD_SEARCH_REQUEST_RESPONSE, + UNAUTHORIZED_RESPONSE, + FORBIDDEN_RESPONSE, + WORKSPACE_NOT_FOUND_RESPONSE, +) +from plane.bgtasks.work_item_link_task import crawl_work_item_link_title + + +def user_has_issue_permission(user_id, project_id, issue=None, allowed_roles=None, allow_creator=True): + if allow_creator and issue is not None and user_id == issue.created_by_id: + return True + + qs = ProjectMember.objects.filter( + project_id=project_id, + member_id=user_id, + is_active=True, + ) + if allowed_roles is not None: + qs = qs.filter(role__in=allowed_roles) + + return qs.exists() + + +class WorkspaceIssueAPIEndpoint(BaseAPIView): + """ + This viewset provides `retrieveByIssueId` on workspace level + + """ + + model = Issue + webhook_event = "issue" + permission_classes = [ProjectEntityPermission] + serializer_class = IssueSerializer + use_read_replica = True + + @property + def project_identifier(self): + return self.kwargs.get("project_identifier", None) + + def get_queryset(self): + return ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project__identifier=self.kwargs.get("project_identifier")) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + ).distinct() + + @extend_schema( + operation_id="get_workspace_work_item", + summary="Retrieve work item by identifiers", + description="Retrieve a specific work item using workspace slug, project identifier, and issue identifier.", + tags=["Work Items"], + parameters=[ + WORKSPACE_SLUG_PARAMETER, + PROJECT_IDENTIFIER_PARAMETER, + ISSUE_IDENTIFIER_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Work item details", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 404: WORK_ITEM_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_identifier=None, issue_identifier=None): + """Retrieve work item by identifiers + + Retrieve a specific work item using workspace slug, project identifier, and issue identifier. + This endpoint provides workspace-level access to work items. + """ + if issue_identifier and project_identifier: + issue = Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ).get( + workspace__slug=slug, + project__identifier=project_identifier, + sequence_id=issue_identifier, + ) + return Response( + IssueSerializer(issue, fields=self.fields, expand=self.expand).data, + status=status.HTTP_200_OK, + ) + + +class IssueListCreateAPIEndpoint(BaseAPIView): + """ + This viewset provides `list` and `create` on issue level + """ + + model = Issue + webhook_event = "issue" + permission_classes = [ProjectEntityPermission] + serializer_class = IssueSerializer + use_read_replica = True + + def get_queryset(self): + return ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + ).distinct() + + @work_item_docs( + operation_id="list_work_items", + summary="List work items", + description="Retrieve a paginated list of all work items in a project. Supports filtering, ordering, and field selection through query parameters.", # noqa: E501 + parameters=[ + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + EXTERNAL_ID_PARAMETER, + EXTERNAL_SOURCE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueSerializer, + "PaginatedWorkItemResponse", + "Paginated list of work items", + "Paginated Work Items", + ), + 400: INVALID_REQUEST_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id): + """List work items + + Retrieve a paginated list of all work items in a project. + Supports filtering, ordering, and field selection through query parameters. + """ + + external_id = request.GET.get("external_id") + external_source = request.GET.get("external_source") + + if external_id and external_source: + issue = Issue.objects.get( + external_id=external_id, + external_source=external_source, + workspace__slug=slug, + project_id=project_id, + ) + return Response( + IssueSerializer(issue, fields=self.fields, expand=self.expand).data, + status=status.HTTP_200_OK, + ) + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + 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=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() + .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") + ) + ) + + total_issue_queryset = Issue.issue_objects.filter(project_id=project_id, workspace__slug=slug) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = priority_order if order_by_param == "priority" else priority_order[::-1] + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = state_order if order_by_param in ["state__name", "state__group"] else state_order[::-1] + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[When(state__group=state_group, then=Value(i)) for i, state_group in enumerate(state_order)], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max(order_by_param[1::] if order_by_param.startswith("-") else order_by_param) + ).order_by("-max_values" if order_by_param.startswith("-") else "max_values") + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + return self.paginate( + request=request, + queryset=(issue_queryset), + total_count_queryset=total_issue_queryset, + on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data, + ) + + @work_item_docs( + operation_id="create_work_item", + summary="Create work item", + description="Create a new work item in the specified project with the provided details.", + request=OpenApiRequest( + request=IssueSerializer, + examples=[ISSUE_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Work Item created successfully", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def post(self, request, slug, project_id): + """Create work item + + Create a new work item in the specified project with the provided details. + Supports external ID tracking for integration purposes. + """ + project = Project.objects.get(pk=project_id) + + serializer = IssueSerializer( + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, + ) + + if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + issue = Issue.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Issue with the same external id and external source already exists", + "id": str(issue.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer.save() + # Refetch the issue + issue = Issue.objects.filter(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.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), + 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()), + ) + + # Send the model activity + model_activity.delay( + model_name="issue", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class IssueDetailAPIEndpoint(BaseAPIView): + """Issue Detail Endpoint""" + + model = Issue + webhook_event = "issue" + permission_classes = [ProjectEntityPermission] + serializer_class = IssueSerializer + use_read_replica = True + + def get_queryset(self): + return ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + ).distinct() + + @work_item_docs( + operation_id="retrieve_work_item", + summary="Retrieve work item", + description="Retrieve details of a specific work item.", + parameters=[ + PROJECT_ID_PARAMETER, + EXTERNAL_ID_PARAMETER, + EXTERNAL_SOURCE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="List of issues or issue details", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: WORK_ITEM_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, pk): + """Retrieve work item + + Retrieve details of a specific work item. + Supports filtering, ordering, and field selection through query parameters. + """ + + issue = Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ).get(workspace__slug=slug, project_id=project_id, pk=pk) + return Response( + IssueSerializer(issue, fields=self.fields, expand=self.expand).data, + status=status.HTTP_200_OK, + ) + + @work_item_docs( + operation_id="put_work_item", + summary="Update or create work item", + description="Update an existing work item identified by external ID and source, or create a new one if it doesn't exist. Requires external_id and external_source parameters for identification.", # noqa: E501 + request=OpenApiRequest( + request=IssueSerializer, + examples=[ISSUE_UPSERT_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Work Item updated successfully", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 201: OpenApiResponse( + description="Work Item created successfully", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: WORK_ITEM_NOT_FOUND_RESPONSE, + }, + ) + def put(self, request, slug, project_id): + """Update or create work item + + Update an existing work item identified by external ID and source, or create a new one if it doesn't exist. + Requires external_id and external_source parameters for identification. + """ + # Get the entities required for putting the issue, external_id and + # external_source are must to identify the issue here + project = Project.objects.get(pk=project_id) + external_id = request.data.get("external_id") + external_source = request.data.get("external_source") + + # If the external_id and source are present, we need to find the exact + # issue that needs to be updated with the provided external_id and + # external_source + if external_id and external_source: + try: + issue = Issue.objects.get( + project_id=project_id, + workspace__slug=slug, + external_id=external_id, + external_source=external_source, + ) + + # Get the current instance of the issue in order to track + # changes and dispatch the issue activity + current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder) + + # 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) + serializer = IssueSerializer( + issue, + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + }, + partial=True, + ) + if serializer.is_valid(): + # If the serializer is valid, save the issue and dispatch + # the update issue activity worker event. + serializer.save() + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response( + # If the serializer is not valid, respond with 400 bad + # request + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + except Issue.DoesNotExist: + # If the issue does not exist, a new record needs to be created + # for the requested data. + # Serialize the data with the context of the project and + # workspace + serializer = IssueSerializer( + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, + ) + + # If the serializer is valid, save the issue and dispatch the + # issue activity worker event as created + if serializer.is_valid(): + serializer.save() + # Refetch the issue + issue = Issue.objects.filter( + workspace__slug=slug, + project_id=project_id, + pk=serializer.data["id"], + ).first() + + # 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_by_id = request.data.get("created_by", request.user.id) + issue.save(update_fields=["created_at", "created_by"]) + + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(serializer.data.get("id", None)), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response( + {"error": "external_id and external_source are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @work_item_docs( + operation_id="update_work_item", + summary="Partially update work item", + description="Partially update an existing work item with the provided fields. Supports external ID validation to prevent conflicts.", # noqa: E501 + parameters=[ + PROJECT_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueSerializer, + examples=[ISSUE_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Work Item patched successfully", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: WORK_ITEM_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def patch(self, request, slug, project_id, pk): + """Update work item + + Partially update an existing work item with the provided fields. + Supports external ID validation to prevent conflicts. + """ + 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) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + serializer = IssueSerializer( + issue, + data=request.data, + context={"project_id": project_id, "workspace_id": project.workspace_id}, + partial=True, + ) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and (issue.external_id != str(request.data.get("external_id"))) + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", issue.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Issue with the same external id and external source already exists", + "id": str(issue.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer.save() + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @work_item_docs( + operation_id="delete_work_item", + summary="Delete work item", + description="Permanently delete an existing work item from the project. Only admins or the item creator can perform this action.", # noqa: E501 + parameters=[ + PROJECT_ID_PARAMETER, + ], + responses={ + 204: DELETED_RESPONSE, + 403: ADMIN_ONLY_RESPONSE, + 404: WORK_ITEM_NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, pk): + """Delete work item + + Permanently delete an existing work item from the project. + Only admins or the item creator can perform this action. + """ + 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 work item"}, + status=status.HTTP_403_FORBIDDEN, + ) + current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder) + issue.delete() + issue_activity.delay( + type="issue.activity.deleted", + requested_data=json.dumps({"issue_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class LabelListCreateAPIEndpoint(BaseAPIView): + """Label List and Create Endpoint""" + + serializer_class = LabelSerializer + model = Label + permission_classes = [ProjectMemberPermission] + use_read_replica = True + + def get_queryset(self): + return ( + Label.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("project") + .select_related("workspace") + .select_related("parent") + .distinct() + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + @label_docs( + operation_id="create_label", + description="Create a new label in the specified project with name, color, and description.", + request=OpenApiRequest( + request=LabelCreateUpdateSerializer, + examples=[LABEL_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Label created successfully", + response=LabelSerializer, + examples=[LABEL_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 409: LABEL_NAME_EXISTS_RESPONSE, + }, + ) + def post(self, request, slug, project_id): + """Create label + + Create a new label in the specified project with name, color, and description. + Supports external ID tracking for integration purposes. + """ + try: + serializer = LabelCreateUpdateSerializer(data=request.data) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Label.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + label = Label.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer.save(project_id=project_id) + label = Label.objects.get(pk=serializer.instance.id) + serializer = LabelSerializer(label) + 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, + project_id=project_id, + name=request.data.get("name"), + ).first() + return Response( + { + "error": "Label with the same name already exists in the project", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + @label_docs( + operation_id="list_labels", + description="Retrieve all labels in a project. Supports filtering by name and color.", + parameters=[ + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + LabelSerializer, + "PaginatedLabelResponse", + "Paginated list of labels", + "Paginated Labels", + ), + 400: INVALID_REQUEST_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id): + """List labels + + Retrieve all labels in the project. + """ + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda labels: LabelSerializer(labels, many=True, fields=self.fields, expand=self.expand).data, + ) + + +class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint): + """Label Detail Endpoint""" + + serializer_class = LabelSerializer + model = Label + permission_classes = [ProjectMemberPermission] + use_read_replica = True + + @label_docs( + operation_id="get_labels", + description="Retrieve details of a specific label.", + parameters=[ + LABEL_ID_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Labels", + response=LabelSerializer, + examples=[LABEL_EXAMPLE], + ), + 404: LABEL_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, pk): + """Retrieve label + + Retrieve details of a specific label. + """ + label = self.get_queryset().get(pk=pk) + serializer = LabelSerializer(label) + return Response(serializer.data, status=status.HTTP_200_OK) + + @label_docs( + operation_id="update_label", + description="Partially update an existing label's properties like name, color, or description.", + parameters=[ + LABEL_ID_PARAMETER, + ], + request=OpenApiRequest( + request=LabelCreateUpdateSerializer, + examples=[LABEL_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Label updated successfully", + response=LabelSerializer, + examples=[LABEL_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: LABEL_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def patch(self, request, slug, project_id, pk): + """Update label + + Partially update an existing label's properties like name, color, or description. + Validates external ID uniqueness if provided. + """ + label = self.get_queryset().get(pk=pk) + serializer = LabelCreateUpdateSerializer(label, data=request.data, partial=True) + if serializer.is_valid(): + if ( + str(request.data.get("external_id")) + and (label.external_id != str(request.data.get("external_id"))) + and Label.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", label.external_source), + external_id=request.data.get("external_id"), + ) + .exclude(id=pk) + .exists() + ): + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() + label = Label.objects.get(pk=serializer.instance.id) + serializer = LabelSerializer(label) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @label_docs( + operation_id="delete_label", + description="Permanently remove a label from the project. This action cannot be undone.", + parameters=[ + LABEL_ID_PARAMETER, + ], + responses={ + 204: DELETED_RESPONSE, + 404: LABEL_NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, pk): + """Delete label + + Permanently remove a label from the project. + This action cannot be undone. + """ + label = self.get_queryset().get(pk=pk) + label.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueLinkListCreateAPIEndpoint(BaseAPIView): + """Work Item Link List and Create Endpoint""" + + serializer_class = IssueLinkSerializer + model = IssueLink + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @issue_link_docs( + operation_id="list_work_item_links", + description="Retrieve all links associated with a work item. Supports filtering by URL, title, and metadata.", + parameters=[ + ISSUE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueLinkSerializer, + "PaginatedIssueLinkResponse", + "Paginated list of work item links", + "Paginated Work Item Links", + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id): + """List work item links + + Retrieve all links associated with a work item. + """ + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda issue_links: IssueLinkSerializer( + issue_links, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + @issue_link_docs( + operation_id="create_work_item_link", + description="Add a new external link to a work item with URL, title, and metadata.", + parameters=[ + ISSUE_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueLinkCreateSerializer, + examples=[ISSUE_LINK_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Work item link created successfully", + response=IssueLinkSerializer, + examples=[ISSUE_LINK_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def post(self, request, slug, project_id, issue_id): + """Create issue link + + Add a new external link to a work item with URL, title, and metadata. + Automatically tracks link creation activity. + """ + serializer = IssueLinkCreateSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id, issue_id=issue_id) + crawl_work_item_link_title.delay(serializer.instance.id, serializer.instance.url) + link = IssueLink.objects.get(pk=serializer.instance.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), + issue_id=str(self.kwargs.get("issue_id")), + project_id=str(self.kwargs.get("project_id")), + actor_id=str(link.created_by_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + serializer = IssueLinkSerializer(link) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class IssueLinkDetailAPIEndpoint(BaseAPIView): + """Issue Link Detail Endpoint""" + + permission_classes = [ProjectEntityPermission] + + model = IssueLink + serializer_class = IssueLinkSerializer + use_read_replica = True + + def get_queryset(self): + return ( + IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @issue_link_docs( + operation_id="retrieve_work_item_link", + description="Retrieve details of a specific work item link.", + parameters=[ + ISSUE_ID_PARAMETER, + LINK_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueLinkSerializer, + "PaginatedIssueLinkDetailResponse", + "Work item link details or paginated list", + "Work Item Link Details", + ), + 404: OpenApiResponse(description="Issue not found"), + }, + ) + def get(self, request, slug, project_id, issue_id, pk): + """Retrieve work item link + + Retrieve details of a specific work item link. + """ + if pk is None: + issue_links = self.get_queryset() + serializer = IssueLinkSerializer(issue_links, fields=self.fields, expand=self.expand) + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda issue_links: IssueLinkSerializer( + issue_links, many=True, fields=self.fields, expand=self.expand + ).data, + ) + issue_link = self.get_queryset().get(pk=pk) + serializer = IssueLinkSerializer(issue_link, fields=self.fields, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + @issue_link_docs( + operation_id="update_issue_link", + description="Modify the URL, title, or metadata of an existing issue link.", + parameters=[ + ISSUE_ID_PARAMETER, + LINK_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueLinkUpdateSerializer, + examples=[ISSUE_LINK_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Issue link updated successfully", + response=IssueLinkSerializer, + examples=[ISSUE_LINK_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: LINK_NOT_FOUND_RESPONSE, + }, + ) + def patch(self, request, slug, project_id, issue_id, pk): + """Update issue link + + Modify the URL, title, or metadata of an existing issue link. + Tracks all changes in issue activity logs. + """ + issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) + current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder) + serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url")) + issue_activity.delay( + type="link.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + serializer = IssueLinkSerializer(issue_link) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @issue_link_docs( + operation_id="delete_work_item_link", + description="Permanently remove an external link from a work item.", + parameters=[ + ISSUE_ID_PARAMETER, + LINK_ID_PARAMETER, + ], + responses={ + 204: OpenApiResponse(description="Work item link deleted successfully"), + 404: OpenApiResponse(description="Work item link not found"), + }, + ) + def delete(self, request, slug, project_id, issue_id, pk): + """Delete work item link + + Permanently remove an external link from a work item. + Records deletion activity for audit purposes. + """ + issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder) + issue_activity.delay( + type="link.activity.deleted", + requested_data=json.dumps({"link_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + issue_link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueCommentListCreateAPIEndpoint(BaseAPIView): + """Issue Comment List and Create Endpoint""" + + serializer_class = IssueCommentSerializer + model = IssueComment + webhook_event = "issue_comment" + permission_classes = [ProjectLitePermission] + use_read_replica = True + + def get_queryset(self): + return ( + IssueComment.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("workspace", "project", "issue", "actor") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member_id=self.request.user.id, + is_active=True, + ) + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @issue_comment_docs( + operation_id="list_work_item_comments", + description="Retrieve all comments for a work item.", + parameters=[ + ISSUE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueCommentSerializer, + "PaginatedIssueCommentResponse", + "Paginated list of work item comments", + "Paginated Work Item Comments", + ), + 404: OpenApiResponse(description="Issue not found"), + }, + ) + def get(self, request, slug, project_id, issue_id): + """List work item comments + + Retrieve all comments for a work item. + """ + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda issue_comments: IssueCommentSerializer( + issue_comments, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + @issue_comment_docs( + operation_id="create_work_item_comment", + description="Add a new comment to a work item with HTML content.", + parameters=[ + ISSUE_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueCommentCreateSerializer, + examples=[ISSUE_COMMENT_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Work item comment created successfully", + response=IssueCommentSerializer, + examples=[ISSUE_COMMENT_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def post(self, request, slug, project_id, issue_id): + """Create work item comment + + Add a new comment to a work item with HTML content. + Supports external ID tracking for integration purposes. + """ + # Validation check if the issue already exists + if ( + request.data.get("external_id") + and request.data.get("external_source") + and IssueComment.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + issue_comment = IssueComment.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Work item comment with the same external id and external source already exists", + "id": str(issue_comment.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer = IssueCommentCreateSerializer(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.instance.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_by_id = request.data.get("created_by", request.user.id) + issue_comment.actor_id = request.data.get("created_by", request.user.id) + issue_comment.save(update_fields=["created_at", "created_by"]) + + issue_activity.delay( + type="comment.activity.created", + 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")), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + + # Send the model activity + model_activity.delay( + model_name="issue_comment", + model_id=str(serializer.instance.id), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + + serializer = IssueCommentSerializer(issue_comment) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class IssueCommentDetailAPIEndpoint(BaseAPIView): + """Work Item Comment Detail Endpoint""" + + serializer_class = IssueCommentSerializer + model = IssueComment + webhook_event = "issue_comment" + permission_classes = [ProjectLitePermission] + use_read_replica = True + + def get_queryset(self): + return ( + IssueComment.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("workspace", "project", "issue", "actor") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member_id=self.request.user.id, + is_active=True, + ) + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @issue_comment_docs( + operation_id="retrieve_work_item_comment", + description="Retrieve details of a specific comment.", + parameters=[ + ISSUE_ID_PARAMETER, + COMMENT_ID_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Work item comments", + response=IssueCommentSerializer, + examples=[ISSUE_COMMENT_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id, pk): + """Retrieve issue comment + + Retrieve details of a specific comment. + """ + issue_comment = self.get_queryset().get(pk=pk) + serializer = IssueCommentSerializer(issue_comment, fields=self.fields, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + @issue_comment_docs( + operation_id="update_work_item_comment", + description="Modify the content of an existing comment on a work item.", + parameters=[ + ISSUE_ID_PARAMETER, + COMMENT_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueCommentCreateSerializer, + examples=[ISSUE_COMMENT_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Work item comment updated successfully", + response=IssueCommentSerializer, + examples=[ISSUE_COMMENT_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: COMMENT_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def patch(self, request, slug, project_id, issue_id, pk): + """Update work item comment + + Modify the content of an existing comment on a work item. + Validates external ID uniqueness if provided. + """ + issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder) + + # 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 IssueComment.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", issue_comment.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Work item comment with the same external id and external source already exists", + "id": str(issue_comment.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer = IssueCommentCreateSerializer(issue_comment, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="comment.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + # Send the model activity + model_activity.delay( + model_name="issue_comment", + model_id=str(pk), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + + issue_comment = IssueComment.objects.get(pk=serializer.instance.id) + serializer = IssueCommentSerializer(issue_comment) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @issue_comment_docs( + operation_id="delete_work_item_comment", + description="Permanently remove a comment from a work item. Records deletion activity for audit purposes.", + parameters=[ + ISSUE_ID_PARAMETER, + COMMENT_ID_PARAMETER, + ], + responses={ + 204: OpenApiResponse(description="Work item comment deleted successfully"), + 404: COMMENT_NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, issue_id, pk): + """Delete issue comment + + Permanently remove a comment from a work item. + Records deletion activity for audit purposes. + """ + issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder) + issue_comment.delete() + issue_activity.delay( + type="comment.activity.deleted", + requested_data=json.dumps({"comment_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueActivityListAPIEndpoint(BaseAPIView): + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + @issue_activity_docs( + operation_id="list_work_item_activities", + description="Retrieve all activities for a work item. Supports filtering by activity type and date range.", + parameters=[ + ISSUE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueActivitySerializer, + "PaginatedIssueActivityResponse", + "Paginated list of issue activities", + "Paginated Issue Activities", + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id): + """List issue activities + + Retrieve chronological activity logs for an issue. + Excludes comment, vote, reaction, and draft activities. + """ + issue_activities = ( + IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id) + .filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("actor", "workspace", "issue", "project") + ).order_by(request.GET.get("order_by", "created_at")) + + return self.paginate( + request=request, + queryset=(issue_activities), + on_results=lambda issue_activity: IssueActivitySerializer( + issue_activity, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + +class IssueActivityDetailAPIEndpoint(BaseAPIView): + """Issue Activity Detail Endpoint""" + + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + @issue_activity_docs( + operation_id="retrieve_work_item_activity", + description="Retrieve details of a specific activity.", + parameters=[ + ISSUE_ID_PARAMETER, + ACTIVITY_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueActivitySerializer, + "PaginatedIssueActivityDetailResponse", + "Paginated list of work item activities", + "Work Item Activity Details", + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id, pk): + """Retrieve issue activity + + Retrieve details of a specific activity. + Excludes comment, vote, reaction, and draft activities. + """ + issue_activities = ( + IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id) + .filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("actor", "workspace", "issue", "project") + ).order_by(request.GET.get("order_by", "created_at")) + + return self.paginate( + request=request, + queryset=(issue_activities), + on_results=lambda issue_activity: IssueActivitySerializer( + issue_activity, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + +class IssueAttachmentListCreateAPIEndpoint(BaseAPIView): + """Issue Attachment List and Create Endpoint""" + + serializer_class = IssueAttachmentSerializer + model = FileAsset + use_read_replica = True + + @issue_attachment_docs( + operation_id="create_work_item_attachment", + description="Generate presigned URL for uploading file attachments to a work item.", + parameters=[ + ISSUE_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueAttachmentUploadSerializer, + examples=[ISSUE_ATTACHMENT_UPLOAD_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Presigned download URL generated successfully", + examples=[ + OpenApiExample( + name="Work Item Attachment Response", + value={ + "upload_data": { + "url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url", + "fields": { + "key": "file.pdf", + "AWSAccessKeyId": "AKIAIOSFODNN7EXAMPLE", + "policy": "EXAMPLE", + "signature": "EXAMPLE", + "acl": "public-read", + "Content-Type": "application/pdf", + }, + }, + "asset_id": "550e8400-e29b-41d4-a716-446655440000", + "asset_url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url", + "attachment": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "file.pdf", + "type": "application/pdf", + "size": 1234567890, + "url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url", + }, + }, + ) + ], + ), + 400: OpenApiResponse( + description="Validation error", + examples=[ + OpenApiExample( + name="Missing required fields", + value={ + "error": "Name and size are required fields.", + "status": False, + }, + ), + OpenApiExample( + name="Invalid file type", + value={"error": "Invalid file type.", "status": False}, + ), + ], + ), + 404: OpenApiResponse( + description="Issue or Project or Workspace not found", + examples=[ + OpenApiExample( + name="Workspace not found", + value={"error": "Workspace not found"}, + ), + OpenApiExample(name="Project not found", value={"error": "Project not found"}), + OpenApiExample(name="Issue not found", value={"error": "Issue not found"}), + ], + ), + }, + ) + def post(self, request, slug, project_id, issue_id): + """Create work item attachment + + Generate presigned URL for uploading file attachments to a work item. + Validates file type and size before creating the attachment record. + """ + issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) + # if the user is creator or admin,member then allow the upload + if not user_has_issue_permission( + request.user.id, + project_id=project_id, + issue=issue, + allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value], + allow_creator=True, + ): + return Response( + {"error": "You are not allowed to upload this attachment"}, + status=status.HTTP_403_FORBIDDEN, + ) + + name = request.data.get("name") + type = request.data.get("type", False) + size = request.data.get("size") + external_id = request.data.get("external_id") + external_source = request.data.get("external_source") + + # Check if the request is valid + if not name or not size: + return Response( + {"error": "Invalid request.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + size_limit = min(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}" + + if ( + request.data.get("external_id") + and request.data.get("external_source") + and FileAsset.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ).exists() + ): + asset = FileAsset.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ).first() + return Response( + { + "error": "Issue with the same external id and external source already exists", + "id": str(asset.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + # 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, + external_id=external_id, + external_source=external_source, + ) + + # 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, + ) + + @issue_attachment_docs( + operation_id="list_work_item_attachments", + description="Retrieve all attachments for a work item.", + parameters=[ + ISSUE_ID_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Work item attachment", + response=IssueAttachmentSerializer, + examples=[ISSUE_ATTACHMENT_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ATTACHMENT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id): + """List issue attachments + + List all attachments for an issue. + """ + # 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) + + +class IssueAttachmentDetailAPIEndpoint(BaseAPIView): + """Issue Attachment Detail Endpoint""" + + serializer_class = IssueAttachmentSerializer + model = FileAsset + use_read_replica = True + + @issue_attachment_docs( + operation_id="delete_work_item_attachment", + description="Permanently remove an attachment from a work item. Records deletion activity for audit purposes.", + parameters=[ + ATTACHMENT_ID_PARAMETER, + ], + responses={ + 204: OpenApiResponse(description="Work item attachment deleted successfully"), + 404: ATTACHMENT_NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, issue_id, pk): + """Delete work item attachment + + Soft delete an attachment from a work item by marking it as deleted. + Records deletion activity and triggers metadata cleanup. + """ + issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) + # if the request user is creator or admin then delete the attachment + if not user_has_issue_permission( + request.user, + project_id=project_id, + issue=issue, + allowed_roles=[ROLE.ADMIN.value], + allow_creator=True, + ): + return Response( + {"error": "You are not allowed to delete this attachment"}, + status=status.HTTP_403_FORBIDDEN, + ) + + 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=base_host(request=request, is_app=True), + ) + + # 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) + + @issue_attachment_docs( + operation_id="retrieve_work_item_attachment", + description="Download attachment file. Returns a redirect to the presigned download URL.", + parameters=[ + ATTACHMENT_ID_PARAMETER, + ], + responses={ + 302: OpenApiResponse( + description="Redirect to presigned download URL", + ), + 400: OpenApiResponse( + description="Asset not uploaded", + response={ + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message", + "example": "The asset is not uploaded.", + }, + "status": { + "type": "boolean", + "description": "Request status", + "example": False, + }, + }, + }, + examples=[ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE], + ), + 404: ATTACHMENT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id, pk): + """Retrieve work item attachment + + Retrieve details of a specific attachment. + """ + # if the user is part of the project then allow the download + if not user_has_issue_permission( + request.user, + project_id=project_id, + issue=None, + allowed_roles=None, + allow_creator=False, + ): + return Response( + {"error": "You are not allowed to download this attachment"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # 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) + + @issue_attachment_docs( + operation_id="upload_work_item_attachment", + description="Mark an attachment as uploaded after successful file transfer to storage.", + parameters=[ + ATTACHMENT_ID_PARAMETER, + ], + request=OpenApiRequest( + request={ + "application/json": { + "type": "object", + "properties": { + "is_uploaded": { + "type": "boolean", + "description": "Mark attachment as uploaded", + } + }, + } + }, + examples=[ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE], + ), + responses={ + 204: OpenApiResponse(description="Work item attachment uploaded successfully"), + 400: INVALID_REQUEST_RESPONSE, + 404: ATTACHMENT_NOT_FOUND_RESPONSE, + }, + ) + def patch(self, request, slug, project_id, issue_id, pk): + """Confirm attachment upload + + Mark an attachment as uploaded after successful file transfer to storage. + Triggers activity logging and metadata extraction. + """ + + issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) + # if the user is creator or admin then allow the upload + if not user_has_issue_permission( + request.user, + project_id=project_id, + issue=issue, + allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value], + allow_creator=True, + ): + return Response( + {"error": "You are not allowed to upload this attachment"}, + status=status.HTTP_403_FORBIDDEN, + ) + + 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=base_host(request=request, is_app=True), + ) + + # 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) + + +class IssueSearchEndpoint(BaseAPIView): + """Endpoint to search across multiple fields in the issues""" + + use_read_replica = True + + @extend_schema( + operation_id="search_work_items", + tags=["Work Items"], + description="Perform semantic search across issue names, sequence IDs, and project identifiers.", + parameters=[ + WORKSPACE_SLUG_PARAMETER, + SEARCH_PARAMETER_REQUIRED, + LIMIT_PARAMETER, + WORKSPACE_SEARCH_PARAMETER, + PROJECT_ID_QUERY_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Work item search results", + response=IssueSearchSerializer, + examples=[ISSUE_SEARCH_EXAMPLE], + ), + 400: BAD_SEARCH_REQUEST_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: WORKSPACE_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug): + """Search work items + + Perform semantic search across work item names, sequence IDs, and project identifiers. + Supports workspace-wide or project-specific search with configurable result limits. + """ + query = request.query_params.get("search", False) + limit = request.query_params.get("limit", 10) + workspace_search = request.query_params.get("workspace_search", "false") + project_id = request.query_params.get("project_id", False) + + if not query: + return Response({"issues": []}, status=status.HTTP_200_OK) + + # Build search query + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + # Filter issues + issues = Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + workspace__slug=slug, + ) + + # Apply project filter if not searching across workspace + if workspace_search == "false" and project_id: + issues = issues.filter(project_id=project_id) + + # Get results + issue_results = issues.distinct().values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "workspace__slug", + )[: int(limit)] + + return Response({"issues": issue_results}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py new file mode 100644 index 00000000..f761d5c9 --- /dev/null +++ b/apps/api/plane/api/views/member.py @@ -0,0 +1,133 @@ +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from drf_spectacular.utils import ( + extend_schema, + OpenApiResponse, +) + +# Module imports +from .base import BaseAPIView +from plane.api.serializers import UserLiteSerializer +from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember +from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission +from plane.utils.openapi import ( + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + UNAUTHORIZED_RESPONSE, + FORBIDDEN_RESPONSE, + WORKSPACE_NOT_FOUND_RESPONSE, + PROJECT_NOT_FOUND_RESPONSE, + WORKSPACE_MEMBER_EXAMPLE, + PROJECT_MEMBER_EXAMPLE, +) + + +class WorkspaceMemberAPIEndpoint(BaseAPIView): + permission_classes = [WorkSpaceAdminPermission] + use_read_replica = True + + @extend_schema( + operation_id="get_workspace_members", + summary="List workspace members", + description="Retrieve all users who are members of the specified workspace.", + tags=["Members"], + parameters=[WORKSPACE_SLUG_PARAMETER], + responses={ + 200: OpenApiResponse( + description="List of workspace members with their roles", + response={ + "type": "array", + "items": { + "allOf": [ + {"$ref": "#/components/schemas/UserLite"}, + { + "type": "object", + "properties": { + "role": { + "type": "integer", + "description": "Member role in the workspace", + } + }, + }, + ] + }, + }, + examples=[WORKSPACE_MEMBER_EXAMPLE], + ), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: WORKSPACE_NOT_FOUND_RESPONSE, + }, + ) + # Get all the users that are present inside the workspace + def get(self, request, slug): + """List workspace members + + Retrieve all users who are members of the specified workspace. + Returns user profiles with their respective workspace roles and permissions. + """ + # Check if the workspace exists + if not Workspace.objects.filter(slug=slug).exists(): + return Response( + {"error": "Provided workspace does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace_members = WorkspaceMember.objects.filter(workspace__slug=slug).select_related("member") + + # Get all the users with their roles + users_with_roles = [] + for workspace_member in workspace_members: + user_data = UserLiteSerializer(workspace_member.member).data + user_data["role"] = workspace_member.role + users_with_roles.append(user_data) + + return Response(users_with_roles, status=status.HTTP_200_OK) + + +# API endpoint to get and insert users inside the workspace +class ProjectMemberAPIEndpoint(BaseAPIView): + permission_classes = [ProjectMemberPermission] + use_read_replica = True + + @extend_schema( + operation_id="get_project_members", + summary="List project members", + description="Retrieve all users who are members of the specified project.", + tags=["Members"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={ + 200: OpenApiResponse( + description="List of project members with their roles", + response=UserLiteSerializer, + examples=[PROJECT_MEMBER_EXAMPLE], + ), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + # Get all the users that are present inside the workspace + def get(self, request, slug, project_id): + """List project members + + Retrieve all users who are members of the specified project. + Returns user profiles with their project-specific roles and access levels. + """ + # Check if the workspace exists + if not Workspace.objects.filter(slug=slug).exists(): + return Response( + {"error": "Provided workspace does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the workspace members that are present inside the workspace + project_members = ProjectMember.objects.filter(project_id=project_id, workspace__slug=slug).values_list( + "member_id", flat=True + ) + + # Get all the users that are present inside the workspace + users = UserLiteSerializer(User.objects.filter(id__in=project_members), many=True).data + + return Response(users, status=status.HTTP_200_OK) diff --git a/apps/api/plane/api/views/module.py b/apps/api/plane/api/views/module.py new file mode 100644 index 00000000..d79b9408 --- /dev/null +++ b/apps/api/plane/api/views/module.py @@ -0,0 +1,1073 @@ +# Python imports +import json + +# Django imports +from django.core import serializers +from django.db.models import Count, F, Func, OuterRef, Prefetch, Q +from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import OpenApiResponse, OpenApiRequest + +# Module imports +from plane.api.serializers import ( + IssueSerializer, + ModuleIssueSerializer, + ModuleSerializer, + ModuleIssueRequestSerializer, + ModuleCreateSerializer, + ModuleUpdateSerializer, +) +from plane.app.permissions import ProjectEntityPermission +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import ( + Issue, + FileAsset, + IssueLink, + Module, + ModuleIssue, + ModuleLink, + Project, + ProjectMember, + UserFavorite, +) + +from .base import BaseAPIView +from plane.bgtasks.webhook_task import model_activity +from plane.utils.host import base_host +from plane.utils.openapi import ( + module_docs, + module_issue_docs, + MODULE_ID_PARAMETER, + MODULE_PK_PARAMETER, + ISSUE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + create_paginated_response, + # Request Examples + MODULE_CREATE_EXAMPLE, + MODULE_UPDATE_EXAMPLE, + MODULE_ISSUE_REQUEST_EXAMPLE, + # Response Examples + MODULE_EXAMPLE, + MODULE_ISSUE_EXAMPLE, + INVALID_REQUEST_RESPONSE, + PROJECT_NOT_FOUND_RESPONSE, + EXTERNAL_ID_EXISTS_RESPONSE, + MODULE_NOT_FOUND_RESPONSE, + DELETED_RESPONSE, + ADMIN_ONLY_RESPONSE, + REQUIRED_FIELDS_RESPONSE, + MODULE_ISSUE_NOT_FOUND_RESPONSE, + ARCHIVED_RESPONSE, + CANNOT_ARCHIVE_RESPONSE, + UNARCHIVED_RESPONSE, +) + + +class ModuleListCreateAPIEndpoint(BaseAPIView): + """Module List and Create Endpoint""" + + serializer_class = ModuleSerializer + model = Module + webhook_event = "module" + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + Module.objects.filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related("module", "created_by"), + ) + ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + completed_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + cancelled_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + started_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + unstarted_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + backlog_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + @module_docs( + operation_id="create_module", + summary="Create module", + description="Create a new project module with specified name, description, and timeline.", + request=OpenApiRequest( + request=ModuleCreateSerializer, + examples=[MODULE_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Module created", + response=ModuleSerializer, + examples=[MODULE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def post(self, request, slug, project_id): + """Create module + + Create a new project module with specified name, description, and timeline. + Automatically assigns the creator as module lead and tracks activity. + """ + project = Project.objects.get(pk=project_id, workspace__slug=slug) + serializer = ModuleCreateSerializer( + data=request.data, + context={"project_id": project_id, "workspace_id": project.workspace_id}, + ) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Module.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + module = Module.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).first() + return Response( + { + "error": "Module with the same external id and external source already exists", + "id": str(module.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(serializer.instance.id), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + module = Module.objects.get(pk=serializer.instance.id) + serializer = ModuleSerializer(module) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @module_docs( + operation_id="list_modules", + summary="List modules", + description="Retrieve all modules in a project.", + parameters=[ + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + ModuleSerializer, + "PaginatedModuleResponse", + "Paginated list of modules", + "Paginated Modules", + ), + 404: OpenApiResponse(description="Module not found"), + }, + ) + def get(self, request, slug, project_id): + """List or retrieve modules + + Retrieve all modules in a project or get details of a specific module. + Returns paginated results with module statistics and member information. + """ + 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 + ).data, + ) + + +class ModuleDetailAPIEndpoint(BaseAPIView): + """Module Detail Endpoint""" + + model = Module + permission_classes = [ProjectEntityPermission] + serializer_class = ModuleSerializer + webhook_event = "module" + use_read_replica = True + + def get_queryset(self): + return ( + Module.objects.filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related("module", "created_by"), + ) + ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + completed_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + cancelled_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + started_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + unstarted_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + backlog_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + @module_docs( + operation_id="update_module", + summary="Update module", + description="Modify an existing module's properties like name, description, status, or timeline.", + parameters=[ + MODULE_PK_PARAMETER, + ], + request=OpenApiRequest( + request=ModuleUpdateSerializer, + examples=[MODULE_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Module updated successfully", + response=ModuleSerializer, + examples=[MODULE_EXAMPLE], + ), + 400: OpenApiResponse( + description="Invalid request data", + response=ModuleSerializer, + examples=[MODULE_UPDATE_EXAMPLE], + ), + 404: OpenApiResponse(description="Module not found"), + 409: OpenApiResponse(description="Module with same external ID already exists"), + }, + ) + def patch(self, request, slug, project_id, pk): + """Update module + + Modify an existing module's properties like name, description, status, or timeline. + Tracks all changes in model activity logs for audit purposes. + """ + module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) + + current_instance = json.dumps(ModuleSerializer(module).data, cls=DjangoJSONEncoder) + + if module.archived_at: + return Response( + {"error": "Archived module cannot be edited"}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and (module.external_id != request.data.get("external_id")) + and Module.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", module.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Module with the same external id and external source already exists", + "id": str(module.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() + + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(serializer.instance.id), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @module_docs( + operation_id="retrieve_module", + summary="Retrieve module", + description="Retrieve details of a specific module.", + parameters=[ + MODULE_PK_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Module", + response=ModuleSerializer, + examples=[MODULE_EXAMPLE], + ), + 404: OpenApiResponse(description="Module not found"), + }, + ) + def get(self, request, slug, project_id, pk): + """Retrieve module + + Retrieve details of a specific module. + """ + queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) + data = ModuleSerializer(queryset, fields=self.fields, expand=self.expand).data + return Response(data, status=status.HTTP_200_OK) + + @module_docs( + operation_id="delete_module", + summary="Delete module", + description="Permanently remove a module and all its associated issue relationships.", + parameters=[ + MODULE_PK_PARAMETER, + ], + responses={ + 204: DELETED_RESPONSE, + 403: ADMIN_ONLY_RESPONSE, + 404: MODULE_NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, pk): + """Delete module + + Permanently remove a module and all its associated issue relationships. + Only admins or the module creator can perform this action. + """ + 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, + member=request.user, + role=20, + project_id=project_id, + is_active=True, + ).exists() + ): + return Response( + {"error": "Only admin or creator can delete the module"}, + status=status.HTTP_403_FORBIDDEN, + ) + + module_issues = list(ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)) + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps( + { + "module_id": str(pk), + "module_name": str(module.name), + "issues": [str(issue_id) for issue_id in module_issues], + } + ), + actor_id=str(request.user.id), + issue_id=None, + project_id=str(project_id), + current_instance=json.dumps({"module_name": str(module.name)}), + epoch=int(timezone.now().timestamp()), + origin=base_host(request=request, is_app=True), + ) + module.delete() + # Delete the module issues + 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).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ModuleIssueListCreateAPIEndpoint(BaseAPIView): + """Module Work Item List and Create Endpoint""" + + serializer_class = ModuleIssueSerializer + model = ModuleIssue + webhook_event = "module_issue" + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + ModuleIssue.objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(module_id=self.kwargs.get("module_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("project") + .select_related("workspace") + .select_related("module") + .select_related("issue", "issue__state", "issue__project") + .prefetch_related("issue__assignees", "issue__labels") + .prefetch_related("module__members") + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @module_issue_docs( + operation_id="list_module_work_items", + summary="List module work items", + description="Retrieve all work items assigned to a module with detailed information.", + parameters=[ + MODULE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + request={}, + responses={ + 200: create_paginated_response( + IssueSerializer, + "PaginatedModuleIssueResponse", + "Paginated list of module work items", + "Paginated Module Work Items", + ), + 404: OpenApiResponse(description="Module not found"), + }, + ) + def get(self, request, slug, project_id, module_id): + """List module work items + + Retrieve all work items assigned to a module with detailed information. + Returns paginated results including assignees, labels, and attachments. + """ + order_by = request.GET.get("order_by", "created_at") + issues = ( + 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")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate(bridge_id=F("issue_module__id")) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(order_by) + .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") + ) + ) + return self.paginate( + request=request, + queryset=(issues), + on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data, + ) + + @module_issue_docs( + operation_id="add_module_work_items", + summary="Add Work Items to Module", + description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.", # noqa: E501 + parameters=[ + MODULE_ID_PARAMETER, + ], + request=OpenApiRequest( + request=ModuleIssueRequestSerializer, + examples=[MODULE_ISSUE_REQUEST_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Module issues added", + response=ModuleIssueSerializer, + examples=[MODULE_ISSUE_EXAMPLE], + ), + 400: REQUIRED_FIELDS_RESPONSE, + 404: MODULE_NOT_FOUND_RESPONSE, + }, + ) + def post(self, request, slug, project_id, module_id): + """Add module work items + + Assign multiple work items to a module or move them from another module. + Automatically handles bulk creation and updates with activity tracking. + """ + issues = request.data.get("issues", []) + if not len(issues): + return Response({"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST) + module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=module_id) + + issues = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issues).values_list( + "id", flat=True + ) + + module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues)) + + update_module_issue_activity = [] + records_to_update = [] + record_to_create = [] + + for issue in issues: + module_issue = [module_issue for module_issue in module_issues if str(module_issue.issue_id) in issues] + + if len(module_issue): + if module_issue[0].module_id != module_id: + update_module_issue_activity.append( + { + "old_module_id": str(module_issue[0].module_id), + "new_module_id": str(module_id), + "issue_id": str(module_issue[0].issue_id), + } + ) + module_issue[0].module_id = module_id + records_to_update.append(module_issue[0]) + else: + record_to_create.append( + ModuleIssue( + module=module, + issue_id=issue, + project_id=project_id, + workspace=module.workspace, + created_by=request.user, + updated_by=request.user, + ) + ) + + ModuleIssue.objects.bulk_create(record_to_create, batch_size=10, ignore_conflicts=True) + + ModuleIssue.objects.bulk_update(records_to_update, ["module"], batch_size=10) + + # Capture Issue Activity + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"modules_list": str(issues)}), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "updated_module_issues": update_module_issue_activity, + "created_module_issues": serializers.serialize("json", record_to_create), + } + ), + epoch=int(timezone.now().timestamp()), + origin=base_host(request=request, is_app=True), + ) + + return Response( + ModuleIssueSerializer(self.get_queryset(), many=True).data, + status=status.HTTP_200_OK, + ) + + +class ModuleIssueDetailAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to module work items. + + """ + + serializer_class = ModuleIssueSerializer + model = ModuleIssue + webhook_event = "module_issue" + bulk = True + use_read_replica = True + + permission_classes = [ProjectEntityPermission] + + def get_queryset(self): + return ( + ModuleIssue.objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(module_id=self.kwargs.get("module_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("project") + .select_related("workspace") + .select_related("module") + .select_related("issue", "issue__state", "issue__project") + .prefetch_related("issue__assignees", "issue__labels") + .prefetch_related("module__members") + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @module_issue_docs( + operation_id="retrieve_module_work_item", + summary="Retrieve module work item", + description="Retrieve details of a specific module work item.", + parameters=[ + MODULE_ID_PARAMETER, + ISSUE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueSerializer, + "PaginatedModuleIssueDetailResponse", + "Paginated list of module work item details", + "Module Work Item Details", + ), + 404: OpenApiResponse(description="Module not found"), + }, + ) + def get(self, request, slug, project_id, module_id, issue_id): + """List module work items + + Retrieve all work items assigned to a module with detailed information. + Returns paginated results including assignees, labels, and attachments. + """ + order_by = request.GET.get("order_by", "created_at") + issues = ( + Issue.issue_objects.filter( + issue_module__module_id=module_id, + issue_module__deleted_at__isnull=True, + pk=issue_id, + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate(bridge_id=F("issue_module__id")) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(order_by) + .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") + ) + ) + return self.paginate( + request=request, + queryset=(issues), + on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data, + ) + + @module_issue_docs( + operation_id="delete_module_work_item", + summary="Delete module work item", + description="Remove a work item from a module while keeping the work item in the project.", + parameters=[ + MODULE_ID_PARAMETER, + ISSUE_ID_PARAMETER, + ], + responses={ + 204: DELETED_RESPONSE, + 404: MODULE_ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, module_id, issue_id): + """Remove module work item + + Remove a work item from a module while keeping the work item in the project. + Records the removal activity for tracking purposes. + """ + module_issue = ModuleIssue.objects.get( + workspace__slug=slug, + project_id=project_id, + module_id=module_id, + issue_id=issue_id, + ) + module_issue.delete() + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps({"module_id": str(module_id), "issues": [str(module_issue.issue_id)]}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + Module.objects.filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(archived_at__isnull=False) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related("module", "created_by"), + ) + ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + completed_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + cancelled_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + started_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + unstarted_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + backlog_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + @module_docs( + operation_id="list_archived_modules", + summary="List archived modules", + description="Retrieve all modules that have been archived in the project.", + parameters=[ + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + request={}, + responses={ + 200: create_paginated_response( + ModuleSerializer, + "PaginatedArchivedModuleResponse", + "Paginated list of archived modules", + "Paginated Archived Modules", + ), + 404: OpenApiResponse(description="Project not found"), + }, + ) + def get(self, request, slug, project_id): + """List archived modules + + Retrieve all modules that have been archived in the project. + Returns paginated results with module statistics. + """ + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda modules: ModuleSerializer( + modules, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + @module_docs( + operation_id="archive_module", + summary="Archive module", + description="Move a module to archived status for historical tracking.", + parameters=[ + MODULE_PK_PARAMETER, + ], + request={}, + responses={ + 204: ARCHIVED_RESPONSE, + 400: CANNOT_ARCHIVE_RESPONSE, + 404: MODULE_NOT_FOUND_RESPONSE, + }, + ) + def post(self, request, slug, project_id, pk): + """Archive module + + Move a completed module to archived status for historical tracking. + Only modules with completed status can be archived. + """ + 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"}, + status=status.HTTP_400_BAD_REQUEST, + ) + module.archived_at = timezone.now() + module.save() + UserFavorite.objects.filter( + entity_type="module", + entity_identifier=pk, + project_id=project_id, + workspace__slug=slug, + ).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @module_docs( + operation_id="unarchive_module", + summary="Unarchive module", + description="Restore an archived module to active status, making it available for regular use.", + parameters=[ + MODULE_PK_PARAMETER, + ], + responses={ + 204: UNARCHIVED_RESPONSE, + 404: MODULE_NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, pk): + """Unarchive module + + Restore an archived module to active status, making it available for regular use. + The module will reappear in active module lists and become fully functional. + """ + 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/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py new file mode 100644 index 00000000..131932bf --- /dev/null +++ b/apps/api/plane/api/views/project.py @@ -0,0 +1,586 @@ +# Python imports +import json + +# Django imports +from django.db import IntegrityError +from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery +from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.serializers import ValidationError +from drf_spectacular.utils import OpenApiResponse, OpenApiRequest + + +# Module imports +from plane.db.models import ( + Cycle, + Intake, + IssueUserProperty, + Module, + Project, + DeployBoard, + ProjectMember, + State, + Workspace, + UserFavorite, +) +from plane.bgtasks.webhook_task import model_activity, webhook_activity +from .base import BaseAPIView +from plane.utils.host import base_host +from plane.api.serializers import ( + ProjectSerializer, + ProjectCreateSerializer, + ProjectUpdateSerializer, +) +from plane.app.permissions import ProjectBasePermission +from plane.utils.openapi import ( + project_docs, + PROJECT_ID_PARAMETER, + PROJECT_PK_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + create_paginated_response, + # Request Examples + PROJECT_CREATE_EXAMPLE, + PROJECT_UPDATE_EXAMPLE, + # Response Examples + PROJECT_EXAMPLE, + PROJECT_NOT_FOUND_RESPONSE, + WORKSPACE_NOT_FOUND_RESPONSE, + PROJECT_NAME_TAKEN_RESPONSE, + DELETED_RESPONSE, + ARCHIVED_RESPONSE, + UNARCHIVED_RESPONSE, +) + + +class ProjectListCreateAPIEndpoint(BaseAPIView): + """Project List and Create Endpoint""" + + serializer_class = ProjectSerializer + model = Project + webhook_event = "project" + permission_classes = [ProjectBasePermission] + use_read_replica = True + + def get_queryset(self): + return ( + Project.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter( + Q( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + | Q(network=2) + ) + .select_related("project_lead") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ) + ) + ) + .annotate( + total_members=ProjectMember.objects.filter( + project_id=OuterRef("id"), member__is_bot=False, is_active=True + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_cycles=Cycle.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_modules=Module.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + member_role=ProjectMember.objects.filter( + project_id=OuterRef("pk"), + member_id=self.request.user.id, + is_active=True, + ).values("role") + ) + .annotate( + is_deployed=Exists( + DeployBoard.objects.filter( + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ) + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @project_docs( + operation_id="list_projects", + summary="List or retrieve projects", + description="Retrieve all projects in a workspace or get details of a specific project.", + parameters=[ + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + ProjectSerializer, + "PaginatedProjectResponse", + "Paginated list of projects", + "Paginated Projects", + ), + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug): + """List projects + + Retrieve all projects in a workspace or get details of a specific project. + Returns projects ordered by user's custom sort order with member information. + """ + sort_order_query = ProjectMember.objects.filter( + member=request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ).values("sort_order") + projects = ( + self.get_queryset() + .annotate(sort_order=Subquery(sort_order_query)) + .prefetch_related( + Prefetch( + "project_projectmember", + queryset=ProjectMember.objects.filter(workspace__slug=slug, is_active=True).select_related( + "member" + ), + ) + ) + .order_by(request.GET.get("order_by", "sort_order")) + ) + return self.paginate( + request=request, + queryset=(projects), + on_results=lambda projects: ProjectSerializer( + projects, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + @project_docs( + operation_id="create_project", + summary="Create project", + description="Create a new project in the workspace with default states and member assignments.", + request=OpenApiRequest( + request=ProjectCreateSerializer, + examples=[PROJECT_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Project created successfully", + response=ProjectSerializer, + examples=[PROJECT_EXAMPLE], + ), + 404: WORKSPACE_NOT_FOUND_RESPONSE, + 409: PROJECT_NAME_TAKEN_RESPONSE, + }, + ) + def post(self, request, slug): + """Create project + + Create a new project in the workspace with default states and member assignments. + Automatically adds the creator as admin and sets up default workflow states. + """ + try: + workspace = Workspace.objects.get(slug=slug) + serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id}) + if serializer.is_valid(): + serializer.save() + + # Add the user as Administrator to the project + _ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20) + # Also create the issue property for the user + _ = IssueUserProperty.objects.create(project_id=serializer.instance.id, user=request.user) + + if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str( + request.user.id + ): + ProjectMember.objects.create( + project_id=serializer.instance.id, + member_id=serializer.instance.project_lead, + role=20, + ) + # Also create the issue property for the user + IssueUserProperty.objects.create( + project_id=serializer.instance.id, + user_id=serializer.instance.project_lead, + ) + + # Default states + states = [ + { + "name": "Backlog", + "color": "#60646C", + "sequence": 15000, + "group": "backlog", + "default": True, + }, + { + "name": "Todo", + "color": "#60646C", + "sequence": 25000, + "group": "unstarted", + }, + { + "name": "In Progress", + "color": "#F59E0B", + "sequence": 35000, + "group": "started", + }, + { + "name": "Done", + "color": "#46A758", + "sequence": 45000, + "group": "completed", + }, + { + "name": "Cancelled", + "color": "#9AA4BC", + "sequence": 55000, + "group": "cancelled", + }, + ] + + State.objects.bulk_create( + [ + State( + name=state["name"], + color=state["color"], + project=serializer.instance, + sequence=state["sequence"], + workspace=serializer.instance.workspace, + group=state["group"], + default=state.get("default", False), + created_by=request.user, + ) + for state in states + ] + ) + + project = self.get_queryset().filter(pk=serializer.instance.id).first() + + # Model activity + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + + serializer = ProjectSerializer(project) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The project name is already taken"}, + status=status.HTTP_409_CONFLICT, + ) + except Workspace.DoesNotExist: + return Response({"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND) + except ValidationError: + return Response( + {"identifier": "The project identifier is already taken"}, + status=status.HTTP_409_CONFLICT, + ) + + +class ProjectDetailAPIEndpoint(BaseAPIView): + """Project Endpoints to update, retrieve and delete endpoint""" + + serializer_class = ProjectSerializer + model = Project + webhook_event = "project" + + permission_classes = [ProjectBasePermission] + use_read_replica = True + + def get_queryset(self): + return ( + Project.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter( + Q( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + | Q(network=2) + ) + .select_related("workspace", "workspace__owner", "default_assignee", "project_lead") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ) + ) + ) + .annotate( + total_members=ProjectMember.objects.filter( + project_id=OuterRef("id"), member__is_bot=False, is_active=True + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_cycles=Cycle.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_modules=Module.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + member_role=ProjectMember.objects.filter( + project_id=OuterRef("pk"), + member_id=self.request.user.id, + is_active=True, + ).values("role") + ) + .annotate( + is_deployed=Exists( + DeployBoard.objects.filter( + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ) + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @project_docs( + operation_id="retrieve_project", + summary="Retrieve project", + description="Retrieve details of a specific project.", + parameters=[ + PROJECT_PK_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Project details", + response=ProjectSerializer, + examples=[PROJECT_EXAMPLE], + ), + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, pk): + """Retrieve project + + Retrieve details of a specific project. + """ + project = self.get_queryset().get(workspace__slug=slug, pk=pk) + serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + @project_docs( + operation_id="update_project", + summary="Update project", + description="Partially update an existing project's properties like name, description, or settings.", + parameters=[ + PROJECT_PK_PARAMETER, + ], + request=OpenApiRequest( + request=ProjectUpdateSerializer, + examples=[PROJECT_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Project updated successfully", + response=ProjectSerializer, + examples=[PROJECT_EXAMPLE], + ), + 404: PROJECT_NOT_FOUND_RESPONSE, + 409: PROJECT_NAME_TAKEN_RESPONSE, + }, + ) + def patch(self, request, slug, pk): + """Update project + + Partially update an existing project's properties like name, description, or settings. + Tracks changes in model activity logs for audit purposes. + """ + try: + workspace = Workspace.objects.get(slug=slug) + project = Project.objects.get(pk=pk) + current_instance = json.dumps(ProjectSerializer(project).data, cls=DjangoJSONEncoder) + + intake_view = request.data.get("intake_view", project.intake_view) + + if project.archived_at: + return Response( + {"error": "Archived project cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = ProjectUpdateSerializer( + project, + data={**request.data, "intake_view": intake_view}, + context={"workspace_id": workspace.id}, + partial=True, + ) + + if serializer.is_valid(): + serializer.save() + if serializer.data["intake_view"]: + intake = Intake.objects.filter(project=project, is_default=True).first() + if not intake: + Intake.objects.create( + name=f"{project.name} Intake", + project=project, + is_default=True, + ) + + project = self.get_queryset().filter(pk=serializer.instance.id).first() + + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + + serializer = ProjectSerializer(project) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The project name is already taken"}, + status=status.HTTP_409_CONFLICT, + ) + except (Project.DoesNotExist, Workspace.DoesNotExist): + return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND) + except ValidationError: + return Response( + {"identifier": "The project identifier is already taken"}, + status=status.HTTP_409_CONFLICT, + ) + + @project_docs( + operation_id="delete_project", + summary="Delete project", + description="Permanently remove a project and all its associated data from the workspace.", + parameters=[ + PROJECT_PK_PARAMETER, + ], + responses={ + 204: DELETED_RESPONSE, + }, + ) + def delete(self, request, slug, pk): + """Delete project + + Permanently remove a project and all its associated data from the workspace. + Only admins can delete projects and the action cannot be undone. + """ + 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).delete() + project.delete() + webhook_activity.delay( + event="project", + verb="deleted", + field=None, + old_value=None, + new_value=None, + actor_id=request.user.id, + slug=slug, + current_site=base_host(request=request, is_app=True), + event_id=project.id, + old_identifier=None, + new_identifier=None, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView): + """Project Archive and Unarchive Endpoint""" + + permission_classes = [ProjectBasePermission] + + @project_docs( + operation_id="archive_project", + summary="Archive project", + description="Move a project to archived status, hiding it from active project lists.", + parameters=[ + PROJECT_ID_PARAMETER, + ], + request={}, + responses={ + 204: ARCHIVED_RESPONSE, + }, + ) + def post(self, request, slug, project_id): + """Archive project + + Move a project to archived status, hiding it from active project lists. + Archived projects remain accessible but are excluded from regular workflows. + """ + 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() + return Response(status=status.HTTP_204_NO_CONTENT) + + @project_docs( + operation_id="unarchive_project", + summary="Unarchive project", + description="Restore an archived project to active status, making it available in regular workflows.", + parameters=[ + PROJECT_ID_PARAMETER, + ], + request={}, + responses={ + 204: UNARCHIVED_RESPONSE, + }, + ) + def delete(self, request, slug, project_id): + """Unarchive project + + Restore an archived project to active status, making it available in regular workflows. + The project will reappear in active project lists and become fully functional. + """ + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.archived_at = None + project.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/api/views/state.py b/apps/api/plane/api/views/state.py new file mode 100644 index 00000000..bd91de39 --- /dev/null +++ b/apps/api/plane/api/views/state.py @@ -0,0 +1,296 @@ +# Django imports +from django.db import IntegrityError + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import OpenApiResponse, OpenApiRequest + +# Module imports +from plane.api.serializers import StateSerializer +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import Issue, State +from .base import BaseAPIView +from plane.utils.openapi import ( + state_docs, + STATE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + create_paginated_response, + # Request Examples + STATE_CREATE_EXAMPLE, + STATE_UPDATE_EXAMPLE, + # Response Examples + STATE_EXAMPLE, + INVALID_REQUEST_RESPONSE, + STATE_NAME_EXISTS_RESPONSE, + DELETED_RESPONSE, + STATE_CANNOT_DELETE_RESPONSE, + EXTERNAL_ID_EXISTS_RESPONSE, +) + + +class StateListCreateAPIEndpoint(BaseAPIView): + """State List and Create Endpoint""" + + serializer_class = StateSerializer + model = State + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + State.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(is_triage=False) + .filter(project__archived_at__isnull=True) + .select_related("project") + .select_related("workspace") + .distinct() + ) + + @state_docs( + operation_id="create_state", + summary="Create state", + description="Create a new workflow state for a project with specified name, color, and group.", + request=OpenApiRequest( + request=StateSerializer, + examples=[STATE_CREATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="State created", + response=StateSerializer, + examples=[STATE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 409: STATE_NAME_EXISTS_RESPONSE, + }, + ) + def post(self, request, slug, project_id): + """Create state + + Create a new workflow state for a project with specified name, color, and group. + Supports external ID tracking for integration purposes. + """ + try: + serializer = StateSerializer(data=request.data, context={"project_id": project_id}) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and State.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + state = State.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "State with the same external id and external source already exists", + "id": str(state.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + 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) + except IntegrityError: + state = State.objects.filter( + workspace__slug=slug, + project_id=project_id, + name=request.data.get("name"), + ).first() + return Response( + { + "error": "State with the same name already exists in the project", + "id": str(state.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + @state_docs( + operation_id="list_states", + summary="List states", + description="Retrieve all workflow states for a project.", + parameters=[ + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + StateSerializer, + "PaginatedStateResponse", + "Paginated list of states", + "Paginated States", + ), + }, + ) + def get(self, request, slug, project_id): + """List states + + Retrieve all workflow states for a project. + Returns paginated results when listing all states. + """ + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda states: StateSerializer(states, many=True, fields=self.fields, expand=self.expand).data, + ) + + +class StateDetailAPIEndpoint(BaseAPIView): + """State Detail Endpoint""" + + serializer_class = StateSerializer + model = State + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + State.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(is_triage=False) + .filter(project__archived_at__isnull=True) + .select_related("project") + .select_related("workspace") + .distinct() + ) + + @state_docs( + operation_id="retrieve_state", + summary="Retrieve state", + description="Retrieve details of a specific state.", + parameters=[ + STATE_ID_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="State retrieved", + response=StateSerializer, + examples=[STATE_EXAMPLE], + ), + }, + ) + def get(self, request, slug, project_id, state_id): + """Retrieve state + + Retrieve details of a specific state. + Returns paginated results when listing all states. + """ + serializer = StateSerializer( + self.get_queryset().get(pk=state_id), + fields=self.fields, + expand=self.expand, + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + @state_docs( + operation_id="delete_state", + summary="Delete state", + description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.", # noqa: E501 + parameters=[ + STATE_ID_PARAMETER, + ], + responses={ + 204: DELETED_RESPONSE, + 400: STATE_CANNOT_DELETE_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, state_id): + """Delete state + + Permanently remove a workflow state from a project. + Default states and states with existing work items cannot be deleted. + """ + state = State.objects.get(is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug) + + if state.default: + return Response( + {"error": "Default state cannot be deleted"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check for any issues in the state + issue_exist = Issue.issue_objects.filter(state=state_id).exists() + + if issue_exist: + return Response( + {"error": "The state is not empty, only empty states can be deleted"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + state.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @state_docs( + operation_id="update_state", + summary="Update state", + description="Partially update an existing workflow state's properties like name, color, or group.", + parameters=[ + STATE_ID_PARAMETER, + ], + request=OpenApiRequest( + request=StateSerializer, + examples=[STATE_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="State updated", + response=StateSerializer, + examples=[STATE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def patch(self, request, slug, project_id, state_id): + """Update state + + Partially update an existing workflow state's properties like name, color, or group. + Validates external ID uniqueness if provided. + """ + state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id) + serializer = StateSerializer(state, data=request.data, partial=True) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and (state.external_id != str(request.data.get("external_id"))) + and State.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", state.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "State with the same external id and external source already exists", + "id": str(state.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/api/views/user.py b/apps/api/plane/api/views/user.py new file mode 100644 index 00000000..b874cec1 --- /dev/null +++ b/apps/api/plane/api/views/user.py @@ -0,0 +1,37 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import OpenApiResponse + +# Module imports +from plane.api.serializers import UserLiteSerializer +from plane.api.views.base import BaseAPIView +from plane.db.models import User +from plane.utils.openapi.decorators import user_docs +from plane.utils.openapi import USER_EXAMPLE + + +class UserEndpoint(BaseAPIView): + serializer_class = UserLiteSerializer + model = User + + @user_docs( + operation_id="get_current_user", + summary="Get current user", + description="Retrieve the authenticated user's profile information including basic details.", + responses={ + 200: OpenApiResponse( + description="Current user profile", + response=UserLiteSerializer, + examples=[USER_EXAMPLE], + ), + }, + ) + def get(self, request): + """Get current user + + Retrieve the authenticated user's profile information including basic details. + Returns user data based on the current authentication context. + """ + serializer = UserLiteSerializer(request.user) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/__init__.py b/apps/api/plane/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/app/apps.py b/apps/api/plane/app/apps.py new file mode 100644 index 00000000..e3277fc4 --- /dev/null +++ b/apps/api/plane/app/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AppApiConfig(AppConfig): + name = "plane.app" diff --git a/apps/api/plane/app/middleware/__init__.py b/apps/api/plane/app/middleware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/app/middleware/api_authentication.py b/apps/api/plane/app/middleware/api_authentication.py new file mode 100644 index 00000000..ddabb413 --- /dev/null +++ b/apps/api/plane/app/middleware/api_authentication.py @@ -0,0 +1,47 @@ +# Django imports +from django.utils import timezone +from django.db.models import Q + +# Third party imports +from rest_framework import authentication +from rest_framework.exceptions import AuthenticationFailed + +# Module imports +from plane.db.models import APIToken + + +class APIKeyAuthentication(authentication.BaseAuthentication): + """ + Authentication with an API Key + """ + + www_authenticate_realm = "api" + media_type = "application/json" + auth_header_name = "X-Api-Key" + + def get_api_token(self, request): + return request.headers.get(self.auth_header_name) + + def validate_api_token(self, token): + try: + api_token = APIToken.objects.get( + Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), + token=token, + is_active=True, + ) + except APIToken.DoesNotExist: + raise AuthenticationFailed("Given API token is not valid") + + # save api token last used + api_token.last_used = timezone.now() + api_token.save(update_fields=["last_used"]) + return (api_token.user, api_token.token) + + def authenticate(self, request): + token = self.get_api_token(request=request) + if not token: + return None + + # Validate the API token + user, token = self.validate_api_token(token) + return user, token diff --git a/apps/api/plane/app/permissions/__init__.py b/apps/api/plane/app/permissions/__init__.py new file mode 100644 index 00000000..95ee038e --- /dev/null +++ b/apps/api/plane/app/permissions/__init__.py @@ -0,0 +1,16 @@ +from .workspace import ( + WorkSpaceBasePermission, + WorkspaceOwnerPermission, + WorkSpaceAdminPermission, + WorkspaceEntityPermission, + WorkspaceViewerPermission, + WorkspaceUserPermission, +) +from .project import ( + ProjectBasePermission, + ProjectEntityPermission, + ProjectMemberPermission, + ProjectLitePermission, +) +from .base import allow_permission, ROLE +from .page import ProjectPagePermission diff --git a/apps/api/plane/app/permissions/base.py b/apps/api/plane/app/permissions/base.py new file mode 100644 index 00000000..a2b1a18f --- /dev/null +++ b/apps/api/plane/app/permissions/base.py @@ -0,0 +1,73 @@ +from plane.db.models import WorkspaceMember, ProjectMember +from functools import wraps +from rest_framework.response import Response +from rest_framework import status + +from enum import Enum + + +class ROLE(Enum): + ADMIN = 20 + MEMBER = 15 + GUEST = 5 + + +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(id=kwargs["pk"], created_by=request.user).exists() + if obj: + return view_func(instance, request, *args, **kwargs) + + # 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] + + # Check role permissions + if level == "WORKSPACE": + if WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + role__in=allowed_role_values, + is_active=True, + ).exists(): + return view_func(instance, request, *args, **kwargs) + else: + is_user_has_allowed_role = ProjectMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + project_id=kwargs["project_id"], + role__in=allowed_role_values, + is_active=True, + ).exists() + + # Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role # noqa: E501 + if is_user_has_allowed_role: + return view_func(instance, request, *args, **kwargs) + elif ( + ProjectMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + project_id=kwargs["project_id"], + is_active=True, + ).exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ): + return view_func(instance, request, *args, **kwargs) + + # Return permission denied if no conditions are met + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) + + return _wrapped_view + + return decorator diff --git a/apps/api/plane/app/permissions/page.py b/apps/api/plane/app/permissions/page.py new file mode 100644 index 00000000..bea878f4 --- /dev/null +++ b/apps/api/plane/app/permissions/page.py @@ -0,0 +1,121 @@ +from plane.db.models import ProjectMember, Page +from plane.app.permissions import ROLE + + +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +# Permission Mappings for workspace members +ADMIN = ROLE.ADMIN.value +MEMBER = ROLE.MEMBER.value +GUEST = ROLE.GUEST.value + + +class ProjectPagePermission(BasePermission): + """ + Custom permission to control access to pages within a workspace + based on user roles, page visibility (public/private), and feature flags. + """ + + def has_permission(self, request, view): + """ + Check basic project-level permissions before checking object-level permissions. + """ + if request.user.is_anonymous: + return False + + user_id = request.user.id + slug = view.kwargs.get("slug") + page_id = view.kwargs.get("page_id") + project_id = view.kwargs.get("project_id") + + # Hook for extended validation + extended_access, role = self._check_access_and_get_role(request, slug, project_id) + if extended_access is False: + return False + + if page_id: + page = Page.objects.get(id=page_id, workspace__slug=slug) + + # Allow access if the user is the owner of the page + if page.owned_by_id == user_id: + return True + + # Handle private page access + if page.access == Page.PRIVATE_ACCESS: + return self._has_private_page_action_access(request, slug, page, project_id) + + # Handle public page access + return self._has_public_page_action_access(request, role) + + def _check_project_member_access(self, request, slug, project_id): + """ + Check if the user is a project member. + """ + return ( + ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + project_id=project_id, + ) + .values_list("role", flat=True) + .first() + ) + + def _check_access_and_get_role(self, request, slug, project_id): + """ + Hook for extended access checking + Returns: True (allow), False (deny), None (continue with normal flow) + """ + role = self._check_project_member_access(request, slug, project_id) + if not role: + return False, None + return True, role + + def _has_private_page_action_access(self, request, slug, page, project_id): + """ + Check access to private pages. Override for feature flag logic. + """ + # Base implementation: only owner can access private pages + return False + + def _check_project_action_access(self, request, role): + method = request.method + + # Only admins can create (POST) pages + if method == "POST": + if role in [ADMIN, MEMBER]: + return True + return False + + # Safe methods (GET, HEAD, OPTIONS) allowed for all active roles + if method in SAFE_METHODS: + if role in [ADMIN, MEMBER, GUEST]: + return True + return False + + # PUT/PATCH: Admins and members can update + if method in ["PUT", "PATCH"]: + if role in [ADMIN, MEMBER]: + return True + return False + + # DELETE: Only admins can delete + if method == "DELETE": + if role in [ADMIN]: + return True + return False + + # Deny by default + return False + + def _has_public_page_action_access(self, request, role): + """ + Check if the user has permission to access a public page + and can perform operations on the page. + """ + project_member_exists = self._check_project_action_access(request, role) + if not project_member_exists: + return False + return True diff --git a/apps/api/plane/app/permissions/project.py b/apps/api/plane/app/permissions/project.py new file mode 100644 index 00000000..e095ffed --- /dev/null +++ b/apps/api/plane/app/permissions/project.py @@ -0,0 +1,125 @@ +# Third Party imports +from rest_framework.permissions import SAFE_METHODS, BasePermission + +# Module import +from plane.db.models import ProjectMember, WorkspaceMember +from plane.db.models.project import ROLE + + +class ProjectBasePermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, is_active=True + ).exists() + + ## Only workspace owners or admins can create the projects + if request.method == "POST": + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + is_active=True, + ).exists() + + project_member_qs = ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project_id=view.project_id, + is_active=True, + ) + + ## Only project admins or workspace admin who is part of the project can access + + if project_member_qs.filter(role=ROLE.ADMIN.value).exists(): + return True + else: + return ( + project_member_qs.exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ) + + +class ProjectMemberPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, is_active=True + ).exists() + ## Only workspace owners or admins can create the projects + if request.method == "POST": + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + is_active=True, + ).exists() + + ## Only Project Admins can update project attributes + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + project_id=view.project_id, + is_active=True, + ).exists() + + +class ProjectEntityPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + # Handle requests based on project__identifier + if hasattr(view, "project_identifier") and view.project_identifier: + if request.method in SAFE_METHODS: + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project__identifier=view.project_identifier, + is_active=True, + ).exists() + + ## 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, + project_id=view.project_id, + is_active=True, + ).exists() + + ## Only project members or admins can create and edit the project attributes + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + project_id=view.project_id, + is_active=True, + ).exists() + + +class ProjectLitePermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project_id=view.project_id, + is_active=True, + ).exists() diff --git a/apps/api/plane/app/permissions/workspace.py b/apps/api/plane/app/permissions/workspace.py new file mode 100644 index 00000000..8dc791c0 --- /dev/null +++ b/apps/api/plane/app/permissions/workspace.py @@ -0,0 +1,106 @@ +# Third Party imports +from rest_framework.permissions import BasePermission, SAFE_METHODS + +# Module imports +from plane.db.models import WorkspaceMember + + +# Permission Mappings +Admin = 20 +Member = 15 +Guest = 5 + + +# TODO: Move the below logic to python match - python v3.10 +class WorkSpaceBasePermission(BasePermission): + def has_permission(self, request, view): + # allow anyone to create a workspace + if request.user.is_anonymous: + return False + + if request.method == "POST": + return True + + ## Safe Methods + if request.method in SAFE_METHODS: + return True + + # allow only admins and owners to update the workspace settings + if request.method in ["PUT", "PATCH"]: + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Admin, Member], + is_active=True, + ).exists() + + # allow only owner to delete the workspace + if request.method == "DELETE": + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role=Admin, + is_active=True, + ).exists() + + +class WorkspaceOwnerPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, role=Admin + ).exists() + + +class WorkSpaceAdminPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Admin, Member], + is_active=True, + ).exists() + + +class WorkspaceEntityPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, is_active=True + ).exists() + + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Admin, Member], + is_active=True, + ).exists() + + +class WorkspaceViewerPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, workspace__slug=view.workspace_slug, is_active=True + ).exists() + + +class WorkspaceUserPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, workspace__slug=view.workspace_slug, is_active=True + ).exists() diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py new file mode 100644 index 00000000..18be363c --- /dev/null +++ b/apps/api/plane/app/serializers/__init__.py @@ -0,0 +1,130 @@ +from .base import BaseSerializer +from .user import ( + UserSerializer, + UserLiteSerializer, + ChangePasswordSerializer, + ResetPasswordSerializer, + UserAdminLiteSerializer, + UserMeSerializer, + UserMeSettingsSerializer, + ProfileSerializer, + AccountSerializer, +) +from .workspace import ( + WorkSpaceSerializer, + WorkSpaceMemberSerializer, + WorkSpaceMemberInviteSerializer, + WorkspaceLiteSerializer, + WorkspaceThemeSerializer, + WorkspaceMemberAdminSerializer, + WorkspaceMemberMeSerializer, + WorkspaceUserPropertiesSerializer, + WorkspaceUserLinkSerializer, + WorkspaceRecentVisitSerializer, + WorkspaceHomePreferenceSerializer, + StickySerializer, +) +from .project import ( + ProjectSerializer, + ProjectListSerializer, + ProjectDetailSerializer, + ProjectMemberSerializer, + ProjectMemberInviteSerializer, + ProjectIdentifierSerializer, + ProjectLiteSerializer, + ProjectMemberLiteSerializer, + DeployBoardSerializer, + ProjectMemberAdminSerializer, + ProjectPublicMemberSerializer, + ProjectMemberRoleSerializer, +) +from .state import StateSerializer, StateLiteSerializer +from .view import IssueViewSerializer, ViewIssueListSerializer +from .cycle import ( + CycleSerializer, + CycleIssueSerializer, + CycleWriteSerializer, + CycleUserPropertiesSerializer, +) +from .asset import FileAssetSerializer +from .issue import ( + IssueCreateSerializer, + IssueActivitySerializer, + IssueCommentSerializer, + IssueUserPropertySerializer, + IssueAssigneeSerializer, + LabelSerializer, + IssueSerializer, + IssueFlatSerializer, + IssueStateSerializer, + IssueLinkSerializer, + IssueIntakeSerializer, + IssueLiteSerializer, + IssueAttachmentSerializer, + IssueSubscriberSerializer, + IssueReactionSerializer, + CommentReactionSerializer, + IssueVoteSerializer, + IssueRelationSerializer, + RelatedIssueSerializer, + IssuePublicSerializer, + IssueDetailSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, + IssueVersionDetailSerializer, + IssueDescriptionVersionDetailSerializer, + IssueListDetailSerializer, +) + +from .module import ( + ModuleDetailSerializer, + ModuleWriteSerializer, + ModuleSerializer, + ModuleIssueSerializer, + ModuleLinkSerializer, + ModuleUserPropertiesSerializer, +) + +from .api import APITokenSerializer, APITokenReadSerializer + +from .importer import ImporterSerializer + +from .page import ( + PageSerializer, + PageDetailSerializer, + PageVersionSerializer, + PageBinaryUpdateSerializer, + PageVersionDetailSerializer, +) + +from .estimate import ( + EstimateSerializer, + EstimatePointSerializer, + EstimateReadSerializer, + WorkspaceEstimateSerializer, +) + +from .intake import ( + IntakeSerializer, + IntakeIssueSerializer, + IssueStateIntakeSerializer, + IntakeIssueLiteSerializer, + IntakeIssueDetailSerializer, +) + +from .analytic import AnalyticViewSerializer + +from .notification import NotificationSerializer, UserNotificationPreferenceSerializer + +from .exporter import ExporterHistorySerializer + +from .webhook import WebhookSerializer, WebhookLogSerializer + +from .favorite import UserFavoriteSerializer + +from .draft import ( + DraftIssueCreateSerializer, + DraftIssueSerializer, + DraftIssueDetailSerializer, +) diff --git a/apps/api/plane/app/serializers/analytic.py b/apps/api/plane/app/serializers/analytic.py new file mode 100644 index 00000000..13b24d14 --- /dev/null +++ b/apps/api/plane/app/serializers/analytic.py @@ -0,0 +1,27 @@ +from .base import BaseSerializer +from plane.db.models import AnalyticView +from plane.utils.issue_filters import issue_filters + + +class AnalyticViewSerializer(BaseSerializer): + class Meta: + model = AnalyticView + fields = "__all__" + read_only_fields = ["workspace", "query"] + + def create(self, validated_data): + query_params = validated_data.get("query_dict", {}) + if bool(query_params): + validated_data["query"] = issue_filters(query_params, "POST") + else: + validated_data["query"] = {} + return AnalyticView.objects.create(**validated_data) + + def update(self, instance, validated_data): + query_params = validated_data.get("query_data", {}) + if bool(query_params): + validated_data["query"] = issue_filters(query_params, "POST") + else: + validated_data["query"] = {} + validated_data["query"] = issue_filters(query_params, "PATCH") + return super().update(instance, validated_data) diff --git a/apps/api/plane/app/serializers/api.py b/apps/api/plane/app/serializers/api.py new file mode 100644 index 00000000..009f7a61 --- /dev/null +++ b/apps/api/plane/app/serializers/api.py @@ -0,0 +1,37 @@ +from .base import BaseSerializer +from plane.db.models import APIToken, APIActivityLog +from rest_framework import serializers +from django.utils import timezone + + +class APITokenSerializer(BaseSerializer): + class Meta: + model = APIToken + fields = "__all__" + read_only_fields = [ + "token", + "expired_at", + "created_at", + "updated_at", + "workspace", + "user", + ] + + +class APITokenReadSerializer(BaseSerializer): + is_active = serializers.SerializerMethodField() + + class Meta: + model = APIToken + exclude = ("token",) + + def get_is_active(self, obj: APIToken) -> bool: + if obj.expired_at is None: + return True + return timezone.now() < obj.expired_at + + +class APIActivityLogSerializer(BaseSerializer): + class Meta: + model = APIActivityLog + fields = "__all__" diff --git a/apps/api/plane/app/serializers/asset.py b/apps/api/plane/app/serializers/asset.py new file mode 100644 index 00000000..560cd353 --- /dev/null +++ b/apps/api/plane/app/serializers/asset.py @@ -0,0 +1,9 @@ +from .base import BaseSerializer +from plane.db.models import FileAsset + + +class FileAssetSerializer(BaseSerializer): + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = ["created_by", "updated_by", "created_at", "updated_at"] diff --git a/apps/api/plane/app/serializers/base.py b/apps/api/plane/app/serializers/base.py new file mode 100644 index 00000000..0d8c855c --- /dev/null +++ b/apps/api/plane/app/serializers/base.py @@ -0,0 +1,197 @@ +from rest_framework import serializers + + +class BaseSerializer(serializers.ModelSerializer): + id = serializers.PrimaryKeyRelatedField(read_only=True) + + +class DynamicBaseSerializer(BaseSerializer): + def __init__(self, *args, **kwargs): + # If 'fields' is provided in the arguments, remove it and store it separately. + # This is done so as not to pass this custom argument up to the superclass. + fields = kwargs.pop("fields", []) + self.expand = kwargs.pop("expand", []) or [] + fields = self.expand + + # Call the initialization of the superclass. + super().__init__(*args, **kwargs) + # If 'fields' was provided, filter the fields of the serializer accordingly. + if fields is not None: + self.fields = self._filter_fields(fields) + + def _filter_fields(self, fields): + """ + Adjust the serializer's fields based on the provided 'fields' list. + + :param fields: List or dictionary specifying which fields to include in the serializer. + :return: The updated fields for the serializer. + """ + # Check each field_name in the provided fields. + for field_name in fields: + # If the field is a dictionary (indicating nested fields), + # loop through its keys and values. + if isinstance(field_name, dict): + for key, value in field_name.items(): + # If the value of this nested field is a list, + # perform a recursive filter on it. + if isinstance(value, list): + self._filter_fields(self.fields[key], value) + + # Create a list to store allowed fields. + allowed = [] + for item in fields: + # If the item is a string, it directly represents a field's name. + if isinstance(item, str): + allowed.append(item) + # If the item is a dictionary, it represents a nested field. + # Add the key of this dictionary to the allowed list. + elif isinstance(item, dict): + allowed.append(list(item.keys())[0]) + + for field in allowed: + if field not in self.fields: + from . import ( + WorkspaceLiteSerializer, + ProjectLiteSerializer, + UserLiteSerializer, + StateLiteSerializer, + IssueSerializer, + LabelSerializer, + CycleIssueSerializer, + IssueLiteSerializer, + IssueRelationSerializer, + IntakeIssueLiteSerializer, + IssueReactionLiteSerializer, + IssueLinkLiteSerializer, + RelatedIssueSerializer, + ) + + # Expansion mapper + expansion = { + "user": UserLiteSerializer, + "workspace": WorkspaceLiteSerializer, + "project": ProjectLiteSerializer, + "default_assignee": UserLiteSerializer, + "project_lead": UserLiteSerializer, + "state": StateLiteSerializer, + "created_by": UserLiteSerializer, + "issue": IssueSerializer, + "actor": UserLiteSerializer, + "owned_by": UserLiteSerializer, + "members": UserLiteSerializer, + "assignees": UserLiteSerializer, + "labels": LabelSerializer, + "issue_cycle": CycleIssueSerializer, + "parent": IssueLiteSerializer, + "issue_relation": IssueRelationSerializer, + "issue_intake": IntakeIssueLiteSerializer, + "issue_related": RelatedIssueSerializer, + "issue_reactions": IssueReactionLiteSerializer, + "issue_link": IssueLinkLiteSerializer, + "sub_issues": IssueLiteSerializer, + } + + if field not in self.fields and field in expansion: + self.fields[field] = expansion[field]( + many=( + True + if field + in [ + "members", + "assignees", + "labels", + "issue_cycle", + "issue_relation", + "issue_intake", + "issue_reactions", + "issue_attachment", + "issue_link", + "sub_issues", + "issue_related", + ] + else False + ) + ) + + return self.fields + + def to_representation(self, instance): + response = super().to_representation(instance) + + # Ensure 'expand' is iterable before processing + if self.expand: + for expand in self.expand: + if expand in self.fields: + # Import all the expandable serializers + from . import ( + WorkspaceLiteSerializer, + ProjectLiteSerializer, + UserLiteSerializer, + StateLiteSerializer, + IssueSerializer, + LabelSerializer, + CycleIssueSerializer, + IssueRelationSerializer, + IntakeIssueLiteSerializer, + IssueLiteSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, + RelatedIssueSerializer, + ) + + # Expansion mapper + expansion = { + "user": UserLiteSerializer, + "workspace": WorkspaceLiteSerializer, + "project": ProjectLiteSerializer, + "default_assignee": UserLiteSerializer, + "project_lead": UserLiteSerializer, + "state": StateLiteSerializer, + "created_by": UserLiteSerializer, + "issue": IssueSerializer, + "actor": UserLiteSerializer, + "owned_by": UserLiteSerializer, + "members": UserLiteSerializer, + "assignees": UserLiteSerializer, + "labels": LabelSerializer, + "issue_cycle": CycleIssueSerializer, + "parent": IssueLiteSerializer, + "issue_relation": IssueRelationSerializer, + "issue_intake": IntakeIssueLiteSerializer, + "issue_related": RelatedIssueSerializer, + "issue_reactions": IssueReactionLiteSerializer, + "issue_attachment": IssueAttachmentLiteSerializer, + "issue_link": IssueLinkLiteSerializer, + "sub_issues": IssueLiteSerializer, + } + # Check if field in expansion then expand the field + if expand in expansion: + if isinstance(response.get(expand), list): + exp_serializer = expansion[expand](getattr(instance, expand), many=True) + else: + exp_serializer = expansion[expand](getattr(instance, expand)) + response[expand] = exp_serializer.data + else: + # You might need to handle this case differently + response[expand] = getattr(instance, f"{expand}_id", None) + + # 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/apps/api/plane/app/serializers/cycle.py b/apps/api/plane/app/serializers/cycle.py new file mode 100644 index 00000000..89a5efc0 --- /dev/null +++ b/apps/api/plane/app/serializers/cycle.py @@ -0,0 +1,102 @@ +# Third party imports +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.utils.timezone_converter import convert_to_utc + + +class CycleWriteSerializer(BaseSerializer): + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + and data.get("start_date", None) > data.get("end_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed end date") + if data.get("start_date", None) is not None and data.get("end_date", None) is not None: + project_id = ( + self.initial_data.get("project_id", None) + or (self.instance and self.instance.project_id) + or self.context.get("project_id", None) + ) + data["start_date"] = convert_to_utc( + date=str(data.get("start_date").date()), + project_id=project_id, + is_start_date=True, + ) + data["end_date"] = convert_to_utc( + date=str(data.get("end_date", None).date()), + project_id=project_id, + ) + return data + + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = ["workspace", "project", "owned_by", "archived_at"] + + +class CycleSerializer(BaseSerializer): + # favorite + is_favorite = serializers.BooleanField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) + # state group wise distribution + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) + + # active | draft | upcoming | completed + status = serializers.CharField(read_only=True) + + class Meta: + model = Cycle + fields = [ + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + "logo_props", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "status", + ] + read_only_fields = fields + + +class CycleIssueSerializer(BaseSerializer): + issue_detail = IssueStateSerializer(read_only=True, source="issue") + sub_issues_count = serializers.IntegerField(read_only=True) + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = ["workspace", "project", "cycle"] + + +class CycleUserPropertiesSerializer(BaseSerializer): + class Meta: + model = CycleUserProperties + fields = "__all__" + read_only_fields = ["workspace", "project", "cycle", "user"] diff --git a/apps/api/plane/app/serializers/draft.py b/apps/api/plane/app/serializers/draft.py new file mode 100644 index 00000000..b017a03b --- /dev/null +++ b/apps/api/plane/app/serializers/draft.py @@ -0,0 +1,338 @@ +# 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, + ProjectMember, + EstimatePoint, +) +from plane.utils.content_validator import ( + validate_html_content, + validate_binary_data, +) +from plane.app.permissions import ROLE + + +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, attrs): + if ( + attrs.get("start_date", None) is not None + and attrs.get("target_date", None) is not None + and attrs.get("start_date", None) > attrs.get("target_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed target date") + + # Validate description content for security + if "description_html" in attrs and attrs["description_html"]: + is_valid, error_msg, sanitized_html = validate_html_content(attrs["description_html"]) + if not is_valid: + raise serializers.ValidationError({"error": "html content is not valid"}) + # Update the attrs with sanitized HTML if available + if sanitized_html is not None: + attrs["description_html"] = sanitized_html + + if "description_binary" in attrs and attrs["description_binary"]: + is_valid, error_msg = validate_binary_data(attrs["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) + + # Validate assignees are from project + if attrs.get("assignee_ids", []): + attrs["assignee_ids"] = ProjectMember.objects.filter( + project_id=self.context["project_id"], + role__gte=ROLE.MEMBER.value, + is_active=True, + member_id__in=attrs["assignee_ids"], + ).values_list("member_id", flat=True) + + # Validate labels are from project + if attrs.get("label_ids"): + label_ids = [label.id for label in attrs["label_ids"]] + attrs["label_ids"] = list( + Label.objects.filter(project_id=self.context.get("project_id"), id__in=label_ids).values_list( + "id", flat=True + ) + ) + + # # Check state is from the project only else raise validation error + if ( + attrs.get("state") + and not State.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("state").id, + ).exists() + ): + raise serializers.ValidationError("State is not valid please pass a valid state_id") + + # # Check parent issue is from workspace as it can be cross workspace + if ( + attrs.get("parent") + and not Issue.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("parent").id, + ).exists() + ): + raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id") + + if ( + attrs.get("estimate_point") + and not EstimatePoint.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("estimate_point").id, + ).exists() + ): + raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id") + + return attrs + + 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_id=assignee_id, + draft_issue=issue, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ) + + if labels is not None and len(labels): + DraftIssueLabel.objects.bulk_create( + [ + DraftIssueLabel( + label_id=label_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 label_id 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_id=assignee_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 assignee_id in assignees + ], + batch_size=10, + ) + + if labels is not None: + DraftIssueLabel.objects.filter(draft_issue=instance).delete() + DraftIssueLabel.objects.bulk_create( + [ + DraftIssueLabel( + label_id=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/apps/api/plane/app/serializers/estimate.py b/apps/api/plane/app/serializers/estimate.py new file mode 100644 index 00000000..b2d65ef8 --- /dev/null +++ b/apps/api/plane/app/serializers/estimate.py @@ -0,0 +1,46 @@ +# Module imports +from .base import BaseSerializer + +from plane.db.models import Estimate, EstimatePoint + +from rest_framework import serializers + + +class EstimateSerializer(BaseSerializer): + class Meta: + model = Estimate + fields = "__all__" + read_only_fields = ["workspace", "project"] + + +class EstimatePointSerializer(BaseSerializer): + def validate(self, data): + if not data: + 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") + return data + + class Meta: + model = EstimatePoint + fields = "__all__" + read_only_fields = ["estimate", "workspace", "project"] + + +class EstimateReadSerializer(BaseSerializer): + points = EstimatePointSerializer(read_only=True, many=True) + + class Meta: + model = Estimate + fields = "__all__" + read_only_fields = ["points", "name", "description"] + + +class WorkspaceEstimateSerializer(BaseSerializer): + points = EstimatePointSerializer(read_only=True, many=True) + + class Meta: + model = Estimate + fields = "__all__" + read_only_fields = ["points", "name", "description"] diff --git a/apps/api/plane/app/serializers/exporter.py b/apps/api/plane/app/serializers/exporter.py new file mode 100644 index 00000000..5c78cfa6 --- /dev/null +++ b/apps/api/plane/app/serializers/exporter.py @@ -0,0 +1,26 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import ExporterHistory +from .user import UserLiteSerializer + + +class ExporterHistorySerializer(BaseSerializer): + initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True) + + class Meta: + model = ExporterHistory + fields = [ + "id", + "created_at", + "updated_at", + "project", + "provider", + "status", + "url", + "initiated_by", + "initiated_by_detail", + "token", + "created_by", + "updated_by", + ] + read_only_fields = fields diff --git a/apps/api/plane/app/serializers/favorite.py b/apps/api/plane/app/serializers/favorite.py new file mode 100644 index 00000000..246461f8 --- /dev/null +++ b/apps/api/plane/app/serializers/favorite.py @@ -0,0 +1,85 @@ +from rest_framework import serializers + +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"] + + +class PageFavoriteLiteSerializer(serializers.ModelSerializer): + project_id = serializers.SerializerMethodField() + + class Meta: + model = Page + fields = ["id", "name", "logo_props", "project_id"] + + def get_project_id(self, obj): + project = obj.projects.first() # This gets the first project related to the Page + return project.id if project else None + + +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"] + + +def get_entity_model_and_serializer(entity_type): + entity_map = { + "cycle": (Cycle, CycleFavoriteLiteSerializer), + "issue": (Issue, None), + "module": (Module, ModuleFavoriteLiteSerializer), + "view": (IssueView, ViewFavoriteSerializer), + "page": (Page, PageFavoriteLiteSerializer), + "project": (Project, ProjectFavoriteLiteSerializer), + "folder": (None, None), + } + return entity_map.get(entity_type, (None, None)) + + +class UserFavoriteSerializer(serializers.ModelSerializer): + entity_data = serializers.SerializerMethodField() + + class Meta: + model = UserFavorite + fields = [ + "id", + "entity_type", + "entity_identifier", + "entity_data", + "name", + "is_folder", + "sequence", + "parent", + "workspace_id", + "project_id", + ] + read_only_fields = ["workspace", "created_by", "updated_by"] + + def get_entity_data(self, obj): + entity_type = obj.entity_type + entity_identifier = obj.entity_identifier + + 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) + return entity_serializer(entity).data + except entity_model.DoesNotExist: + return None + return None diff --git a/apps/api/plane/app/serializers/importer.py b/apps/api/plane/app/serializers/importer.py new file mode 100644 index 00000000..8997f639 --- /dev/null +++ b/apps/api/plane/app/serializers/importer.py @@ -0,0 +1,16 @@ +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .project import ProjectLiteSerializer +from .workspace import WorkspaceLiteSerializer +from plane.db.models import Importer + + +class ImporterSerializer(BaseSerializer): + 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) + + class Meta: + model = Importer + fields = "__all__" diff --git a/apps/api/plane/app/serializers/intake.py b/apps/api/plane/app/serializers/intake.py new file mode 100644 index 00000000..7bc25822 --- /dev/null +++ b/apps/api/plane/app/serializers/intake.py @@ -0,0 +1,90 @@ +# Third party frameworks +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .issue import IssueIntakeSerializer, LabelLiteSerializer, IssueDetailSerializer +from .project import ProjectLiteSerializer +from .state import StateLiteSerializer +from .user import UserLiteSerializer +from plane.db.models import Intake, IntakeIssue, Issue + + +class IntakeSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(source="project", read_only=True) + pending_issue_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Intake + fields = "__all__" + read_only_fields = ["project", "workspace"] + + +class IntakeIssueSerializer(BaseSerializer): + issue = IssueIntakeSerializer(read_only=True) + + class Meta: + model = IntakeIssue + fields = [ + "id", + "status", + "duplicate_to", + "snoozed_till", + "source", + "issue", + "created_by", + ] + read_only_fields = ["project", "workspace"] + + def to_representation(self, instance): + # Pass the annotated fields to the Issue instance if they exist + if hasattr(instance, "label_ids"): + instance.issue.label_ids = instance.label_ids + return super().to_representation(instance) + + +class IntakeIssueDetailSerializer(BaseSerializer): + issue = IssueDetailSerializer(read_only=True) + duplicate_issue_detail = IssueIntakeSerializer(read_only=True, source="duplicate_to") + + class Meta: + model = IntakeIssue + fields = [ + "id", + "status", + "duplicate_to", + "snoozed_till", + "duplicate_issue_detail", + "source", + "issue", + ] + read_only_fields = ["project", "workspace"] + + def to_representation(self, instance): + # Pass the annotated fields to the Issue instance if they exist + if hasattr(instance, "assignee_ids"): + instance.issue.assignee_ids = instance.assignee_ids + if hasattr(instance, "label_ids"): + instance.issue.label_ids = instance.label_ids + + return super().to_representation(instance) + + +class IntakeIssueLiteSerializer(BaseSerializer): + class Meta: + model = IntakeIssue + fields = ["id", "status", "duplicate_to", "snoozed_till", "source"] + read_only_fields = fields + + +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) + sub_issues_count = serializers.IntegerField(read_only=True) + issue_intake = IntakeIssueLiteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py new file mode 100644 index 00000000..583b62fd --- /dev/null +++ b/apps/api/plane/app/serializers/issue.py @@ -0,0 +1,1001 @@ +# Django imports +from django.utils import timezone +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer, DynamicBaseSerializer +from .user import UserLiteSerializer +from .state import StateLiteSerializer +from .project import ProjectLiteSerializer +from .workspace import WorkspaceLiteSerializer +from plane.db.models import ( + User, + Issue, + IssueActivity, + IssueComment, + IssueUserProperty, + IssueAssignee, + IssueSubscriber, + IssueLabel, + Label, + CycleIssue, + Cycle, + Module, + ModuleIssue, + IssueLink, + FileAsset, + IssueReaction, + CommentReaction, + IssueVote, + IssueRelation, + State, + IssueVersion, + IssueDescriptionVersion, + ProjectMember, + EstimatePoint, +) +from plane.utils.content_validator import ( + validate_html_content, + validate_binary_data, +) + + +class IssueFlatSerializer(BaseSerializer): + ## Contain only flat fields + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description", + "description_html", + "priority", + "start_date", + "target_date", + "sequence_id", + "sort_order", + "is_draft", + ] + + +class IssueProjectLiteSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = Issue + fields = ["id", "project_detail", "name", "sequence_id"] + read_only_fields = fields + + +##TODO: Find a better way to write this serializer +## Find a better approach to save manytomany? +class IssueCreateSerializer(BaseSerializer): + # 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, + ) + project_id = serializers.UUIDField(source="project.id", read_only=True) + workspace_id = serializers.UUIDField(source="workspace.id", read_only=True) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + 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, attrs): + if ( + attrs.get("start_date", None) is not None + and attrs.get("target_date", None) is not None + and attrs.get("start_date", None) > attrs.get("target_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed target date") + + # Validate description content for security + if "description_html" in attrs and attrs["description_html"]: + is_valid, error_msg, sanitized_html = validate_html_content(attrs["description_html"]) + if not is_valid: + raise serializers.ValidationError({"error": "html content is not valid"}) + # Update the attrs with sanitized HTML if available + if sanitized_html is not None: + attrs["description_html"] = sanitized_html + + if "description_binary" in attrs and attrs["description_binary"]: + is_valid, error_msg = validate_binary_data(attrs["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) + + # Validate assignees are from project + if attrs.get("assignee_ids", []): + attrs["assignee_ids"] = ProjectMember.objects.filter( + project_id=self.context["project_id"], + role__gte=15, + is_active=True, + member_id__in=attrs["assignee_ids"], + ).values_list("member_id", flat=True) + + # Validate labels are from project + if attrs.get("label_ids"): + label_ids = [label.id for label in attrs["label_ids"]] + attrs["label_ids"] = list( + Label.objects.filter( + project_id=self.context.get("project_id"), + id__in=label_ids, + ).values_list("id", flat=True) + ) + + # Check state is from the project only else raise validation error + if ( + attrs.get("state") + and not State.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("state").id, + ).exists() + ): + raise serializers.ValidationError("State is not valid please pass a valid state_id") + + # Check parent issue is from workspace as it can be cross workspace + if ( + attrs.get("parent") + and not Issue.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("parent").id, + ).exists() + ): + raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id") + + if ( + attrs.get("estimate_point") + and not EstimatePoint.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("estimate_point").id, + ).exists() + ): + raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id") + + return attrs + + def create(self, validated_data): + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) + + project_id = self.context["project_id"] + workspace_id = self.context["workspace_id"] + default_assignee_id = self.context["default_assignee_id"] + + # Create Issue + issue = Issue.objects.create(**validated_data, project_id=project_id) + + # Issue Audit Users + created_by_id = issue.created_by_id + updated_by_id = issue.updated_by_id + + if assignees is not None and len(assignees): + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee_id=assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ) + except IntegrityError: + pass + else: + # Then assign it to default assignee, if it is a valid assignee + if ( + default_assignee_id is not None + and ProjectMember.objects.filter( + member_id=default_assignee_id, + project_id=project_id, + role__gte=15, + is_active=True, + ).exists() + ): + try: + IssueAssignee.objects.create( + assignee_id=default_assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + except IntegrityError: + pass + + if labels is not None and len(labels): + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label_id=label_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label_id in labels + ], + batch_size=10, + ) + except IntegrityError: + pass + + return issue + + def update(self, instance, validated_data): + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) + + # Related models + project_id = instance.project_id + workspace_id = instance.workspace_id + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + + if assignees is not None: + IssueAssignee.objects.filter(issue=instance).delete() + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee_id=assignee_id, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass + + if labels is not None: + IssueLabel.objects.filter(issue=instance).delete() + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label_id=label_id, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label_id in labels + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass + + # Time updation occues even when other related models are updated + instance.updated_at = timezone.now() + return super().update(instance, validated_data) + + +class IssueActivitySerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + source_data = serializers.SerializerMethodField() + + def get_source_data(self, obj): + if hasattr(obj, "issue") and hasattr(obj.issue, "source_data") and obj.issue.source_data: + return { + "source": obj.issue.source_data[0].source, + "source_email": obj.issue.source_data[0].source_email, + "extra": obj.issue.source_data[0].extra, + } + return None + + class Meta: + model = IssueActivity + fields = "__all__" + + +class IssueUserPropertySerializer(BaseSerializer): + class Meta: + model = IssueUserProperty + fields = "__all__" + read_only_fields = ["user", "workspace", "project"] + + +class LabelSerializer(BaseSerializer): + class Meta: + model = Label + fields = [ + "parent", + "name", + "color", + "id", + "project_id", + "workspace_id", + "sort_order", + ] + read_only_fields = ["workspace", "project"] + + +class LabelLiteSerializer(BaseSerializer): + class Meta: + model = Label + fields = ["id", "name", "color"] + + +class IssueLabelSerializer(BaseSerializer): + class Meta: + model = IssueLabel + fields = "__all__" + read_only_fields = ["workspace", "project"] + + +class IssueRelationSerializer(BaseSerializer): + id = serializers.UUIDField(source="related_issue.id", read_only=True) + project_id = serializers.PrimaryKeyRelatedField(source="related_issue.project_id", read_only=True) + sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True) + name = serializers.CharField(source="related_issue.name", read_only=True) + relation_type = serializers.CharField(read_only=True) + state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True) + priority = serializers.CharField(source="related_issue.priority", read_only=True) + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = IssueRelation + fields = [ + "id", + "project_id", + "sequence_id", + "relation_type", + "name", + "state_id", + "priority", + "assignee_ids", + "created_by", + "created_at", + "updated_at", + "updated_by", + ] + read_only_fields = [ + "workspace", + "project", + "created_by", + "created_at", + "updated_by", + "updated_at", + ] + + +class RelatedIssueSerializer(BaseSerializer): + id = serializers.UUIDField(source="issue.id", read_only=True) + project_id = serializers.PrimaryKeyRelatedField(source="issue.project_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) + state_id = serializers.UUIDField(source="issue.state.id", read_only=True) + priority = serializers.CharField(source="issue.priority", read_only=True) + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = IssueRelation + fields = [ + "id", + "project_id", + "sequence_id", + "relation_type", + "name", + "state_id", + "priority", + "assignee_ids", + "created_by", + "created_at", + "updated_by", + "updated_at", + ] + read_only_fields = [ + "workspace", + "project", + "created_by", + "created_at", + "updated_by", + "updated_at", + ] + + +class IssueAssigneeSerializer(BaseSerializer): + assignee_details = UserLiteSerializer(read_only=True, source="assignee") + + class Meta: + model = IssueAssignee + fields = "__all__" + + +class CycleBaseSerializer(BaseSerializer): + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueCycleDetailSerializer(BaseSerializer): + cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class ModuleBaseSerializer(BaseSerializer): + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueModuleDetailSerializer(BaseSerializer): + module_detail = ModuleBaseSerializer(read_only=True, source="module") + + class Meta: + model = ModuleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueLinkSerializer(BaseSerializer): + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + + class Meta: + model = IssueLink + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "issue", + ] + + def to_internal_value(self, data): + # Modify the URL before validation by appending http:// if missing + url = data.get("url", "") + if url and not url.startswith(("http://", "https://")): + data["url"] = "http://" + url + + return super().to_internal_value(data) + + def validate_url(self, value): + # Use Django's built-in URLValidator for validation + url_validator = URLValidator() + try: + url_validator(value) + except ValidationError: + raise serializers.ValidationError({"error": "Invalid URL format."}) + + return value + + # Validation if url already exists + def create(self, validated_data): + if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists(): + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) + return IssueLink.objects.create(**validated_data) + + def update(self, instance, validated_data): + if ( + IssueLink.objects.filter(url=validated_data.get("url"), issue_id=instance.issue_id) + .exclude(pk=instance.id) + .exists() + ): + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) + + return super().update(instance, validated_data) + + +class IssueLinkLiteSerializer(BaseSerializer): + class Meta: + model = IssueLink + fields = [ + "id", + "issue_id", + "title", + "url", + "metadata", + "created_by_id", + "created_at", + ] + read_only_fields = fields + + +class IssueAttachmentSerializer(BaseSerializer): + asset_url = serializers.CharField(read_only=True) + + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = [ + "created_by", + "updated_by", + "created_at", + "updated_at", + "workspace", + "project", + "issue", + ] + + +class IssueAttachmentLiteSerializer(DynamicBaseSerializer): + class Meta: + model = FileAsset + fields = [ + "id", + "asset", + "attributes", + # "issue_id", + "created_by", + "updated_at", + "updated_by", + "asset_url", + ] + read_only_fields = fields + + +class IssueReactionSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueReaction + fields = "__all__" + read_only_fields = ["workspace", "project", "issue", "actor", "deleted_at"] + + +class IssueReactionLiteSerializer(DynamicBaseSerializer): + display_name = serializers.CharField(source="actor.display_name", read_only=True) + + class Meta: + model = IssueReaction + fields = ["id", "actor", "issue", "reaction", "display_name"] + + +class CommentReactionSerializer(BaseSerializer): + display_name = serializers.CharField(source="actor.display_name", read_only=True) + + class Meta: + model = CommentReaction + fields = [ + "id", + "actor", + "comment", + "reaction", + "display_name", + "deleted_at", + "workspace", + "project", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at", "created_by", "updated_by"] + + +class IssueVoteSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueVote + fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"] + read_only_fields = fields + + +class IssueCommentSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + comment_reactions = CommentReactionSerializer(read_only=True, many=True) + is_member = serializers.BooleanField(read_only=True) + + class Meta: + model = IssueComment + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueStateFlatSerializer(BaseSerializer): + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + + class Meta: + model = Issue + fields = ["id", "sequence_id", "name", "state_detail", "project_detail"] + + +# Issue Serializer with state details +class IssueStateSerializer(DynamicBaseSerializer): + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + attachment_count = serializers.IntegerField(read_only=True) + link_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Issue + fields = "__all__" + + +class IssueIntakeSerializer(DynamicBaseSerializer): + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "priority", + "sequence_id", + "project_id", + "created_at", + "label_ids", + "created_by", + ] + read_only_fields = fields + + +class IssueSerializer(DynamicBaseSerializer): + # 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) + + # Count items + sub_issues_count = serializers.IntegerField(read_only=True) + attachment_count = serializers.IntegerField(read_only=True) + link_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Issue + fields = [ + "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", + ] + read_only_fields = fields + + +class IssueListDetailSerializer(serializers.Serializer): + def __init__(self, *args, **kwargs): + # Extract expand parameter and store it as instance variable + self.expand = kwargs.pop("expand", []) or [] + # Extract fields parameter and store it as instance variable + self.fields = kwargs.pop("fields", []) or [] + super().__init__(*args, **kwargs) + + def get_module_ids(self, obj): + return [module.module_id for module in obj.issue_module.all()] + + def get_label_ids(self, obj): + return [label.label_id for label in obj.label_issue.all()] + + def get_assignee_ids(self, obj): + return [assignee.assignee_id for assignee in obj.issue_assignee.all()] + + def to_representation(self, instance): + data = { + # Basic fields + "id": instance.id, + "name": instance.name, + "state_id": instance.state_id, + "sort_order": instance.sort_order, + "completed_at": instance.completed_at, + "estimate_point": instance.estimate_point_id, + "priority": instance.priority, + "start_date": instance.start_date, + "target_date": instance.target_date, + "sequence_id": instance.sequence_id, + "project_id": instance.project_id, + "parent_id": instance.parent_id, + "created_at": instance.created_at, + "updated_at": instance.updated_at, + "created_by": instance.created_by_id, + "updated_by": instance.updated_by_id, + "is_draft": instance.is_draft, + "archived_at": instance.archived_at, + # Computed fields + "cycle_id": instance.cycle_id, + "module_ids": self.get_module_ids(instance), + "label_ids": self.get_label_ids(instance), + "assignee_ids": self.get_assignee_ids(instance), + "sub_issues_count": instance.sub_issues_count, + "attachment_count": instance.attachment_count, + "link_count": instance.link_count, + } + + # Handle expanded fields only when requested - using direct field access + if self.expand: + if "issue_relation" in self.expand: + relations = [] + for relation in instance.issue_relation.all(): + related_issue = relation.related_issue + # If the related issue is deleted, skip it + if not related_issue: + continue + # Add the related issue to the relations list + relations.append( + { + "id": related_issue.id, + "project_id": related_issue.project_id, + "sequence_id": related_issue.sequence_id, + "name": related_issue.name, + "relation_type": relation.relation_type, + "state_id": related_issue.state_id, + "priority": related_issue.priority, + "created_by": related_issue.created_by_id, + "created_at": related_issue.created_at, + "updated_at": related_issue.updated_at, + "updated_by": related_issue.updated_by_id, + } + ) + data["issue_relation"] = relations + + if "issue_related" in self.expand: + related = [] + for relation in instance.issue_related.all(): + issue = relation.issue + # If the related issue is deleted, skip it + if not issue: + continue + # Add the related issue to the related list + related.append( + { + "id": issue.id, + "project_id": issue.project_id, + "sequence_id": issue.sequence_id, + "name": issue.name, + "relation_type": relation.relation_type, + "state_id": issue.state_id, + "priority": issue.priority, + "created_by": issue.created_by_id, + "created_at": issue.created_at, + "updated_at": issue.updated_at, + "updated_by": issue.updated_by_id, + } + ) + data["issue_related"] = related + + return data + + +class IssueLiteSerializer(DynamicBaseSerializer): + class Meta: + model = Issue + fields = ["id", "sequence_id", "project_id"] + read_only_fields = fields + + +class IssueDetailSerializer(IssueSerializer): + description_html = serializers.CharField() + is_subscribed = serializers.BooleanField(read_only=True) + is_intake = serializers.BooleanField(read_only=True) + + class Meta(IssueSerializer.Meta): + fields = IssueSerializer.Meta.fields + [ + "description_html", + "is_subscribed", + "is_intake", + ] + read_only_fields = fields + + +class IssuePublicSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateLiteSerializer(read_only=True, source="state") + reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions") + votes = IssueVoteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description_html", + "sequence_id", + "state", + "state_detail", + "project", + "project_detail", + "workspace", + "priority", + "target_date", + "reactions", + "votes", + ] + read_only_fields = fields + + +class IssueSubscriberSerializer(BaseSerializer): + class Meta: + model = IssueSubscriber + fields = "__all__" + read_only_fields = ["workspace", "project", "issue"] + + +class IssueVersionDetailSerializer(BaseSerializer): + class Meta: + model = IssueVersion + fields = [ + "id", + "workspace", + "project", + "issue", + "parent", + "state", + "estimate_point", + "name", + "priority", + "start_date", + "target_date", + "assignees", + "sequence_id", + "labels", + "sort_order", + "completed_at", + "archived_at", + "is_draft", + "external_source", + "external_id", + "type", + "cycle", + "modules", + "meta", + "name", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "project", "issue"] + + +class IssueDescriptionVersionDetailSerializer(BaseSerializer): + class Meta: + model = IssueDescriptionVersion + fields = [ + "id", + "workspace", + "project", + "issue", + "description_binary", + "description_html", + "description_stripped", + "description_json", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "project", "issue"] diff --git a/apps/api/plane/app/serializers/module.py b/apps/api/plane/app/serializers/module.py new file mode 100644 index 00000000..b5e2953c --- /dev/null +++ b/apps/api/plane/app/serializers/module.py @@ -0,0 +1,276 @@ +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer, DynamicBaseSerializer +from .project import ProjectLiteSerializer + +# Django imports +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError + +from plane.db.models import ( + User, + Module, + ModuleMember, + ModuleIssue, + ModuleLink, + ModuleUserProperties, +) + + +class ModuleWriteSerializer(BaseSerializer): + lead_id = serializers.PrimaryKeyRelatedField( + source="lead", queryset=User.objects.all(), required=False, allow_null=True + ) + member_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "archived_at", + "deleted_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + data["member_ids"] = [str(member.id) for member in instance.members.all()] + return data + + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed target date") + return data + + def create(self, validated_data): + members = validated_data.pop("member_ids", None) + project = self.context["project"] + + 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(): + raise serializers.ValidationError({"error": "Module with this name already exists"}) + + module = Module.objects.create(**validated_data, project=project) + if members is not None: + ModuleMember.objects.bulk_create( + [ + ModuleMember( + module=module, + member=member, + project=project, + workspace=project.workspace, + created_by=module.created_by, + updated_by=module.updated_by, + ) + for member in members + ], + batch_size=10, + ignore_conflicts=True, + ) + + return module + + def update(self, instance, validated_data): + members = validated_data.pop("member_ids", None) + 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=instance.project).exclude(id=instance.id).exists(): + raise serializers.ValidationError({"error": "Module with this name already exists"}) + + if members is not None: + ModuleMember.objects.filter(module=instance).delete() + ModuleMember.objects.bulk_create( + [ + ModuleMember( + module=instance, + member=member, + project=instance.project, + workspace=instance.project.workspace, + created_by=instance.created_by, + updated_by=instance.updated_by, + ) + for member in members + ], + batch_size=10, + ignore_conflicts=True, + ) + + return super().update(instance, validated_data) + + +class ModuleFlatSerializer(BaseSerializer): + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class ModuleIssueSerializer(BaseSerializer): + module_detail = ModuleFlatSerializer(read_only=True, source="module") + issue_detail = ProjectLiteSerializer(read_only=True, source="issue") + sub_issues_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ModuleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "module", + ] + + +class ModuleLinkSerializer(BaseSerializer): + class Meta: + model = ModuleLink + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "module", + ] + + def to_internal_value(self, data): + # Modify the URL before validation by appending http:// if missing + url = data.get("url", "") + if url and not url.startswith(("http://", "https://")): + data["url"] = "http://" + url + + return super().to_internal_value(data) + + def validate_url(self, value): + # Use Django's built-in URLValidator for validation + url_validator = URLValidator() + try: + url_validator(value) + except ValidationError: + raise serializers.ValidationError({"error": "Invalid URL format."}) + + return value + + 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")).exists(): + raise serializers.ValidationError({"error": "URL already exists."}) + return super().create(validated_data) + + def update(self, instance, validated_data): + validated_data["url"] = self.validate_url(validated_data.get("url")) + if ( + ModuleLink.objects.filter(url=validated_data.get("url"), module_id=instance.module_id) + .exclude(pk=instance.id) + .exists() + ): + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) + + return super().update(instance, validated_data) + + +class ModuleSerializer(DynamicBaseSerializer): + member_ids = serializers.ListField(child=serializers.UUIDField(), required=False, allow_null=True) + is_favorite = serializers.BooleanField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) + total_estimate_points = serializers.FloatField(read_only=True) + completed_estimate_points = serializers.FloatField(read_only=True) + + class Meta: + model = Module + fields = [ + # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + "logo_props", + # computed fields + "total_estimate_points", + "completed_estimate_points", + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + "archived_at", + ] + read_only_fields = fields + + +class ModuleDetailSerializer(ModuleSerializer): + link_module = ModuleLinkSerializer(read_only=True, many=True) + sub_issues = serializers.IntegerField(read_only=True) + backlog_estimate_points = serializers.FloatField(read_only=True) + unstarted_estimate_points = serializers.FloatField(read_only=True) + started_estimate_points = serializers.FloatField(read_only=True) + cancelled_estimate_points = serializers.FloatField(read_only=True) + + class Meta(ModuleSerializer.Meta): + fields = ModuleSerializer.Meta.fields + [ + "link_module", + "sub_issues", + "backlog_estimate_points", + "unstarted_estimate_points", + "started_estimate_points", + "cancelled_estimate_points", + ] + + +class ModuleUserPropertiesSerializer(BaseSerializer): + class Meta: + model = ModuleUserProperties + fields = "__all__" + read_only_fields = ["workspace", "project", "module", "user"] diff --git a/apps/api/plane/app/serializers/notification.py b/apps/api/plane/app/serializers/notification.py new file mode 100644 index 00000000..58007ec2 --- /dev/null +++ b/apps/api/plane/app/serializers/notification.py @@ -0,0 +1,24 @@ +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from plane.db.models import Notification, UserNotificationPreference + +# Third Party imports +from rest_framework import serializers + + +class NotificationSerializer(BaseSerializer): + 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: + model = Notification + fields = "__all__" + + +class UserNotificationPreferenceSerializer(BaseSerializer): + class Meta: + model = UserNotificationPreference + fields = "__all__" diff --git a/apps/api/plane/app/serializers/page.py b/apps/api/plane/app/serializers/page.py new file mode 100644 index 00000000..3aecbafd --- /dev/null +++ b/apps/api/plane/app/serializers/page.py @@ -0,0 +1,221 @@ +# Third party imports +from rest_framework import serializers +import base64 + +# Module imports +from .base import BaseSerializer +from plane.utils.content_validator import ( + validate_binary_data, + validate_html_content, +) +from plane.db.models import ( + Page, + PageLabel, + Label, + ProjectPage, + Project, + PageVersion, +) + + +class PageSerializer(BaseSerializer): + is_favorite = serializers.BooleanField(read_only=True) + labels = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + # Many to many + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + project_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + class Meta: + model = Page + fields = [ + "id", + "name", + "owned_by", + "access", + "color", + "labels", + "parent", + "is_favorite", + "is_locked", + "archived_at", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + "view_props", + "logo_props", + "label_ids", + "project_ids", + ] + read_only_fields = ["workspace", "owned_by"] + + def create(self, validated_data): + labels = validated_data.pop("labels", None) + project_id = self.context["project_id"] + owned_by_id = self.context["owned_by_id"] + description = self.context["description"] + description_binary = self.context["description_binary"] + description_html = self.context["description_html"] + + # Get the workspace id from the project + project = Project.objects.get(pk=project_id) + + # Create the page + page = Page.objects.create( + **validated_data, + description=description, + description_binary=description_binary, + description_html=description_html, + owned_by_id=owned_by_id, + workspace_id=project.workspace_id, + ) + + # Create the project page + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + + # Create page labels + if labels is not None: + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=page, + workspace_id=page.workspace_id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + return page + + def update(self, instance, validated_data): + labels = validated_data.pop("labels", None) + if labels is not None: + PageLabel.objects.filter(page=instance).delete() + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=instance, + workspace_id=instance.workspace_id, + created_by_id=instance.created_by_id, + updated_by_id=instance.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + return super().update(instance, validated_data) + + +class PageDetailSerializer(PageSerializer): + description_html = serializers.CharField() + + class Meta(PageSerializer.Meta): + fields = PageSerializer.Meta.fields + ["description_html"] + + +class PageVersionSerializer(BaseSerializer): + class Meta: + model = PageVersion + fields = [ + "id", + "workspace", + "page", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "page"] + + +class PageVersionDetailSerializer(BaseSerializer): + class Meta: + model = PageVersion + fields = [ + "id", + "workspace", + "page", + "last_saved_at", + "description_binary", + "description_html", + "description_json", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "page"] + + +class PageBinaryUpdateSerializer(serializers.Serializer): + """Serializer for updating page binary description with validation""" + + description_binary = serializers.CharField(required=False, allow_blank=True) + description_html = serializers.CharField(required=False, allow_blank=True) + description = serializers.JSONField(required=False, allow_null=True) + + def validate_description_binary(self, value): + """Validate the base64-encoded binary data""" + if not value: + return value + + try: + # Decode the base64 data + binary_data = base64.b64decode(value) + + # Validate the binary data + is_valid, error_message = validate_binary_data(binary_data) + if not is_valid: + raise serializers.ValidationError(f"Invalid binary data: {error_message}") + + return binary_data + except Exception as e: + if isinstance(e, serializers.ValidationError): + raise + raise serializers.ValidationError("Failed to decode base64 data") + + def validate_description_html(self, value): + """Validate the HTML content""" + if not value: + return value + + # Use the validation function from utils + is_valid, error_message, sanitized_html = validate_html_content(value) + if not is_valid: + raise serializers.ValidationError(error_message) + + # Return sanitized HTML if available, otherwise return original + return sanitized_html if sanitized_html is not None else value + + def update(self, instance, validated_data): + """Update the page instance with validated data""" + if "description_binary" in validated_data: + instance.description_binary = validated_data.get("description_binary") + + if "description_html" in validated_data: + instance.description_html = validated_data.get("description_html") + + if "description" in validated_data: + instance.description = validated_data.get("description") + + instance.save() + return instance diff --git a/apps/api/plane/app/serializers/project.py b/apps/api/plane/app/serializers/project.py new file mode 100644 index 00000000..c709093a --- /dev/null +++ b/apps/api/plane/app/serializers/project.py @@ -0,0 +1,203 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer, DynamicBaseSerializer +from plane.app.serializers.workspace import WorkspaceLiteSerializer +from plane.app.serializers.user import UserLiteSerializer, UserAdminLiteSerializer +from plane.db.models import ( + Project, + ProjectMember, + ProjectMemberInvite, + ProjectIdentifier, + DeployBoard, + ProjectPublicMember, +) +from plane.utils.content_validator import ( + validate_html_content, +) + + +class ProjectSerializer(BaseSerializer): + 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"] + + def validate_name(self, name): + project_id = self.instance.id if self.instance else None + workspace_id = self.context["workspace_id"] + + project = Project.objects.filter(name=name, workspace_id=workspace_id) + + if project_id: + project = project.exclude(id=project_id) + + if project.exists(): + raise serializers.ValidationError( + detail="PROJECT_NAME_ALREADY_EXIST", + ) + + return name + + def validate_identifier(self, identifier): + project_id = self.instance.id if self.instance else None + workspace_id = self.context["workspace_id"] + + project = Project.objects.filter(identifier=identifier, workspace_id=workspace_id) + + if project_id: + project = project.exclude(id=project_id) + + if project.exists(): + raise serializers.ValidationError( + detail="PROJECT_IDENTIFIER_ALREADY_EXIST", + ) + + return identifier + + def validate(self, data): + # Validate description content for security + if "description_html" in data and data["description_html"]: + is_valid, error_msg, sanitized_html = validate_html_content(str(data["description_html"])) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html + + if not is_valid: + raise serializers.ValidationError({"error": "html content is not valid"}) + + return data + + def create(self, validated_data): + workspace_id = self.context["workspace_id"] + + project = Project.objects.create(**validated_data, workspace_id=workspace_id) + + ProjectIdentifier.objects.create(name=project.identifier, project=project, workspace_id=workspace_id) + + return project + + +class ProjectLiteSerializer(BaseSerializer): + class Meta: + model = Project + fields = [ + "id", + "identifier", + "name", + "cover_image", + "cover_image_url", + "logo_props", + "description", + ] + read_only_fields = fields + + +class ProjectListSerializer(DynamicBaseSerializer): + is_favorite = serializers.BooleanField(read_only=True) + sort_order = serializers.FloatField(read_only=True) + 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) + if project_members is not None: + # Filter members by the project ID + return [member.member_id for member in project_members if member.is_active and not member.member.is_bot] + return [] + + class Meta: + model = Project + fields = "__all__" + + +class ProjectDetailSerializer(BaseSerializer): + # workspace = WorkSpaceSerializer(read_only=True) + default_assignee = UserLiteSerializer(read_only=True) + project_lead = UserLiteSerializer(read_only=True) + is_favorite = serializers.BooleanField(read_only=True) + sort_order = serializers.FloatField(read_only=True) + member_role = serializers.IntegerField(read_only=True) + anchor = serializers.CharField(read_only=True) + + class Meta: + model = Project + fields = "__all__" + + +class ProjectMemberSerializer(BaseSerializer): + workspace = WorkspaceLiteSerializer(read_only=True) + project = ProjectLiteSerializer(read_only=True) + member = UserLiteSerializer(read_only=True) + + class Meta: + model = ProjectMember + fields = "__all__" + + +class ProjectMemberAdminSerializer(BaseSerializer): + workspace = WorkspaceLiteSerializer(read_only=True) + project = ProjectLiteSerializer(read_only=True) + member = UserAdminLiteSerializer(read_only=True) + + class Meta: + model = ProjectMember + fields = "__all__" + + +class ProjectMemberRoleSerializer(DynamicBaseSerializer): + original_role = serializers.IntegerField(source="role", read_only=True) + + class Meta: + model = ProjectMember + fields = ("id", "role", "member", "project", "original_role", "created_at") + read_only_fields = ["original_role", "created_at"] + + +class ProjectMemberInviteSerializer(BaseSerializer): + project = ProjectLiteSerializer(read_only=True) + workspace = WorkspaceLiteSerializer(read_only=True) + + class Meta: + model = ProjectMemberInvite + fields = "__all__" + + +class ProjectIdentifierSerializer(BaseSerializer): + class Meta: + model = ProjectIdentifier + fields = "__all__" + + +class ProjectMemberLiteSerializer(BaseSerializer): + member = UserLiteSerializer(read_only=True) + is_subscribed = serializers.BooleanField(read_only=True) + + class Meta: + model = ProjectMember + fields = ["member", "id", "is_subscribed"] + read_only_fields = fields + + +class DeployBoardSerializer(BaseSerializer): + project_details = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + + class Meta: + model = DeployBoard + fields = "__all__" + read_only_fields = ["workspace", "project", "anchor"] + + +class ProjectPublicMemberSerializer(BaseSerializer): + class Meta: + model = ProjectPublicMember + fields = "__all__" + read_only_fields = ["workspace", "project", "member"] diff --git a/apps/api/plane/app/serializers/state.py b/apps/api/plane/app/serializers/state.py new file mode 100644 index 00000000..29d8cf30 --- /dev/null +++ b/apps/api/plane/app/serializers/state.py @@ -0,0 +1,32 @@ +# Module imports +from .base import BaseSerializer +from rest_framework import serializers + +from plane.db.models import State + + +class StateSerializer(BaseSerializer): + order = serializers.FloatField(required=False) + + class Meta: + model = State + fields = [ + "id", + "project_id", + "workspace_id", + "name", + "color", + "group", + "default", + "description", + "sequence", + "order", + ] + read_only_fields = ["workspace", "project"] + + +class StateLiteSerializer(BaseSerializer): + class Meta: + model = State + fields = ["id", "name", "color", "group"] + read_only_fields = fields diff --git a/apps/api/plane/app/serializers/user.py b/apps/api/plane/app/serializers/user.py new file mode 100644 index 00000000..670667a8 --- /dev/null +++ b/apps/api/plane/app/serializers/user.py @@ -0,0 +1,207 @@ +# Third party imports +from rest_framework import serializers + +# Module import +from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite +from plane.utils.url import contains_url + +from .base import BaseSerializer + + +class UserSerializer(BaseSerializer): + def validate_first_name(self, value): + if contains_url(value): + raise serializers.ValidationError("First name cannot contain a URL.") + return value + + def validate_last_name(self, value): + if contains_url(value): + raise serializers.ValidationError("Last name cannot contain a URL.") + return value + + class Meta: + model = User + # Exclude password field from the serializer + 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", + "username", + "mobile_number", + "email", + "token", + "created_at", + "updated_at", + "is_superuser", + "is_staff", + "is_managed", + "last_active", + "last_login_time", + "last_logout_time", + "last_login_ip", + "last_logout_ip", + "last_login_uagent", + "last_location", + "last_login_medium", + "created_location", + "is_bot", + "is_password_autoset", + "is_email_verified", + "is_active", + "token_updated_at", + ] + + # If the user has already filled first name or last name then he is onboarded + def get_is_onboarded(self, obj): + return bool(obj.first_name) or bool(obj.last_name) + + +class UserMeSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "avatar", + "cover_image", + "avatar_url", + "cover_image_url", + "date_joined", + "display_name", + "email", + "first_name", + "last_name", + "is_active", + "is_bot", + "is_email_verified", + "user_timezone", + "username", + "is_password_autoset", + "is_email_verified", + "last_login_medium", + ] + read_only_fields = fields + + +class UserMeSettingsSerializer(BaseSerializer): + workspace = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ["id", "email", "workspace"] + read_only_fields = fields + + def get_workspace(self, obj): + workspace_invites = WorkspaceMemberInvite.objects.filter(email=obj.email).count() + + # profile + profile = Profile.objects.get(user=obj) + if ( + profile.last_workspace_id is not None + and Workspace.objects.filter( + pk=profile.last_workspace_id, + workspace_member__member=obj.id, + workspace_member__is_active=True, + ).exists() + ): + workspace = Workspace.objects.filter( + pk=profile.last_workspace_id, + workspace_member__member=obj.id, + workspace_member__is_active=True, + ).first() + logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else "" + return { + "last_workspace_id": profile.last_workspace_id, + "last_workspace_slug": (workspace.slug if workspace is not None else ""), + "last_workspace_name": (workspace.name if workspace is not None else ""), + "last_workspace_logo": (logo_asset_url), + "fallback_workspace_id": profile.last_workspace_id, + "fallback_workspace_slug": (workspace.slug if workspace is not None else ""), + "invites": workspace_invites, + } + else: + fallback_workspace = ( + Workspace.objects.filter(workspace_member__member_id=obj.id, workspace_member__is_active=True) + .order_by("created_at") + .first() + ) + return { + "last_workspace_id": None, + "last_workspace_slug": None, + "fallback_workspace_id": (fallback_workspace.id if fallback_workspace is not None else None), + "fallback_workspace_slug": (fallback_workspace.slug if fallback_workspace is not None else None), + "invites": workspace_invites, + } + + +class UserLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "avatar", + "avatar_url", + "is_bot", + "display_name", + ] + read_only_fields = ["id", "is_bot"] + + +class UserAdminLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "avatar", + "avatar_url", + "is_bot", + "display_name", + "email", + "last_login_medium", + ] + read_only_fields = ["id", "is_bot"] + + +class ChangePasswordSerializer(serializers.Serializer): + model = User + + """ + Serializer for password change endpoint. + """ + old_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) + confirm_password = serializers.CharField(required=True, min_length=8) + + def validate(self, data): + if data.get("old_password") == data.get("new_password"): + raise serializers.ValidationError({"error": "New password cannot be same as old password."}) + + if data.get("new_password") != data.get("confirm_password"): + raise serializers.ValidationError({"error": "Confirm password should be same as the new password."}) + + return data + + +class ResetPasswordSerializer(serializers.Serializer): + """ + Serializer for password change endpoint. + """ + + new_password = serializers.CharField(required=True, min_length=8) + + +class ProfileSerializer(BaseSerializer): + class Meta: + model = Profile + fields = "__all__" + read_only_fields = ["user"] + + +class AccountSerializer(BaseSerializer): + class Meta: + model = Account + fields = "__all__" + read_only_fields = ["user"] diff --git a/apps/api/plane/app/serializers/view.py b/apps/api/plane/app/serializers/view.py new file mode 100644 index 00000000..bf7ff972 --- /dev/null +++ b/apps/api/plane/app/serializers/view.py @@ -0,0 +1,82 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import DynamicBaseSerializer +from plane.db.models import IssueView +from plane.utils.issue_filters import issue_filters + + +class ViewIssueListSerializer(serializers.Serializer): + def get_assignee_ids(self, instance): + return [assignee.assignee_id for assignee in instance.issue_assignee.all()] + + def get_label_ids(self, instance): + return [label.label_id for label in instance.label_issue.all()] + + def get_module_ids(self, instance): + return [module.module_id for module in instance.issue_module.all()] + + def to_representation(self, instance): + data = { + "id": instance.id, + "name": instance.name, + "state_id": instance.state_id, + "sort_order": instance.sort_order, + "completed_at": instance.completed_at, + "estimate_point": instance.estimate_point_id, + "priority": instance.priority, + "start_date": instance.start_date, + "target_date": instance.target_date, + "sequence_id": instance.sequence_id, + "project_id": instance.project_id, + "parent_id": instance.parent_id, + "cycle_id": instance.cycle_id, + "sub_issues_count": instance.sub_issues_count, + "created_at": instance.created_at, + "updated_at": instance.updated_at, + "created_by": instance.created_by_id, + "updated_by": instance.updated_by_id, + "attachment_count": instance.attachment_count, + "link_count": instance.link_count, + "is_draft": instance.is_draft, + "archived_at": instance.archived_at, + "state__group": instance.state.group if instance.state else None, + "assignee_ids": self.get_assignee_ids(instance), + "label_ids": self.get_label_ids(instance), + "module_ids": self.get_module_ids(instance), + } + return data + + +class IssueViewSerializer(DynamicBaseSerializer): + is_favorite = serializers.BooleanField(read_only=True) + + class Meta: + model = IssueView + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "query", + "owned_by", + "access", + "is_locked", + ] + + def create(self, validated_data): + query_params = validated_data.get("filters", {}) + if bool(query_params): + validated_data["query"] = issue_filters(query_params, "POST") + else: + validated_data["query"] = {} + return IssueView.objects.create(**validated_data) + + def update(self, instance, validated_data): + query_params = validated_data.get("filters", {}) + if bool(query_params): + validated_data["query"] = issue_filters(query_params, "POST") + else: + validated_data["query"] = {} + validated_data["query"] = issue_filters(query_params, "PATCH") + return super().update(instance, validated_data) diff --git a/apps/api/plane/app/serializers/webhook.py b/apps/api/plane/app/serializers/webhook.py new file mode 100644 index 00000000..ef193e24 --- /dev/null +++ b/apps/api/plane/app/serializers/webhook.py @@ -0,0 +1,98 @@ +# Python imports +import socket +import ipaddress +from urllib.parse import urlparse + +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import DynamicBaseSerializer +from plane.db.models import Webhook, WebhookLog +from plane.db.models.webhook import validate_domain, validate_schema + + +class WebhookSerializer(DynamicBaseSerializer): + url = serializers.URLField(validators=[validate_schema, validate_domain]) + + def create(self, validated_data): + url = validated_data.get("url", None) + + # Extract the hostname from the URL + hostname = urlparse(url).hostname + if not hostname: + raise serializers.ValidationError({"url": "Invalid URL: No hostname found."}) + + # Resolve the hostname to IP addresses + try: + ip_addresses = socket.getaddrinfo(hostname, None) + except socket.gaierror: + raise serializers.ValidationError({"url": "Hostname could not be resolved."}) + + if not ip_addresses: + raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) + + for addr in ip_addresses: + ip = ipaddress.ip_address(addr[4][0]) + if ip.is_loopback: + raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) + + # Additional validation for multiple request domains and their subdomains + request = self.context.get("request") + disallowed_domains = ["plane.so"] # Add your disallowed domains here + if request: + request_host = request.get_host().split(":")[0] # Remove port if present + disallowed_domains.append(request_host) + + # Check if hostname is a subdomain or exact match of any disallowed domain + if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains): + raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."}) + + return Webhook.objects.create(**validated_data) + + def update(self, instance, validated_data): + url = validated_data.get("url", None) + if url: + # Extract the hostname from the URL + hostname = urlparse(url).hostname + if not hostname: + raise serializers.ValidationError({"url": "Invalid URL: No hostname found."}) + + # Resolve the hostname to IP addresses + try: + ip_addresses = socket.getaddrinfo(hostname, None) + except socket.gaierror: + raise serializers.ValidationError({"url": "Hostname could not be resolved."}) + + if not ip_addresses: + raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) + + for addr in ip_addresses: + ip = ipaddress.ip_address(addr[4][0]) + if ip.is_loopback: + raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) + + # Additional validation for multiple request domains and their subdomains + request = self.context.get("request") + disallowed_domains = ["plane.so"] # Add your disallowed domains here + if request: + request_host = request.get_host().split(":")[0] # Remove port if present + disallowed_domains.append(request_host) + + # Check if hostname is a subdomain or exact match of any disallowed domain + if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains): + raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."}) + + return super().update(instance, validated_data) + + class Meta: + model = Webhook + fields = "__all__" + read_only_fields = ["workspace", "secret_key", "deleted_at"] + + +class WebhookLogSerializer(DynamicBaseSerializer): + class Meta: + model = WebhookLog + fields = "__all__" + read_only_fields = ["workspace", "webhook"] diff --git a/apps/api/plane/app/serializers/workspace.py b/apps/api/plane/app/serializers/workspace.py new file mode 100644 index 00000000..ba59f242 --- /dev/null +++ b/apps/api/plane/app/serializers/workspace.py @@ -0,0 +1,329 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer, DynamicBaseSerializer +from .user import UserLiteSerializer, UserAdminLiteSerializer + + +from plane.db.models import ( + Workspace, + WorkspaceMember, + WorkspaceMemberInvite, + WorkspaceTheme, + WorkspaceUserProperties, + WorkspaceUserLink, + UserRecentVisit, + Issue, + Page, + Project, + ProjectMember, + WorkspaceHomePreference, + Sticky, + WorkspaceUserPreference, +) +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +from plane.utils.url import contains_url +from plane.utils.content_validator import ( + validate_html_content, + validate_binary_data, +) + +# Django imports +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError +import re + + +class WorkSpaceSerializer(DynamicBaseSerializer): + total_members = serializers.IntegerField(read_only=True) + logo_url = serializers.CharField(read_only=True) + role = serializers.IntegerField(read_only=True) + + def validate_name(self, value): + # Check if the name contains a URL + if contains_url(value): + raise serializers.ValidationError("Name must not contain URLs") + return value + + def validate_slug(self, value): + # Check if the slug is restricted + if value in RESTRICTED_WORKSPACE_SLUGS: + raise serializers.ValidationError("Slug is not valid") + # Slug should only contain alphanumeric characters, hyphens, and underscores + if not re.match(r"^[a-zA-Z0-9_-]+$", value): + raise serializers.ValidationError( + "Slug can only contain letters, numbers, hyphens (-), and underscores (_)" + ) + return value + + class Meta: + model = Workspace + fields = "__all__" + read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", + "owner", + "logo_url", + ] + + +class WorkspaceLiteSerializer(BaseSerializer): + class Meta: + model = Workspace + fields = ["name", "slug", "id", "logo_url"] + read_only_fields = fields + + +class WorkSpaceMemberSerializer(DynamicBaseSerializer): + member = UserLiteSerializer(read_only=True) + + class Meta: + model = WorkspaceMember + fields = "__all__" + + +class WorkspaceMemberMeSerializer(BaseSerializer): + draft_issue_count = serializers.IntegerField(read_only=True) + + class Meta: + model = WorkspaceMember + fields = "__all__" + + +class WorkspaceMemberAdminSerializer(DynamicBaseSerializer): + member = UserAdminLiteSerializer(read_only=True) + + class Meta: + model = WorkspaceMember + fields = "__all__" + + +class WorkSpaceMemberInviteSerializer(BaseSerializer): + workspace = WorkspaceLiteSerializer(read_only=True) + invite_link = serializers.SerializerMethodField() + + def get_invite_link(self, obj): + return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}" + + class Meta: + model = WorkspaceMemberInvite + fields = "__all__" + read_only_fields = [ + "id", + "email", + "token", + "workspace", + "message", + "responded_at", + "created_at", + "updated_at", + "invite_link", + ] + + +class WorkspaceThemeSerializer(BaseSerializer): + class Meta: + model = WorkspaceTheme + fields = "__all__" + read_only_fields = ["workspace", "actor"] + + +class WorkspaceUserPropertiesSerializer(BaseSerializer): + class Meta: + model = WorkspaceUserProperties + fields = "__all__" + read_only_fields = ["workspace", "user"] + + +class WorkspaceUserLinkSerializer(BaseSerializer): + class Meta: + model = WorkspaceUserLink + fields = "__all__" + read_only_fields = ["workspace", "owner"] + + def to_internal_value(self, data): + url = data.get("url", "") + if url and not url.startswith(("http://", "https://")): + data["url"] = "http://" + url + + return super().to_internal_value(data) + + def validate_url(self, value): + url_validator = URLValidator() + try: + url_validator(value) + except ValidationError: + raise serializers.ValidationError({"error": "Invalid URL format."}) + + return value + + def create(self, validated_data): + # Filtering the WorkspaceUserLink with the given url to check if the link already exists. + + url = validated_data.get("url") + + workspace_user_link = WorkspaceUserLink.objects.filter( + url=url, + workspace_id=validated_data.get("workspace_id"), + owner_id=validated_data.get("owner_id"), + ) + + if workspace_user_link.exists(): + raise serializers.ValidationError({"error": "URL already exists for this workspace and owner"}) + + return super().create(validated_data) + + def update(self, instance, validated_data): + # Filtering the WorkspaceUserLink with the given url to check if the link already exists. + + url = validated_data.get("url") + + workspace_user_link = WorkspaceUserLink.objects.filter( + url=url, workspace_id=instance.workspace_id, owner=instance.owner + ) + + if workspace_user_link.exclude(pk=instance.id).exists(): + raise serializers.ValidationError({"error": "URL already exists for this workspace and owner"}) + + return super().update(instance, validated_data) + + +class IssueRecentVisitSerializer(serializers.ModelSerializer): + project_identifier = serializers.SerializerMethodField() + assignees = serializers.SerializerMethodField() + + class Meta: + model = Issue + fields = [ + "id", + "name", + "state", + "priority", + "assignees", + "type", + "sequence_id", + "project_id", + "project_identifier", + ] + + def get_project_identifier(self, obj): + project = obj.project + return project.identifier if project else None + + def get_assignees(self, obj): + return list(obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list("id", flat=True)) + + +class ProjectRecentVisitSerializer(serializers.ModelSerializer): + project_members = serializers.SerializerMethodField() + + class Meta: + model = Project + fields = ["id", "name", "logo_props", "project_members", "identifier"] + + def get_project_members(self, obj): + members = ProjectMember.objects.filter(project_id=obj.id, member__is_bot=False, is_active=True).values_list( + "member", flat=True + ) + + return members + + +class PageRecentVisitSerializer(serializers.ModelSerializer): + project_id = serializers.SerializerMethodField() + project_identifier = serializers.SerializerMethodField() + + class Meta: + model = Page + fields = [ + "id", + "name", + "logo_props", + "project_id", + "owned_by", + "project_identifier", + ] + + def get_project_id(self, obj): + return obj.project_id if hasattr(obj, "project_id") else obj.projects.values_list("id", flat=True).first() + + def get_project_identifier(self, obj): + project = obj.projects.first() + + return project.identifier if project else None + + +def get_entity_model_and_serializer(entity_type): + entity_map = { + "issue": (Issue, IssueRecentVisitSerializer), + "page": (Page, PageRecentVisitSerializer), + "project": (Project, ProjectRecentVisitSerializer), + } + return entity_map.get(entity_type, (None, None)) + + +class WorkspaceRecentVisitSerializer(BaseSerializer): + entity_data = serializers.SerializerMethodField() + + class Meta: + model = UserRecentVisit + fields = ["id", "entity_name", "entity_identifier", "entity_data", "visited_at"] + read_only_fields = ["workspace", "owner", "created_by", "updated_by"] + + def get_entity_data(self, obj): + entity_name = obj.entity_name + entity_identifier = obj.entity_identifier + + entity_model, entity_serializer = get_entity_model_and_serializer(entity_name) + + if entity_model and entity_serializer: + try: + entity = entity_model.objects.get(pk=entity_identifier) + + return entity_serializer(entity).data + except entity_model.DoesNotExist: + return None + return None + + +class WorkspaceHomePreferenceSerializer(BaseSerializer): + class Meta: + model = WorkspaceHomePreference + fields = ["key", "is_enabled", "sort_order"] + read_only_fields = ["workspace", "created_by", "updated_by"] + + +class StickySerializer(BaseSerializer): + class Meta: + model = Sticky + fields = "__all__" + read_only_fields = ["workspace", "owner"] + extra_kwargs = {"name": {"required": False}} + + def validate(self, data): + # Validate description content for security + if "description_html" in data and data["description_html"]: + is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"]) + if not is_valid: + raise serializers.ValidationError({"error": "html content is not valid"}) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html + + if "description_binary" in data and data["description_binary"]: + is_valid, error_msg = validate_binary_data(data["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) + + return data + + +class WorkspaceUserPreferenceSerializer(BaseSerializer): + class Meta: + model = WorkspaceUserPreference + fields = ["key", "is_pinned", "sort_order"] + read_only_fields = ["workspace", "created_by", "updated_by"] diff --git a/apps/api/plane/app/urls/__init__.py b/apps/api/plane/app/urls/__init__.py new file mode 100644 index 00000000..3feab4cb --- /dev/null +++ b/apps/api/plane/app/urls/__init__.py @@ -0,0 +1,43 @@ +from .analytic import urlpatterns as analytic_urls +from .api import urlpatterns as api_urls +from .asset import urlpatterns as asset_urls +from .cycle import urlpatterns as cycle_urls +from .estimate import urlpatterns as estimate_urls +from .external import urlpatterns as external_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 +from .page import urlpatterns as page_urls +from .project import urlpatterns as project_urls +from .search import urlpatterns as search_urls +from .state import urlpatterns as state_urls +from .user import urlpatterns as user_urls +from .views import urlpatterns as view_urls +from .webhook import urlpatterns as webhook_urls +from .workspace import urlpatterns as workspace_urls +from .timezone import urlpatterns as timezone_urls +from .exporter import urlpatterns as exporter_urls + +urlpatterns = [ + *analytic_urls, + *asset_urls, + *cycle_urls, + *estimate_urls, + *external_urls, + *intake_urls, + *issue_urls, + *module_urls, + *notification_urls, + *page_urls, + *project_urls, + *search_urls, + *state_urls, + *user_urls, + *view_urls, + *workspace_urls, + *api_urls, + *webhook_urls, + *timezone_urls, + *exporter_urls, +] diff --git a/apps/api/plane/app/urls/analytic.py b/apps/api/plane/app/urls/analytic.py new file mode 100644 index 00000000..df6ad249 --- /dev/null +++ b/apps/api/plane/app/urls/analytic.py @@ -0,0 +1,86 @@ +from django.urls import path + + +from plane.app.views import ( + AnalyticsEndpoint, + AnalyticViewViewset, + SavedAnalyticEndpoint, + ExportAnalyticsEndpoint, + AdvanceAnalyticsEndpoint, + AdvanceAnalyticsStatsEndpoint, + AdvanceAnalyticsChartEndpoint, + DefaultAnalyticsEndpoint, + ProjectStatsEndpoint, + ProjectAdvanceAnalyticsEndpoint, + ProjectAdvanceAnalyticsStatsEndpoint, + ProjectAdvanceAnalyticsChartEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//analytics/", + AnalyticsEndpoint.as_view(), + name="plane-analytics", + ), + path( + "workspaces//analytic-view/", + AnalyticViewViewset.as_view({"get": "list", "post": "create"}), + name="analytic-view", + ), + path( + "workspaces//analytic-view//", + AnalyticViewViewset.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), + name="analytic-view", + ), + path( + "workspaces//saved-analytic-view//", + SavedAnalyticEndpoint.as_view(), + name="saved-analytic-view", + ), + path( + "workspaces//export-analytics/", + ExportAnalyticsEndpoint.as_view(), + name="export-analytics", + ), + path( + "workspaces//default-analytics/", + DefaultAnalyticsEndpoint.as_view(), + name="default-analytics", + ), + path( + "workspaces//project-stats/", + ProjectStatsEndpoint.as_view(), + name="project-analytics", + ), + path( + "workspaces//advance-analytics/", + AdvanceAnalyticsEndpoint.as_view(), + name="advance-analytics", + ), + path( + "workspaces//advance-analytics-stats/", + AdvanceAnalyticsStatsEndpoint.as_view(), + name="advance-analytics-stats", + ), + path( + "workspaces//advance-analytics-charts/", + AdvanceAnalyticsChartEndpoint.as_view(), + name="advance-analytics-chart", + ), + path( + "workspaces//projects//advance-analytics/", + ProjectAdvanceAnalyticsEndpoint.as_view(), + name="project-advance-analytics", + ), + path( + "workspaces//projects//advance-analytics-stats/", + ProjectAdvanceAnalyticsStatsEndpoint.as_view(), + name="project-advance-analytics-stats", + ), + path( + "workspaces//projects//advance-analytics-charts/", + ProjectAdvanceAnalyticsChartEndpoint.as_view(), + name="project-advance-analytics-chart", + ), +] diff --git a/apps/api/plane/app/urls/api.py b/apps/api/plane/app/urls/api.py new file mode 100644 index 00000000..c74aeddb --- /dev/null +++ b/apps/api/plane/app/urls/api.py @@ -0,0 +1,22 @@ +from django.urls import path +from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint + +urlpatterns = [ + # API Tokens + path( + "users/api-tokens/", + ApiTokenEndpoint.as_view(), + name="api-tokens", + ), + path( + "users/api-tokens//", + ApiTokenEndpoint.as_view(), + name="api-tokens-details", + ), + path( + "workspaces//service-api-tokens/", + ServiceApiTokenEndpoint.as_view(), + name="service-api-tokens", + ), + ## End API Tokens +] diff --git a/apps/api/plane/app/urls/asset.py b/apps/api/plane/app/urls/asset.py new file mode 100644 index 00000000..93356b04 --- /dev/null +++ b/apps/api/plane/app/urls/asset.py @@ -0,0 +1,104 @@ +from django.urls import path + + +from plane.app.views import ( + FileAssetEndpoint, + UserAssetsEndpoint, + FileAssetViewSet, + # V2 Endpoints + WorkspaceFileAssetEndpoint, + UserAssetsV2Endpoint, + StaticFileAssetEndpoint, + AssetRestoreEndpoint, + ProjectAssetEndpoint, + ProjectBulkAssetEndpoint, + AssetCheckEndpoint, + WorkspaceAssetDownloadEndpoint, + ProjectAssetDownloadEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//file-assets/", + FileAssetEndpoint.as_view(), + name="file-assets", + ), + path( + "workspaces/file-assets///", + 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( + "workspaces/file-assets///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(), + name="bulk-asset-update", + ), + path( + "assets/v2/workspaces//check//", + AssetCheckEndpoint.as_view(), + name="asset-check", + ), + path( + "assets/v2/workspaces//download//", + WorkspaceAssetDownloadEndpoint.as_view(), + name="workspace-asset-download", + ), + path( + "assets/v2/workspaces//projects//download//", + ProjectAssetDownloadEndpoint.as_view(), + name="project-asset-download", + ), +] diff --git a/apps/api/plane/app/urls/cycle.py b/apps/api/plane/app/urls/cycle.py new file mode 100644 index 00000000..f188d087 --- /dev/null +++ b/apps/api/plane/app/urls/cycle.py @@ -0,0 +1,102 @@ +from django.urls import path + + +from plane.app.views import ( + CycleViewSet, + CycleIssueViewSet, + CycleDateCheckEndpoint, + CycleFavoriteViewSet, + CycleProgressEndpoint, + CycleAnalyticsEndpoint, + TransferCycleIssueEndpoint, + CycleUserPropertiesEndpoint, + CycleArchiveUnarchiveEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//projects//cycles/", + CycleViewSet.as_view({"get": "list", "post": "create"}), + name="project-cycle", + ), + path( + "workspaces//projects//cycles//", + CycleViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-cycle", + ), + path( + "workspaces//projects//cycles//cycle-issues/", + CycleIssueViewSet.as_view({"get": "list", "post": "create"}), + name="project-issue-cycle", + ), + path( + "workspaces//projects//cycles//cycle-issues//", + CycleIssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-cycle", + ), + path( + "workspaces//projects//cycles/date-check/", + CycleDateCheckEndpoint.as_view(), + name="project-cycle-date", + ), + path( + "workspaces//projects//user-favorite-cycles/", + CycleFavoriteViewSet.as_view({"get": "list", "post": "create"}), + name="user-favorite-cycle", + ), + path( + "workspaces//projects//user-favorite-cycles//", + CycleFavoriteViewSet.as_view({"delete": "destroy"}), + name="user-favorite-cycle", + ), + path( + "workspaces//projects//cycles//transfer-issues/", + TransferCycleIssueEndpoint.as_view(), + name="transfer-issues", + ), + path( + "workspaces//projects//cycles//user-properties/", + CycleUserPropertiesEndpoint.as_view(), + name="cycle-user-filters", + ), + path( + "workspaces//projects//cycles//archive/", + CycleArchiveUnarchiveEndpoint.as_view(), + name="cycle-archive-unarchive", + ), + path( + "workspaces//projects//archived-cycles/", + CycleArchiveUnarchiveEndpoint.as_view(), + name="cycle-archive-unarchive", + ), + path( + "workspaces//projects//archived-cycles//", + CycleArchiveUnarchiveEndpoint.as_view(), + name="cycle-archive-unarchive", + ), + path( + "workspaces//projects//cycles//progress/", + CycleProgressEndpoint.as_view(), + name="project-cycle", + ), + path( + "workspaces//projects//cycles//analytics/", + CycleAnalyticsEndpoint.as_view(), + name="project-cycle", + ), +] diff --git a/apps/api/plane/app/urls/estimate.py b/apps/api/plane/app/urls/estimate.py new file mode 100644 index 00000000..c77a5b6b --- /dev/null +++ b/apps/api/plane/app/urls/estimate.py @@ -0,0 +1,37 @@ +from django.urls import path + + +from plane.app.views import ( + ProjectEstimatePointEndpoint, + BulkEstimatePointEndpoint, + EstimatePointEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//projects//project-estimates/", + ProjectEstimatePointEndpoint.as_view(), + name="project-estimate-points", + ), + path( + "workspaces//projects//estimates/", + 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"}), + name="bulk-create-estimate-points", + ), + path( + "workspaces//projects//estimates//estimate-points/", + EstimatePointEndpoint.as_view({"post": "create"}), + name="estimate-points", + ), + path( + "workspaces//projects//estimates//estimate-points//", + EstimatePointEndpoint.as_view({"patch": "partial_update", "delete": "destroy"}), + name="estimate-points", + ), +] diff --git a/apps/api/plane/app/urls/exporter.py b/apps/api/plane/app/urls/exporter.py new file mode 100644 index 00000000..0bcb4621 --- /dev/null +++ b/apps/api/plane/app/urls/exporter.py @@ -0,0 +1,12 @@ +from django.urls import path + +from plane.app.views import ExportIssuesEndpoint + + +urlpatterns = [ + path( + "workspaces//export-issues/", + ExportIssuesEndpoint.as_view(), + name="export-issues", + ), +] \ No newline at end of file diff --git a/apps/api/plane/app/urls/external.py b/apps/api/plane/app/urls/external.py new file mode 100644 index 00000000..4972962d --- /dev/null +++ b/apps/api/plane/app/urls/external.py @@ -0,0 +1,20 @@ +from django.urls import path + + +from plane.app.views import UnsplashEndpoint +from plane.app.views import GPTIntegrationEndpoint, WorkspaceGPTIntegrationEndpoint + + +urlpatterns = [ + path("unsplash/", UnsplashEndpoint.as_view(), name="unsplash"), + path( + "workspaces//projects//ai-assistant/", + GPTIntegrationEndpoint.as_view(), + name="importer", + ), + path( + "workspaces//ai-assistant/", + WorkspaceGPTIntegrationEndpoint.as_view(), + name="importer", + ), +] diff --git a/apps/api/plane/app/urls/intake.py b/apps/api/plane/app/urls/intake.py new file mode 100644 index 00000000..dd1efc87 --- /dev/null +++ b/apps/api/plane/app/urls/intake.py @@ -0,0 +1,62 @@ +from django.urls import path + + +from plane.app.views import ( + IntakeViewSet, + IntakeIssueViewSet, + IntakeWorkItemDescriptionVersionEndpoint, +) + + +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", + ), + path( + "workspaces//projects//intake-work-items//description-versions/", + IntakeWorkItemDescriptionVersionEndpoint.as_view(), + name="intake-work-item-versions", + ), + path( + "workspaces//projects//intake-work-items//description-versions//", + IntakeWorkItemDescriptionVersionEndpoint.as_view(), + name="intake-work-item-versions", + ), +] diff --git a/apps/api/plane/app/urls/issue.py b/apps/api/plane/app/urls/issue.py new file mode 100644 index 00000000..1d809e24 --- /dev/null +++ b/apps/api/plane/app/urls/issue.py @@ -0,0 +1,282 @@ +from django.urls import path + +from plane.app.views import ( + BulkCreateIssueLabelsEndpoint, + BulkDeleteIssuesEndpoint, + SubIssuesEndpoint, + IssueLinkViewSet, + IssueAttachmentEndpoint, + CommentReactionViewSet, + IssueActivityEndpoint, + IssueArchiveViewSet, + IssueCommentViewSet, + IssueListEndpoint, + IssueReactionViewSet, + IssueRelationViewSet, + IssueSubscriberViewSet, + IssueUserDisplayPropertyEndpoint, + IssueViewSet, + LabelViewSet, + BulkArchiveIssuesEndpoint, + DeletedIssuesListViewSet, + IssuePaginatedViewSet, + IssueDetailEndpoint, + IssueAttachmentV2Endpoint, + IssueBulkUpdateDateEndpoint, + IssueVersionEndpoint, + WorkItemDescriptionVersionEndpoint, + IssueMetaEndpoint, + IssueDetailIdentifierEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//issues/list/", + IssueListEndpoint.as_view(), + name="project-issue", + ), + path( + "workspaces//projects//issues/", + 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/", + IssuePaginatedViewSet.as_view({"get": "list"}), + name="project-issues-paginated", + ), + path( + "workspaces//projects//issues//", + IssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue", + ), + path( + "workspaces//projects//issue-labels/", + LabelViewSet.as_view({"get": "list", "post": "create"}), + name="project-issue-labels", + ), + path( + "workspaces//projects//issue-labels//", + LabelViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-labels", + ), + path( + "workspaces//projects//bulk-create-labels/", + BulkCreateIssueLabelsEndpoint.as_view(), + name="project-bulk-labels", + ), + path( + "workspaces//projects//bulk-delete-issues/", + BulkDeleteIssuesEndpoint.as_view(), + name="project-issues-bulk", + ), + path( + "workspaces//projects//bulk-archive-issues/", + BulkArchiveIssuesEndpoint.as_view(), + name="bulk-archive-issues", + ), + ## + path( + "workspaces//projects//issues//sub-issues/", + SubIssuesEndpoint.as_view(), + name="sub-issues", + ), + path( + "workspaces//projects//issues//issue-links/", + IssueLinkViewSet.as_view({"get": "list", "post": "create"}), + name="project-issue-links", + ), + path( + "workspaces//projects//issues//issue-links//", + IssueLinkViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-links", + ), + path( + "workspaces//projects//issues//issue-attachments/", + IssueAttachmentEndpoint.as_view(), + name="project-issue-attachments", + ), + path( + "workspaces//projects//issues//issue-attachments//", + IssueAttachmentEndpoint.as_view(), + name="project-issue-attachments", + ), + # 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", + ), + ## End Issues + ## Issue Activity + path( + "workspaces//projects//issues//history/", + IssueActivityEndpoint.as_view(), + name="project-issue-history", + ), + ## Issue Activity + ## IssueComments + path( + "workspaces//projects//issues//comments/", + IssueCommentViewSet.as_view({"get": "list", "post": "create"}), + name="project-issue-comment", + ), + path( + "workspaces//projects//issues//comments//", + IssueCommentViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-comment", + ), + ## End IssueComments + # Issue Subscribers + path( + "workspaces//projects//issues//issue-subscribers/", + IssueSubscriberViewSet.as_view({"get": "list", "post": "create"}), + name="project-issue-subscribers", + ), + path( + "workspaces//projects//issues//issue-subscribers//", + IssueSubscriberViewSet.as_view({"delete": "destroy"}), + name="project-issue-subscribers", + ), + path( + "workspaces//projects//issues//subscribe/", + IssueSubscriberViewSet.as_view({"get": "subscription_status", "post": "subscribe", "delete": "unsubscribe"}), + name="project-issue-subscribers", + ), + ## End Issue Subscribers + # Issue Reactions + path( + "workspaces//projects//issues//reactions/", + IssueReactionViewSet.as_view({"get": "list", "post": "create"}), + name="project-issue-reactions", + ), + path( + "workspaces//projects//issues//reactions//", + IssueReactionViewSet.as_view({"delete": "destroy"}), + name="project-issue-reactions", + ), + ## End Issue Reactions + # Comment Reactions + path( + "workspaces//projects//comments//reactions/", + CommentReactionViewSet.as_view({"get": "list", "post": "create"}), + name="project-issue-comment-reactions", + ), + path( + "workspaces//projects//comments//reactions//", + CommentReactionViewSet.as_view({"delete": "destroy"}), + name="project-issue-comment-reactions", + ), + ## End Comment Reactions + ## IssueUserProperty + path( + "workspaces//projects//user-properties/", + IssueUserDisplayPropertyEndpoint.as_view(), + name="project-issue-display-properties", + ), + ## IssueUserProperty End + ## Issue Archives + path( + "workspaces//projects//archived-issues/", + IssueArchiveViewSet.as_view({"get": "list"}), + name="project-issue-archive", + ), + path( + "workspaces//projects//issues//archive/", + IssueArchiveViewSet.as_view({"get": "retrieve", "post": "archive", "delete": "unarchive"}), + name="project-issue-archive-unarchive", + ), + ## End Issue Archives + ## Issue Relation + path( + "workspaces//projects//issues//issue-relation/", + IssueRelationViewSet.as_view({"get": "list", "post": "create"}), + name="issue-relation", + ), + path( + "workspaces//projects//issues//remove-relation/", + IssueRelationViewSet.as_view({"post": "remove_relation"}), + name="issue-relation", + ), + ## End Issue Relation + path( + "workspaces//projects//deleted-issues/", + DeletedIssuesListViewSet.as_view(), + name="deleted-issues", + ), + path( + "workspaces//projects//issue-dates/", + IssueBulkUpdateDateEndpoint.as_view(), + name="project-issue-dates", + ), + path( + "workspaces//projects//issues//versions/", + IssueVersionEndpoint.as_view(), + name="issue-versions", + ), + path( + "workspaces//projects//issues//versions//", + IssueVersionEndpoint.as_view(), + name="issue-versions", + ), + path( + "workspaces//projects//work-items//description-versions/", + WorkItemDescriptionVersionEndpoint.as_view(), + name="work-item-versions", + ), + path( + "workspaces//projects//work-items//description-versions//", + WorkItemDescriptionVersionEndpoint.as_view(), + name="work-item-versions", + ), + path( + "workspaces//projects//issues//meta/", + IssueMetaEndpoint.as_view(), + name="issue-meta", + ), + path( + "workspaces//work-items/-/", + IssueDetailIdentifierEndpoint.as_view(), + name="issue-detail-identifier", + ), +] diff --git a/apps/api/plane/app/urls/module.py b/apps/api/plane/app/urls/module.py new file mode 100644 index 00000000..75cbb14d --- /dev/null +++ b/apps/api/plane/app/urls/module.py @@ -0,0 +1,101 @@ +from django.urls import path + + +from plane.app.views import ( + ModuleViewSet, + ModuleIssueViewSet, + ModuleLinkViewSet, + ModuleFavoriteViewSet, + ModuleUserPropertiesEndpoint, + ModuleArchiveUnarchiveEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//projects//modules/", + ModuleViewSet.as_view({"get": "list", "post": "create"}), + name="project-modules", + ), + path( + "workspaces//projects//modules//", + ModuleViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-modules", + ), + path( + "workspaces//projects//issues//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"}), + name="project-module-issues", + ), + path( + "workspaces//projects//modules//issues//", + ModuleIssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-module-issues", + ), + path( + "workspaces//projects//modules//module-links/", + ModuleLinkViewSet.as_view({"get": "list", "post": "create"}), + name="project-issue-module-links", + ), + path( + "workspaces//projects//modules//module-links//", + ModuleLinkViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-module-links", + ), + path( + "workspaces//projects//user-favorite-modules/", + ModuleFavoriteViewSet.as_view({"get": "list", "post": "create"}), + name="user-favorite-module", + ), + path( + "workspaces//projects//user-favorite-modules//", + ModuleFavoriteViewSet.as_view({"delete": "destroy"}), + name="user-favorite-module", + ), + path( + "workspaces//projects//modules//user-properties/", + ModuleUserPropertiesEndpoint.as_view(), + name="cycle-user-filters", + ), + path( + "workspaces//projects//modules//archive/", + ModuleArchiveUnarchiveEndpoint.as_view(), + name="module-archive-unarchive", + ), + path( + "workspaces//projects//archived-modules/", + ModuleArchiveUnarchiveEndpoint.as_view(), + name="module-archive-unarchive", + ), + path( + "workspaces//projects//archived-modules//", + ModuleArchiveUnarchiveEndpoint.as_view(), + name="module-archive-unarchive", + ), +] diff --git a/apps/api/plane/app/urls/notification.py b/apps/api/plane/app/urls/notification.py new file mode 100644 index 00000000..0c992d49 --- /dev/null +++ b/apps/api/plane/app/urls/notification.py @@ -0,0 +1,48 @@ +from django.urls import path + + +from plane.app.views import ( + NotificationViewSet, + UnreadNotificationEndpoint, + MarkAllReadNotificationViewSet, + UserNotificationPreferenceEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//users/notifications/", + NotificationViewSet.as_view({"get": "list"}), + name="notifications", + ), + path( + "workspaces//users/notifications//", + NotificationViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), + name="notifications", + ), + path( + "workspaces//users/notifications//read/", + NotificationViewSet.as_view({"post": "mark_read", "delete": "mark_unread"}), + name="notifications", + ), + path( + "workspaces//users/notifications//archive/", + NotificationViewSet.as_view({"post": "archive", "delete": "unarchive"}), + name="notifications", + ), + path( + "workspaces//users/notifications/unread/", + UnreadNotificationEndpoint.as_view(), + name="unread-notifications", + ), + path( + "workspaces//users/notifications/mark-all-read/", + MarkAllReadNotificationViewSet.as_view({"post": "create"}), + name="mark-all-read-notifications", + ), + path( + "users/me/notification-preferences/", + UserNotificationPreferenceEndpoint.as_view(), + name="user-notification-preferences", + ), +] diff --git a/apps/api/plane/app/urls/page.py b/apps/api/plane/app/urls/page.py new file mode 100644 index 00000000..8cac22a2 --- /dev/null +++ b/apps/api/plane/app/urls/page.py @@ -0,0 +1,72 @@ +from django.urls import path + + +from plane.app.views import ( + PageViewSet, + PageFavoriteViewSet, + PagesDescriptionViewSet, + PageVersionEndpoint, + PageDuplicateEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//pages-summary/", + PageViewSet.as_view({"get": "summary"}), + name="project-pages-summary", + ), + path( + "workspaces//projects//pages/", + PageViewSet.as_view({"get": "list", "post": "create"}), + name="project-pages", + ), + path( + "workspaces//projects//pages//", + PageViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), + name="project-pages", + ), + # favorite pages + path( + "workspaces//projects//favorite-pages//", + 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"}), + name="project-page-archive-unarchive", + ), + # lock and unlock + path( + "workspaces//projects//pages//lock/", + 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"}), + name="project-pages-access", + ), + path( + "workspaces//projects//pages//description/", + PagesDescriptionViewSet.as_view({"get": "retrieve", "patch": "partial_update"}), + name="page-description", + ), + path( + "workspaces//projects//pages//versions/", + PageVersionEndpoint.as_view(), + name="page-versions", + ), + path( + "workspaces//projects//pages//versions//", + PageVersionEndpoint.as_view(), + name="page-versions", + ), + path( + "workspaces//projects//pages//duplicate/", + PageDuplicateEndpoint.as_view(), + name="page-duplicate", + ), +] diff --git a/apps/api/plane/app/urls/project.py b/apps/api/plane/app/urls/project.py new file mode 100644 index 00000000..61d30f91 --- /dev/null +++ b/apps/api/plane/app/urls/project.py @@ -0,0 +1,128 @@ +from django.urls import path + +from plane.app.views import ( + ProjectViewSet, + DeployBoardViewSet, + ProjectInvitationsViewset, + ProjectMemberViewSet, + ProjectMemberUserEndpoint, + ProjectJoinEndpoint, + ProjectUserViewsEndpoint, + ProjectIdentifierEndpoint, + ProjectFavoritesViewSet, + UserProjectInvitationsViewset, + ProjectPublicCoverImagesEndpoint, + UserProjectRolesEndpoint, + ProjectArchiveUnarchiveEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//projects/", + ProjectViewSet.as_view({"get": "list", "post": "create"}), + name="project", + ), + path( + "workspaces//projects/details/", + ProjectViewSet.as_view({"get": "list_detail"}), + name="project", + ), + path( + "workspaces//projects//", + ProjectViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project", + ), + path( + "workspaces//project-identifiers/", + ProjectIdentifierEndpoint.as_view(), + name="project-identifiers", + ), + path( + "workspaces//projects//invitations/", + ProjectInvitationsViewset.as_view({"get": "list", "post": "create"}), + name="project-member-invite", + ), + path( + "workspaces//projects//invitations//", + ProjectInvitationsViewset.as_view({"get": "retrieve", "delete": "destroy"}), + name="project-member-invite", + ), + path( + "users/me/workspaces//projects/invitations/", + UserProjectInvitationsViewset.as_view({"get": "list", "post": "create"}), + name="user-project-invitations", + ), + path( + "users/me/workspaces//project-roles/", + UserProjectRolesEndpoint.as_view(), + name="user-project-roles", + ), + path( + "workspaces//projects//join//", + ProjectJoinEndpoint.as_view(), + name="project-join", + ), + path( + "workspaces//projects//members/", + ProjectMemberViewSet.as_view({"get": "list", "post": "create"}), + name="project-member", + ), + path( + "workspaces//projects//members//", + ProjectMemberViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), + name="project-member", + ), + path( + "workspaces//projects//members/leave/", + ProjectMemberViewSet.as_view({"post": "leave"}), + name="project-member", + ), + path( + "workspaces//projects//project-views/", + ProjectUserViewsEndpoint.as_view(), + name="project-view", + ), + path( + "workspaces//projects//project-members/me/", + ProjectMemberUserEndpoint.as_view(), + name="project-member-view", + ), + path( + "workspaces//user-favorite-projects/", + ProjectFavoritesViewSet.as_view({"get": "list", "post": "create"}), + name="project-favorite", + ), + path( + "workspaces//user-favorite-projects//", + ProjectFavoritesViewSet.as_view({"delete": "destroy"}), + name="project-favorite", + ), + path( + "project-covers/", + ProjectPublicCoverImagesEndpoint.as_view(), + name="project-covers", + ), + path( + "workspaces//projects//project-deploy-boards/", + 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"}), + name="project-deploy-board", + ), + path( + "workspaces//projects//archive/", + ProjectArchiveUnarchiveEndpoint.as_view(), + name="project-archive-unarchive", + ), +] diff --git a/apps/api/plane/app/urls/search.py b/apps/api/plane/app/urls/search.py new file mode 100644 index 00000000..0bbbd9cf --- /dev/null +++ b/apps/api/plane/app/urls/search.py @@ -0,0 +1,23 @@ +from django.urls import path + + +from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint + + +urlpatterns = [ + path( + "workspaces//search/", + GlobalSearchEndpoint.as_view(), + name="global-search", + ), + path( + "workspaces//projects//search-issues/", + IssueSearchEndpoint.as_view(), + name="project-issue-search", + ), + path( + "workspaces//entity-search/", + SearchEndpoint.as_view(), + name="entity-search", + ), +] diff --git a/apps/api/plane/app/urls/state.py b/apps/api/plane/app/urls/state.py new file mode 100644 index 00000000..7dcf01d6 --- /dev/null +++ b/apps/api/plane/app/urls/state.py @@ -0,0 +1,23 @@ +from django.urls import path + + +from plane.app.views import StateViewSet + + +urlpatterns = [ + path( + "workspaces//projects//states/", + StateViewSet.as_view({"get": "list", "post": "create"}), + name="project-states", + ), + path( + "workspaces//projects//states//", + StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), + name="project-state", + ), + path( + "workspaces//projects//states//mark-default/", + StateViewSet.as_view({"post": "mark_as_default"}), + name="project-state", + ), +] diff --git a/apps/api/plane/app/urls/timezone.py b/apps/api/plane/app/urls/timezone.py new file mode 100644 index 00000000..ff14d029 --- /dev/null +++ b/apps/api/plane/app/urls/timezone.py @@ -0,0 +1,8 @@ +from django.urls import path + +from plane.app.views import TimezoneEndpoint + +urlpatterns = [ + # timezone endpoint + path("timezones/", TimezoneEndpoint.as_view(), name="timezone-list") +] diff --git a/apps/api/plane/app/urls/user.py b/apps/api/plane/app/urls/user.py new file mode 100644 index 00000000..ef4162c1 --- /dev/null +++ b/apps/api/plane/app/urls/user.py @@ -0,0 +1,71 @@ +from django.urls import path + +from plane.app.views import ( + AccountEndpoint, + ProfileEndpoint, + UpdateUserOnBoardedEndpoint, + UpdateUserTourCompletedEndpoint, + UserActivityEndpoint, + UserActivityGraphEndpoint, + ## User + UserEndpoint, + UserIssueCompletedGraphEndpoint, + UserWorkspaceDashboardEndpoint, + UserSessionEndpoint, + ## End User + ## Workspaces + UserWorkSpacesEndpoint, +) + +urlpatterns = [ + # User Profile + path( + "users/me/", + UserEndpoint.as_view({"get": "retrieve", "patch": "partial_update", "delete": "deactivate"}), + name="users", + ), + path("users/session/", UserSessionEndpoint.as_view(), name="user-session"), + path( + "users/me/settings/", + UserEndpoint.as_view({"get": "retrieve_user_settings"}), + name="users", + ), + # Profile + 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"), + ## End Accounts + path( + "users/me/instance-admin/", + UserEndpoint.as_view({"get": "retrieve_instance_admin"}), + name="users", + ), + path("users/me/onboard/", UpdateUserOnBoardedEndpoint.as_view(), name="user-onboard"), + path( + "users/me/tour-completed/", + UpdateUserTourCompletedEndpoint.as_view(), + name="user-tour", + ), + path("users/me/activities/", UserActivityEndpoint.as_view(), name="user-activities"), + # user workspaces + path("users/me/workspaces/", UserWorkSpacesEndpoint.as_view(), name="user-workspace"), + # User Graphs + path( + "users/me/workspaces//activity-graph/", + UserActivityGraphEndpoint.as_view(), + name="user-activity-graph", + ), + path( + "users/me/workspaces//issues-completed-graph/", + UserIssueCompletedGraphEndpoint.as_view(), + name="completed-graph", + ), + path( + "users/me/workspaces//dashboard/", + UserWorkspaceDashboardEndpoint.as_view(), + name="user-workspace-dashboard", + ), + ## End User Graph +] diff --git a/apps/api/plane/app/urls/views.py b/apps/api/plane/app/urls/views.py new file mode 100644 index 00000000..063e39c3 --- /dev/null +++ b/apps/api/plane/app/urls/views.py @@ -0,0 +1,62 @@ +from django.urls import path + + +from plane.app.views import ( + IssueViewViewSet, + WorkspaceViewViewSet, + WorkspaceViewIssuesViewSet, + IssueViewFavoriteViewSet, +) + + +urlpatterns = [ + path( + "workspaces//projects//views/", + IssueViewViewSet.as_view({"get": "list", "post": "create"}), + name="project-view", + ), + path( + "workspaces//projects//views//", + IssueViewViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-view", + ), + path( + "workspaces//views/", + WorkspaceViewViewSet.as_view({"get": "list", "post": "create"}), + name="global-view", + ), + path( + "workspaces//views//", + WorkspaceViewViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="global-view", + ), + path( + "workspaces//issues/", + WorkspaceViewIssuesViewSet.as_view({"get": "list"}), + name="global-view-issues", + ), + path( + "workspaces//projects//user-favorite-views/", + IssueViewFavoriteViewSet.as_view({"get": "list", "post": "create"}), + name="user-favorite-view", + ), + path( + "workspaces//projects//user-favorite-views//", + IssueViewFavoriteViewSet.as_view({"delete": "destroy"}), + name="user-favorite-view", + ), +] diff --git a/apps/api/plane/app/urls/webhook.py b/apps/api/plane/app/urls/webhook.py new file mode 100644 index 00000000..e21ec726 --- /dev/null +++ b/apps/api/plane/app/urls/webhook.py @@ -0,0 +1,27 @@ +from django.urls import path + +from plane.app.views import ( + WebhookEndpoint, + WebhookLogsEndpoint, + WebhookSecretRegenerateEndpoint, +) + + +urlpatterns = [ + path("workspaces//webhooks/", WebhookEndpoint.as_view(), name="webhooks"), + path( + "workspaces//webhooks//", + WebhookEndpoint.as_view(), + name="webhooks", + ), + path( + "workspaces//webhooks//regenerate/", + WebhookSecretRegenerateEndpoint.as_view(), + name="webhooks", + ), + path( + "workspaces//webhook-logs//", + WebhookLogsEndpoint.as_view(), + name="webhooks", + ), +] diff --git a/apps/api/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py new file mode 100644 index 00000000..016b6808 --- /dev/null +++ b/apps/api/plane/app/urls/workspace.py @@ -0,0 +1,261 @@ +from django.urls import path + + +from plane.app.views import ( + UserWorkspaceInvitationsViewSet, + WorkSpaceViewSet, + WorkspaceJoinEndpoint, + WorkSpaceMemberViewSet, + WorkspaceInvitationsViewset, + WorkspaceMemberUserEndpoint, + WorkspaceMemberUserViewsEndpoint, + WorkSpaceAvailabilityCheckEndpoint, + UserLastProjectWithWorkspaceEndpoint, + WorkspaceThemeViewSet, + WorkspaceUserProfileStatsEndpoint, + WorkspaceUserActivityEndpoint, + WorkspaceUserProfileEndpoint, + WorkspaceUserProfileIssuesEndpoint, + WorkspaceLabelsEndpoint, + WorkspaceProjectMemberEndpoint, + WorkspaceUserPropertiesEndpoint, + WorkspaceStatesEndpoint, + WorkspaceEstimatesEndpoint, + ExportWorkspaceUserActivityEndpoint, + WorkspaceModulesEndpoint, + WorkspaceCyclesEndpoint, + WorkspaceFavoriteEndpoint, + WorkspaceFavoriteGroupEndpoint, + WorkspaceDraftIssueViewSet, + QuickLinkViewSet, + UserRecentVisitViewSet, + WorkspaceHomePreferenceViewSet, + WorkspaceStickyViewSet, + WorkspaceUserPreferenceViewSet, +) + + +urlpatterns = [ + path( + "workspace-slug-check/", + WorkSpaceAvailabilityCheckEndpoint.as_view(), + name="workspace-availability", + ), + path( + "workspaces/", + WorkSpaceViewSet.as_view({"get": "list", "post": "create"}), + name="workspace", + ), + path( + "workspaces//", + WorkSpaceViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="workspace", + ), + path( + "workspaces//invitations/", + WorkspaceInvitationsViewset.as_view({"get": "list", "post": "create"}), + name="workspace-invitations", + ), + path( + "workspaces//invitations//", + WorkspaceInvitationsViewset.as_view({"delete": "destroy", "get": "retrieve", "patch": "partial_update"}), + name="workspace-invitations", + ), + # user workspace invitations + path( + "users/me/workspaces/invitations/", + UserWorkspaceInvitationsViewSet.as_view({"get": "list", "post": "create"}), + name="user-workspace-invitations", + ), + path( + "workspaces//invitations//join/", + WorkspaceJoinEndpoint.as_view(), + name="workspace-join", + ), + # user join workspace + path( + "workspaces//members/", + WorkSpaceMemberViewSet.as_view({"get": "list"}), + name="workspace-member", + ), + path( + "workspaces//project-members/", + WorkspaceProjectMemberEndpoint.as_view(), + name="workspace-member-roles", + ), + path( + "workspaces//members//", + WorkSpaceMemberViewSet.as_view({"patch": "partial_update", "delete": "destroy", "get": "retrieve"}), + name="workspace-member", + ), + path( + "workspaces//members/leave/", + WorkSpaceMemberViewSet.as_view({"post": "leave"}), + name="leave-workspace-members", + ), + path( + "users/last-visited-workspace/", + UserLastProjectWithWorkspaceEndpoint.as_view(), + name="workspace-project-details", + ), + path( + "workspaces//workspace-members/me/", + WorkspaceMemberUserEndpoint.as_view(), + name="workspace-member-details", + ), + path( + "workspaces//workspace-views/", + WorkspaceMemberUserViewsEndpoint.as_view(), + name="workspace-member-views-details", + ), + path( + "workspaces//workspace-themes/", + WorkspaceThemeViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-themes", + ), + path( + "workspaces//workspace-themes//", + WorkspaceThemeViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), + name="workspace-themes", + ), + path( + "workspaces//user-stats//", + WorkspaceUserProfileStatsEndpoint.as_view(), + name="workspace-user-stats", + ), + path( + "workspaces//user-activity//", + WorkspaceUserActivityEndpoint.as_view(), + name="workspace-user-activity", + ), + path( + "workspaces//user-activity//export/", + ExportWorkspaceUserActivityEndpoint.as_view(), + name="export-workspace-user-activity", + ), + path( + "workspaces//user-profile//", + WorkspaceUserProfileEndpoint.as_view(), + name="workspace-user-profile-page", + ), + path( + "workspaces//user-issues//", + WorkspaceUserProfileIssuesEndpoint.as_view(), + name="workspace-user-profile-issues", + ), + path( + "workspaces//labels/", + WorkspaceLabelsEndpoint.as_view(), + name="workspace-labels", + ), + path( + "workspaces//user-properties/", + WorkspaceUserPropertiesEndpoint.as_view(), + name="workspace-user-filters", + ), + path( + "workspaces//states/", + WorkspaceStatesEndpoint.as_view(), + name="workspace-state", + ), + path( + "workspaces//estimates/", + WorkspaceEstimatesEndpoint.as_view(), + name="workspace-estimate", + ), + path( + "workspaces//modules/", + WorkspaceModulesEndpoint.as_view(), + name="workspace-modules", + ), + path( + "workspaces//cycles/", + WorkspaceCyclesEndpoint.as_view(), + name="workspace-cycles", + ), + path( + "workspaces//user-favorites/", + WorkspaceFavoriteEndpoint.as_view(), + name="workspace-user-favorites", + ), + path( + "workspaces//user-favorites//", + WorkspaceFavoriteEndpoint.as_view(), + name="workspace-user-favorites", + ), + path( + "workspaces//user-favorites//group/", + 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", + ), + # quick link + path( + "workspaces//quick-links/", + QuickLinkViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-quick-links", + ), + path( + "workspaces//quick-links//", + QuickLinkViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), + name="workspace-quick-links", + ), + # Widgets + path( + "workspaces//home-preferences/", + WorkspaceHomePreferenceViewSet.as_view(), + name="workspace-home-preference", + ), + path( + "workspaces//home-preferences//", + WorkspaceHomePreferenceViewSet.as_view(), + name="workspace-home-preference", + ), + path( + "workspaces//recent-visits/", + UserRecentVisitViewSet.as_view({"get": "list"}), + name="workspace-recent-visits", + ), + path( + "workspaces//stickies/", + WorkspaceStickyViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-sticky", + ), + path( + "workspaces//stickies//", + WorkspaceStickyViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), + name="workspace-sticky", + ), + # User Preference + path( + "workspaces//sidebar-preferences/", + WorkspaceUserPreferenceViewSet.as_view(), + name="workspace-user-preference", + ), + path( + "workspaces//sidebar-preferences//", + WorkspaceUserPreferenceViewSet.as_view(), + name="workspace-user-preference", + ), +] diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py new file mode 100644 index 00000000..9d81754e --- /dev/null +++ b/apps/api/plane/app/views/__init__.py @@ -0,0 +1,235 @@ +from .project.base import ( + ProjectViewSet, + ProjectIdentifierEndpoint, + ProjectUserViewsEndpoint, + ProjectFavoritesViewSet, + ProjectPublicCoverImagesEndpoint, + DeployBoardViewSet, + ProjectArchiveUnarchiveEndpoint, +) + +from .project.invite import ( + UserProjectInvitationsViewset, + ProjectInvitationsViewset, + ProjectJoinEndpoint, +) + +from .project.member import ( + ProjectMemberViewSet, + ProjectMemberUserEndpoint, + UserProjectRolesEndpoint, +) + +from .user.base import ( + UserEndpoint, + UpdateUserOnBoardedEndpoint, + UpdateUserTourCompletedEndpoint, + UserActivityEndpoint, +) + + +from .base import BaseAPIView, BaseViewSet + +from .workspace.base import ( + WorkSpaceViewSet, + UserWorkSpacesEndpoint, + WorkSpaceAvailabilityCheckEndpoint, + UserWorkspaceDashboardEndpoint, + WorkspaceThemeViewSet, + ExportWorkspaceUserActivityEndpoint, +) + +from .workspace.draft import WorkspaceDraftIssueViewSet + +from .workspace.home import WorkspaceHomePreferenceViewSet + +from .workspace.favorite import ( + WorkspaceFavoriteEndpoint, + WorkspaceFavoriteGroupEndpoint, +) +from .workspace.recent_visit import UserRecentVisitViewSet +from .workspace.user_preference import WorkspaceUserPreferenceViewSet + +from .workspace.member import ( + WorkSpaceMemberViewSet, + WorkspaceMemberUserEndpoint, + WorkspaceProjectMemberEndpoint, + WorkspaceMemberUserViewsEndpoint, +) +from .workspace.invite import ( + WorkspaceInvitationsViewset, + WorkspaceJoinEndpoint, + UserWorkspaceInvitationsViewSet, +) +from .workspace.label import WorkspaceLabelsEndpoint +from .workspace.state import WorkspaceStatesEndpoint +from .workspace.user import ( + UserLastProjectWithWorkspaceEndpoint, + WorkspaceUserProfileIssuesEndpoint, + WorkspaceUserPropertiesEndpoint, + WorkspaceUserProfileEndpoint, + WorkspaceUserActivityEndpoint, + WorkspaceUserProfileStatsEndpoint, + UserActivityGraphEndpoint, + UserIssueCompletedGraphEndpoint, +) +from .workspace.estimate import WorkspaceEstimatesEndpoint +from .workspace.module import WorkspaceModulesEndpoint +from .workspace.cycle import WorkspaceCyclesEndpoint +from .workspace.quick_link import QuickLinkViewSet +from .workspace.sticky import WorkspaceStickyViewSet + +from .state.base import StateViewSet +from .view.base import ( + WorkspaceViewViewSet, + WorkspaceViewIssuesViewSet, + IssueViewViewSet, + IssueViewFavoriteViewSet, +) +from .cycle.base import ( + CycleViewSet, + CycleDateCheckEndpoint, + CycleFavoriteViewSet, + TransferCycleIssueEndpoint, + CycleUserPropertiesEndpoint, + CycleAnalyticsEndpoint, + CycleProgressEndpoint, +) +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, + AssetCheckEndpoint, + WorkspaceAssetDownloadEndpoint, + ProjectAssetDownloadEndpoint, +) +from .issue.base import ( + IssueListEndpoint, + IssueViewSet, + IssueUserDisplayPropertyEndpoint, + BulkDeleteIssuesEndpoint, + DeletedIssuesListViewSet, + IssuePaginatedViewSet, + IssueDetailEndpoint, + IssueBulkUpdateDateEndpoint, + IssueMetaEndpoint, + IssueDetailIdentifierEndpoint, +) + +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.label import LabelViewSet, BulkCreateIssueLabelsEndpoint + +from .issue.link import IssueLinkViewSet + +from .issue.relation import IssueRelationViewSet + +from .issue.reaction import IssueReactionViewSet + +from .issue.sub_issue import SubIssuesEndpoint + +from .issue.subscriber import IssueSubscriberViewSet + +from .issue.version import IssueVersionEndpoint, WorkItemDescriptionVersionEndpoint + +from .module.base import ( + ModuleViewSet, + ModuleLinkViewSet, + ModuleFavoriteViewSet, + ModuleUserPropertiesEndpoint, +) + +from .module.issue import ModuleIssueViewSet + +from .module.archive import ModuleArchiveUnarchiveEndpoint + +from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint + +from .page.base import ( + PageViewSet, + PageFavoriteViewSet, + PagesDescriptionViewSet, + PageDuplicateEndpoint, +) +from .page.version import PageVersionEndpoint + +from .search.base import GlobalSearchEndpoint, SearchEndpoint +from .search.issue import IssueSearchEndpoint + + +from .external.base import ( + GPTIntegrationEndpoint, + UnsplashEndpoint, + WorkspaceGPTIntegrationEndpoint, +) +from .estimate.base import ( + ProjectEstimatePointEndpoint, + BulkEstimatePointEndpoint, + EstimatePointEndpoint, +) + +from .intake.base import ( + IntakeViewSet, + IntakeIssueViewSet, + IntakeWorkItemDescriptionVersionEndpoint, +) + +from .analytic.base import ( + AnalyticsEndpoint, + AnalyticViewViewset, + SavedAnalyticEndpoint, + ExportAnalyticsEndpoint, + DefaultAnalyticsEndpoint, + ProjectStatsEndpoint, +) + +from .analytic.advance import ( + AdvanceAnalyticsEndpoint, + AdvanceAnalyticsStatsEndpoint, + AdvanceAnalyticsChartEndpoint, +) + +from .analytic.project_analytics import ( + ProjectAdvanceAnalyticsEndpoint, + ProjectAdvanceAnalyticsStatsEndpoint, + ProjectAdvanceAnalyticsChartEndpoint, +) + +from .notification.base import ( + NotificationViewSet, + UnreadNotificationEndpoint, + UserNotificationPreferenceEndpoint, +) + +from .exporter.base import ExportIssuesEndpoint + + +from .webhook.base import ( + WebhookEndpoint, + WebhookLogsEndpoint, + WebhookSecretRegenerateEndpoint, +) + +from .error_404 import custom_404_view + +from .notification.base import MarkAllReadNotificationViewSet +from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint + +from .timezone.base import TimezoneEndpoint diff --git a/apps/api/plane/app/views/analytic/advance.py b/apps/api/plane/app/views/analytic/advance.py new file mode 100644 index 00000000..1a5b1b34 --- /dev/null +++ b/apps/api/plane/app/views/analytic/advance.py @@ -0,0 +1,314 @@ +from rest_framework.response import Response +from rest_framework import status +from typing import Dict, List, Any +from django.db.models import QuerySet, Q, Count +from django.http import HttpRequest +from django.db.models.functions import TruncMonth +from django.utils import timezone +from plane.app.views.base import BaseAPIView +from plane.app.permissions import ROLE, allow_permission +from plane.db.models import ( + WorkspaceMember, + Project, + Issue, + Cycle, + Module, + IssueView, + ProjectPage, + Workspace, + ProjectMember, +) +from plane.utils.build_chart import build_analytics_chart +from plane.utils.date_utils import ( + get_analytics_filters, +) + + +class AdvanceAnalyticsBaseView(BaseAPIView): + def initialize_workspace(self, slug: str, type: str) -> None: + self._workspace_slug = slug + self.filters = get_analytics_filters( + slug=slug, + type=type, + user=self.request.user, + date_filter=self.request.GET.get("date_filter", None), + project_ids=self.request.GET.get("project_ids", None), + ) + + +class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): + def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]: + def get_filtered_count() -> int: + if self.filters["analytics_date_range"]: + return queryset.filter( + created_at__gte=self.filters["analytics_date_range"]["current"]["gte"], + created_at__lte=self.filters["analytics_date_range"]["current"]["lte"], + ).count() + return queryset.count() + + def get_previous_count() -> int: + if self.filters["analytics_date_range"] and self.filters["analytics_date_range"].get("previous"): + return queryset.filter( + created_at__gte=self.filters["analytics_date_range"]["previous"]["gte"], + created_at__lte=self.filters["analytics_date_range"]["previous"]["lte"], + ).count() + return 0 + + return { + "count": get_filtered_count(), + # "filter_count": get_previous_count(), + } + + def get_overview_data(self) -> Dict[str, Dict[str, int]]: + members_query = WorkspaceMember.objects.filter( + workspace__slug=self._workspace_slug, is_active=True, member__is_bot=False + ) + + if self.request.GET.get("project_ids", None): + project_ids = self.request.GET.get("project_ids", None) + project_ids = [str(project_id) for project_id in project_ids.split(",")] + members_query = ProjectMember.objects.filter( + project_id__in=project_ids, is_active=True, member__is_bot=False + ) + + return { + "total_users": self.get_filtered_counts(members_query), + "total_admins": self.get_filtered_counts(members_query.filter(role=ROLE.ADMIN.value)), + "total_members": self.get_filtered_counts(members_query.filter(role=ROLE.MEMBER.value)), + "total_guests": self.get_filtered_counts(members_query.filter(role=ROLE.GUEST.value)), + "total_projects": self.get_filtered_counts(Project.objects.filter(**self.filters["project_filters"])), + "total_work_items": self.get_filtered_counts(Issue.issue_objects.filter(**self.filters["base_filters"])), + "total_cycles": self.get_filtered_counts(Cycle.objects.filter(**self.filters["base_filters"])), + "total_intake": self.get_filtered_counts( + Issue.objects.filter(**self.filters["base_filters"]).filter( + issue_intake__status__in=["-2", "-1", "0", "1", "2"] # TODO: Add description for reference. + ) + ), + } + + def get_work_items_stats(self) -> Dict[str, Dict[str, int]]: + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + + return { + "total_work_items": self.get_filtered_counts(base_queryset), + "started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="started")), + "backlog_work_items": self.get_filtered_counts(base_queryset.filter(state__group="backlog")), + "un_started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="unstarted")), + "completed_work_items": self.get_filtered_counts(base_queryset.filter(state__group="completed")), + } + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request: HttpRequest, slug: str) -> Response: + self.initialize_workspace(slug, type="analytics") + tab = request.GET.get("tab", "overview") + + if tab == "overview": + return Response( + self.get_overview_data(), + status=status.HTTP_200_OK, + ) + elif tab == "work-items": + return Response( + self.get_work_items_stats(), + status=status.HTTP_200_OK, + ) + return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST) + + +class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView): + def get_project_issues_stats(self) -> QuerySet: + # Get the base queryset with workspace and project filters + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + base_queryset = base_queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) + + return ( + base_queryset.values("project_id", "project__name") + .annotate( + cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), + completed_work_items=Count("id", filter=Q(state__group="completed")), + backlog_work_items=Count("id", filter=Q(state__group="backlog")), + un_started_work_items=Count("id", filter=Q(state__group="unstarted")), + started_work_items=Count("id", filter=Q(state__group="started")), + ) + .order_by("project_id") + ) + + def get_work_items_stats(self) -> Dict[str, Dict[str, int]]: + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + return ( + base_queryset.values("project_id", "project__name") + .annotate( + cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), + completed_work_items=Count("id", filter=Q(state__group="completed")), + backlog_work_items=Count("id", filter=Q(state__group="backlog")), + un_started_work_items=Count("id", filter=Q(state__group="unstarted")), + started_work_items=Count("id", filter=Q(state__group="started")), + ) + .order_by("project_id") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request: HttpRequest, slug: str) -> Response: + self.initialize_workspace(slug, type="chart") + type = request.GET.get("type", "work-items") + + if type == "work-items": + return Response( + self.get_work_items_stats(), + status=status.HTTP_200_OK, + ) + + return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST) + + +class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): + def project_chart(self) -> List[Dict[str, Any]]: + # Get the base queryset with workspace and project filters + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + date_filter = {} + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + date_filter = { + "created_at__date__gte": start_date, + "created_at__date__lte": end_date, + } + + total_work_items = base_queryset.filter(**date_filter).count() + total_cycles = Cycle.objects.filter(**self.filters["base_filters"], **date_filter).count() + total_modules = Module.objects.filter(**self.filters["base_filters"], **date_filter).count() + total_intake = Issue.objects.filter( + issue_intake__isnull=False, **self.filters["base_filters"], **date_filter + ).count() + total_members = WorkspaceMember.objects.filter( + workspace__slug=self._workspace_slug, is_active=True, **date_filter + ).count() + total_pages = ProjectPage.objects.filter(**self.filters["base_filters"], **date_filter).count() + total_views = IssueView.objects.filter(**self.filters["base_filters"], **date_filter).count() + + data = { + "work_items": total_work_items, + "cycles": total_cycles, + "modules": total_modules, + "intake": total_intake, + "members": total_members, + "pages": total_pages, + "views": total_views, + } + + return [ + { + "key": key, + "name": key.replace("_", " ").title(), + "count": value or 0, + } + for key, value in data.items() + ] + + def work_item_completion_chart(self) -> Dict[str, Any]: + # Get the base queryset + queryset = ( + Issue.issue_objects.filter(**self.filters["base_filters"]) + .select_related("workspace", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle") + ) + + workspace = Workspace.objects.get(slug=self._workspace_slug) + start_date = workspace.created_at.date().replace(day=1) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) + + # Annotate by month and count + monthly_stats = ( + queryset.annotate(month=TruncMonth("created_at")) + .values("month") + .annotate( + created_count=Count("id"), + completed_count=Count("id", filter=Q(state__group="completed")), + ) + .order_by("month") + ) + + # Create dictionary of month -> counts + stats_dict = { + stat["month"].strftime("%Y-%m-%d"): { + "created_count": stat["created_count"], + "completed_count": stat["completed_count"], + } + for stat in monthly_stats + } + + # Generate monthly data (ensure months with 0 count are included) + data = [] + # include the current date at the end + end_date = timezone.now().date() + last_month = end_date.replace(day=1) + current_month = start_date + + while current_month <= last_month: + date_str = current_month.strftime("%Y-%m-%d") + stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0}) + data.append( + { + "key": date_str, + "name": date_str, + "count": stats["created_count"], + "completed_issues": stats["completed_count"], + "created_issues": stats["created_count"], + } + ) + # Move to next month + if current_month.month == 12: + current_month = current_month.replace(year=current_month.year + 1, month=1) + else: + current_month = current_month.replace(month=current_month.month + 1) + + schema = { + "completed_issues": "completed_issues", + "created_issues": "created_issues", + } + + return {"data": data, "schema": schema} + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request: HttpRequest, slug: str) -> Response: + self.initialize_workspace(slug, type="chart") + type = request.GET.get("type", "projects") + group_by = request.GET.get("group_by", None) + x_axis = request.GET.get("x_axis", "PRIORITY") + + if type == "projects": + return Response(self.project_chart(), status=status.HTTP_200_OK) + + elif type == "custom-work-items": + queryset = ( + Issue.issue_objects.filter(**self.filters["base_filters"]) + .select_related("workspace", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle") + ) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) + + return Response( + build_analytics_chart(queryset, x_axis, group_by), + status=status.HTTP_200_OK, + ) + + elif type == "work-items": + return Response( + self.work_item_completion_chart(), + status=status.HTTP_200_OK, + ) + + return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/app/views/analytic/base.py b/apps/api/plane/app/views/analytic/base.py new file mode 100644 index 00000000..6e9311a1 --- /dev/null +++ b/apps/api/plane/app/views/analytic/base.py @@ -0,0 +1,478 @@ +# Django imports +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, OuterRef, Func +from django.db import models + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import WorkSpaceAdminPermission +from plane.app.serializers import AnalyticViewSerializer +from plane.app.views.base import BaseAPIView, BaseViewSet +from plane.bgtasks.analytic_plot_export import analytic_export_task +from plane.db.models import ( + AnalyticView, + Issue, + Workspace, + Project, + ProjectMember, + Cycle, + Module, +) + +from plane.utils.analytics_plot import build_graph_plot +from plane.utils.issue_filters import issue_filters +from plane.app.permissions import allow_permission, ROLE + + +class AnalyticsEndpoint(BaseAPIView): + @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) + segment = request.GET.get("segment", False) + + valid_xaxis_segment = [ + "state_id", + "state__group", + "labels__id", + "assignees__id", + "estimate_point__value", + "issue_cycle__cycle_id", + "issue_module__module_id", + "priority", + "start_date", + "target_date", + "created_at", + "completed_at", + ] + + valid_yaxis = ["issue_count", "estimate"] + + # Check for x-axis and y-axis as thery are required parameters + if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis: + return Response( + {"error": "x-axis and y-axis dimensions are required and the values should be valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # If segment is present it cannot be same as x-axis + 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"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Additional filters that need to be applied + filters = issue_filters(request.GET, "GET") + + # Get the issues for the workspace with the additional filters applied + queryset = Issue.issue_objects.filter(workspace__slug=slug, **filters) + + # Get the total issue count + total_issues = queryset.count() + + # Build the graph payload + distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment) + + state_details = {} + if x_axis in ["state_id"] or segment in ["state_id"]: + state_details = ( + Issue.issue_objects.filter(workspace__slug=slug, **filters) + .distinct("state_id") + .order_by("state_id") + .values("state_id", "state__name", "state__color") + ) + + label_details = {} + if x_axis in ["labels__id"] or segment in ["labels__id"]: + label_details = ( + Issue.objects.filter( + workspace__slug=slug, + **filters, + labels__id__isnull=False, + label_issue__deleted_at__isnull=True, + ) + .distinct("labels__id") + .order_by("labels__id") + .values("labels__id", "labels__color", "labels__name") + ) + + assignee_details = {} + 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, + ) + .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_url", + "assignees__display_name", + "assignees__first_name", + "assignees__last_name", + "assignees__id", + ) + ) + + cycle_details = {} + 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") + ) + + module_details = {} + if x_axis in ["issue_module__module_id"] or segment in ["issue_module__module_id"]: + module_details = ( + Issue.issue_objects.filter( + 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") + ) + + return Response( + { + "total": total_issues, + "distribution": distribution, + "extras": { + "state_details": state_details, + "assignee_details": assignee_details, + "label_details": label_details, + "cycle_details": cycle_details, + "module_details": module_details, + }, + }, + status=status.HTTP_200_OK, + ) + + +class AnalyticViewViewset(BaseViewSet): + permission_classes = [WorkSpaceAdminPermission] + model = AnalyticView + serializer_class = AnalyticViewSerializer + + def perform_create(self, serializer): + workspace = Workspace.objects.get(slug=self.kwargs.get("slug")) + serializer.save(workspace_id=workspace.id) + + def get_queryset(self): + return self.filter_queryset(super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))) + + +class SavedAnalyticEndpoint(BaseAPIView): + @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) + + filter = analytic_view.query + queryset = Issue.issue_objects.filter(**filter) + + x_axis = analytic_view.query_dict.get("x_axis", False) + y_axis = analytic_view.query_dict.get("y_axis", False) + + if not x_axis or not y_axis: + return Response( + {"error": "x-axis and y-axis dimensions are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + segment = request.GET.get("segment", False) + distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment) + total_issues = queryset.count() + return Response( + {"total": total_issues, "distribution": distribution}, + status=status.HTTP_200_OK, + ) + + +class ExportAnalyticsEndpoint(BaseAPIView): + @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) + segment = request.data.get("segment", False) + + valid_xaxis_segment = [ + "state_id", + "state__group", + "labels__id", + "assignees__id", + "estimate_point", + "issue_cycle__cycle_id", + "issue_module__module_id", + "priority", + "start_date", + "target_date", + "created_at", + "completed_at", + ] + + valid_yaxis = ["issue_count", "estimate"] + + # Check for x-axis and y-axis as thery are required parameters + if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis: + return Response( + {"error": "x-axis and y-axis dimensions are required and the values should be valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # If segment is present it cannot be same as x-axis + 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"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + analytic_export_task.delay(email=request.user.email, data=request.data, slug=slug) + + return Response( + {"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"}, + status=status.HTTP_200_OK, + ) + + +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) + + total_issues = base_issues.count() + + state_groups = base_issues.annotate(state_group=F("state__group")) + + total_issues_classified = ( + state_groups.values("state_group").annotate(state_count=Count("state_group")).order_by("state_group") + ) + + open_issues_groups = ["backlog", "unstarted", "started"] + open_issues_queryset = state_groups.filter(state__group__in=open_issues_groups) + + open_issues = open_issues_queryset.count() + open_issues_classified = ( + open_issues_queryset.values("state_group") + .annotate(state_count=Count("state_group")) + .order_by("state_group") + ) + + current_year = timezone.now().year + issue_completed_month_wise = ( + base_issues.filter(completed_at__year=current_year) + .annotate(month=ExtractMonth("completed_at")) + .values("month") + .annotate(count=Count("*")) + .order_by("month") + ) + + user_details = [ + "created_by__first_name", + "created_by__last_name", + "created_by__display_name", + "created_by__id", + ] + + most_issue_created_user = ( + 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__display_name", + "assignees__id", + ] + + most_issue_closed_user = ( + 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] + ) + + pending_issue_user = ( + 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"] + total_estimate_sum = base_issues.aggregate(sum=Sum("point"))["sum"] + + return Response( + { + "total_issues": total_issues, + "total_issues_classified": total_issues_classified, + "open_issues": open_issues, + "open_issues_classified": open_issues_classified, + "issue_completed_month_wise": issue_completed_month_wise, + "most_issue_created_user": most_issue_created_user, + "most_issue_closed_user": most_issue_closed_user, + "pending_issue_user": pending_issue_user, + "open_estimate_sum": open_estimate_sum, + "total_estimate_sum": total_estimate_sum, + }, + status=status.HTTP_200_OK, + ) + + +class ProjectStatsEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug): + fields = request.GET.get("fields", "").split(",") + project_ids = request.GET.get("project_ids", "") + + valid_fields = { + "total_issues", + "completed_issues", + "total_members", + "total_cycles", + "total_modules", + } + requested_fields = set(filter(None, fields)) & valid_fields + + if not requested_fields: + requested_fields = valid_fields + + projects = Project.objects.filter(workspace__slug=slug) + if project_ids: + projects = projects.filter(id__in=project_ids.split(",")) + + annotations = {} + if "total_issues" in requested_fields: + annotations["total_issues"] = ( + Issue.issue_objects.filter(project_id=OuterRef("pk")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "completed_issues" in requested_fields: + annotations["completed_issues"] = ( + Issue.issue_objects.filter(project_id=OuterRef("pk"), state__group__in=["completed", "cancelled"]) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "total_cycles" in requested_fields: + annotations["total_cycles"] = ( + Cycle.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "total_modules" in requested_fields: + annotations["total_modules"] = ( + Module.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "total_members" in requested_fields: + annotations["total_members"] = ( + ProjectMember.objects.filter(project_id=OuterRef("id"), member__is_bot=False, is_active=True) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + projects = projects.annotate(**annotations).values("id", *requested_fields) + return Response(projects, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/analytic/project_analytics.py b/apps/api/plane/app/views/analytic/project_analytics.py new file mode 100644 index 00000000..2529900b --- /dev/null +++ b/apps/api/plane/app/views/analytic/project_analytics.py @@ -0,0 +1,363 @@ +from rest_framework.response import Response +from rest_framework import status +from typing import Dict, Any +from django.db.models import QuerySet, Q, Count +from django.http import HttpRequest +from django.db.models.functions import TruncMonth +from django.utils import timezone +from datetime import timedelta +from plane.app.views.base import BaseAPIView +from plane.app.permissions import ROLE, allow_permission +from plane.db.models import ( + Project, + Issue, + Cycle, + Module, + CycleIssue, + ModuleIssue, +) +from django.db import models +from django.db.models import F, Case, When, Value +from django.db.models.functions import Concat +from plane.utils.build_chart import build_analytics_chart +from plane.utils.date_utils import ( + get_analytics_filters, +) + + +class ProjectAdvanceAnalyticsBaseView(BaseAPIView): + def initialize_workspace(self, slug: str, type: str) -> None: + self._workspace_slug = slug + self.filters = get_analytics_filters( + slug=slug, + type=type, + user=self.request.user, + date_filter=self.request.GET.get("date_filter", None), + project_ids=self.request.GET.get("project_ids", None), + ) + + +class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView): + def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]: + def get_filtered_count() -> int: + if self.filters["analytics_date_range"]: + return queryset.filter( + created_at__gte=self.filters["analytics_date_range"]["current"]["gte"], + created_at__lte=self.filters["analytics_date_range"]["current"]["lte"], + ).count() + return queryset.count() + + return { + "count": get_filtered_count(), + } + + def get_work_items_stats(self, project_id, cycle_id=None, module_id=None) -> Dict[str, Dict[str, int]]: + """ + Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided. + """ + base_queryset = None + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list( + "issue_id", flat=True + ) + base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) + elif module_id is not None: + module_issues = ModuleIssue.objects.filter(**self.filters["base_filters"], module_id=module_id).values_list( + "issue_id", flat=True + ) + base_queryset = Issue.issue_objects.filter(id__in=module_issues) + else: + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"], project_id=project_id) + + return { + "total_work_items": self.get_filtered_counts(base_queryset), + "started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="started")), + "backlog_work_items": self.get_filtered_counts(base_queryset.filter(state__group="backlog")), + "un_started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="unstarted")), + "completed_work_items": self.get_filtered_counts(base_queryset.filter(state__group="completed")), + } + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def get(self, request: HttpRequest, slug: str, project_id: str) -> Response: + self.initialize_workspace(slug, type="analytics") + + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + return Response( + self.get_work_items_stats(cycle_id=cycle_id, module_id=module_id, project_id=project_id), + status=status.HTTP_200_OK, + ) + + +class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView): + def get_project_issues_stats(self) -> QuerySet: + # Get the base queryset with workspace and project filters + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + base_queryset = base_queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) + + return ( + base_queryset.values("project_id", "project__name") + .annotate( + cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), + completed_work_items=Count("id", filter=Q(state__group="completed")), + backlog_work_items=Count("id", filter=Q(state__group="backlog")), + un_started_work_items=Count("id", filter=Q(state__group="unstarted")), + started_work_items=Count("id", filter=Q(state__group="started")), + ) + .order_by("project_id") + ) + + def get_work_items_stats(self, project_id, cycle_id=None, module_id=None) -> Dict[str, Dict[str, int]]: + base_queryset = None + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list( + "issue_id", flat=True + ) + base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) + elif module_id is not None: + module_issues = ModuleIssue.objects.filter(**self.filters["base_filters"], module_id=module_id).values_list( + "issue_id", flat=True + ) + base_queryset = Issue.issue_objects.filter(id__in=module_issues) + else: + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"], project_id=project_id) + return ( + base_queryset.annotate(display_name=F("assignees__display_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(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") + .annotate( + cancelled_work_items=Count("id", filter=Q(state__group="cancelled"), distinct=True), + completed_work_items=Count("id", filter=Q(state__group="completed"), distinct=True), + backlog_work_items=Count("id", filter=Q(state__group="backlog"), distinct=True), + un_started_work_items=Count("id", filter=Q(state__group="unstarted"), distinct=True), + started_work_items=Count("id", filter=Q(state__group="started"), distinct=True), + ) + .order_by("display_name") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def get(self, request: HttpRequest, slug: str, project_id: str) -> Response: + self.initialize_workspace(slug, type="chart") + type = request.GET.get("type", "work-items") + + if type == "work-items": + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + return Response( + self.get_work_items_stats(project_id=project_id, cycle_id=cycle_id, module_id=module_id), + status=status.HTTP_200_OK, + ) + + return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST) + + +class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): + def work_item_completion_chart(self, project_id, cycle_id=None, module_id=None) -> Dict[str, Any]: + # Get the base queryset + queryset = ( + Issue.issue_objects.filter(**self.filters["base_filters"]) + .filter(project_id=project_id) + .select_related("workspace", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle") + ) + + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list( + "issue_id", flat=True + ) + cycle = Cycle.objects.filter(id=cycle_id).first() + if cycle and cycle.start_date: + start_date = cycle.start_date.date() + end_date = cycle.end_date.date() + else: + return {"data": [], "schema": {}} + queryset = cycle_issues + + elif module_id is not None: + module_issues = ModuleIssue.objects.filter(**self.filters["base_filters"], module_id=module_id).values_list( + "issue_id", flat=True + ) + module = Module.objects.filter(id=module_id).first() + if module and module.start_date: + start_date = module.start_date + end_date = module.target_date + else: + return {"data": [], "schema": {}} + queryset = module_issues + + else: + project = Project.objects.filter(id=project_id).first() + if project.created_at: + start_date = project.created_at.date().replace(day=1) + else: + return {"data": [], "schema": {}} + + if cycle_id or module_id: + # Get daily stats with optimized query + daily_stats = ( + queryset.values("created_at__date") + .annotate( + created_count=Count("id"), + completed_count=Count("id", filter=Q(issue__state__group="completed")), + ) + .order_by("created_at__date") + ) + + # Create a dictionary of existing stats with summed counts + stats_dict = { + stat["created_at__date"].strftime("%Y-%m-%d"): { + "created_count": stat["created_count"], + "completed_count": stat["completed_count"], + } + for stat in daily_stats + } + + # Generate data for all days in the range + data = [] + current_date = start_date + while current_date <= end_date: + date_str = current_date.strftime("%Y-%m-%d") + stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0}) + data.append( + { + "key": date_str, + "name": date_str, + "count": stats["created_count"] + stats["completed_count"], + "completed_issues": stats["completed_count"], + "created_issues": stats["created_count"], + } + ) + current_date += timedelta(days=1) + else: + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) + + # Annotate by month and count + monthly_stats = ( + queryset.annotate(month=TruncMonth("created_at")) + .values("month") + .annotate( + created_count=Count("id"), + completed_count=Count("id", filter=Q(state__group="completed")), + ) + .order_by("month") + ) + + # Create dictionary of month -> counts + stats_dict = { + stat["month"].strftime("%Y-%m-%d"): { + "created_count": stat["created_count"], + "completed_count": stat["completed_count"], + } + for stat in monthly_stats + } + + # Generate monthly data (ensure months with 0 count are included) + data = [] + # include the current date at the end + end_date = timezone.now().date() + last_month = end_date.replace(day=1) + current_month = start_date + + while current_month <= last_month: + date_str = current_month.strftime("%Y-%m-%d") + stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0}) + data.append( + { + "key": date_str, + "name": date_str, + "count": stats["created_count"], + "completed_issues": stats["completed_count"], + "created_issues": stats["created_count"], + } + ) + # Move to next month + if current_month.month == 12: + current_month = current_month.replace(year=current_month.year + 1, month=1) + else: + current_month = current_month.replace(month=current_month.month + 1) + + schema = { + "completed_issues": "completed_issues", + "created_issues": "created_issues", + } + + return {"data": data, "schema": schema} + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request: HttpRequest, slug: str, project_id: str) -> Response: + self.initialize_workspace(slug, type="chart") + type = request.GET.get("type", "projects") + group_by = request.GET.get("group_by", None) + x_axis = request.GET.get("x_axis", "PRIORITY") + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + + if type == "custom-work-items": + queryset = ( + Issue.issue_objects.filter(**self.filters["base_filters"]) + .filter(project_id=project_id) + .select_related("workspace", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle") + ) + + # Apply cycle/module filters if present + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list( + "issue_id", flat=True + ) + queryset = queryset.filter(id__in=cycle_issues) + + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + queryset = queryset.filter(id__in=module_issues) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) + + return Response( + build_analytics_chart(queryset, x_axis, group_by), + status=status.HTTP_200_OK, + ) + + elif type == "work-items": + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + + return Response( + self.work_item_completion_chart(project_id=project_id, cycle_id=cycle_id, module_id=module_id), + status=status.HTTP_200_OK, + ) + + return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/app/views/api.py b/apps/api/plane/app/views/api.py new file mode 100644 index 00000000..41985990 --- /dev/null +++ b/apps/api/plane/app/views/api.py @@ -0,0 +1,84 @@ +# Python import +from uuid import uuid4 +from typing import Optional + +# Third party +from rest_framework.response import Response +from rest_framework.request import Request +from rest_framework import status + +# Module import +from .base import BaseAPIView +from plane.db.models import APIToken, Workspace +from plane.app.serializers import APITokenSerializer, APITokenReadSerializer +from plane.app.permissions import WorkspaceEntityPermission + + +class ApiTokenEndpoint(BaseAPIView): + def post(self, request: Request) -> Response: + label = request.data.get("label", str(uuid4().hex)) + description = request.data.get("description", "") + expired_at = request.data.get("expired_at", None) + + # Check the user type + user_type = 1 if request.user.is_bot else 0 + + api_token = APIToken.objects.create( + label=label, + description=description, + user=request.user, + user_type=user_type, + expired_at=expired_at, + ) + + serializer = APITokenSerializer(api_token) + # Token will be only visible while creating + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request: Request, pk: Optional[str] = None) -> Response: + if pk is None: + api_tokens = APIToken.objects.filter(user=request.user, is_service=False) + serializer = APITokenReadSerializer(api_tokens, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + api_tokens = APIToken.objects.get(user=request.user, pk=pk) + serializer = APITokenReadSerializer(api_tokens) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request: Request, pk: str) -> Response: + api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False) + api_token.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def patch(self, request: Request, pk: str) -> Response: + api_token = APIToken.objects.get(user=request.user, pk=pk) + serializer = APITokenSerializer(api_token, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ServiceApiTokenEndpoint(BaseAPIView): + permission_classes = [WorkspaceEntityPermission] + + def post(self, request: Request, slug: str) -> Response: + workspace = Workspace.objects.get(slug=slug) + + api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first() + + if api_token: + 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 + + api_token = APIToken.objects.create( + label=str(uuid4().hex), + description="Service Token", + user=request.user, + workspace=workspace, + user_type=user_type, + is_service=True, + ) + return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED) diff --git a/apps/api/plane/app/views/asset/base.py b/apps/api/plane/app/views/asset/base.py new file mode 100644 index 00000000..522d4af7 --- /dev/null +++ b/apps/api/plane/app/views/asset/base.py @@ -0,0 +1,82 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser + +# Module imports +from ..base import BaseAPIView, BaseViewSet +from plane.db.models import FileAsset, Workspace +from plane.app.serializers import FileAssetSerializer + + +class FileAssetEndpoint(BaseAPIView): + parser_classes = (MultiPartParser, FormParser, JSONParser) + + """ + A viewset for viewing and editing task instances. + """ + + def get(self, request, workspace_id, asset_key): + asset_key = str(workspace_id) + "/" + asset_key + files = FileAsset.objects.filter(asset=asset_key) + if files.exists(): + serializer = FileAssetSerializer(files, context={"request": request}, many=True) + return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK) + else: + return Response( + {"error": "Asset key does not exist", "status": False}, + status=status.HTTP_200_OK, + ) + + def post(self, request, slug): + serializer = FileAssetSerializer(data=request.data) + if serializer.is_valid(): + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + serializer.save(workspace_id=workspace.id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, workspace_id, asset_key): + asset_key = str(workspace_id) + "/" + asset_key + file_asset = FileAsset.objects.get(asset=asset_key) + file_asset.is_deleted = True + file_asset.save(update_fields=["is_deleted"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class FileAssetViewSet(BaseViewSet): + def restore(self, request, workspace_id, asset_key): + asset_key = str(workspace_id) + "/" + asset_key + file_asset = FileAsset.objects.get(asset=asset_key) + file_asset.is_deleted = False + file_asset.save(update_fields=["is_deleted"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class UserAssetsEndpoint(BaseAPIView): + parser_classes = (MultiPartParser, FormParser) + + def get(self, request, asset_key): + files = FileAsset.objects.filter(asset=asset_key, created_by=request.user) + if files.exists(): + serializer = FileAssetSerializer(files, context={"request": request}) + return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK) + else: + return Response( + {"error": "Asset key does not exist", "status": False}, + status=status.HTTP_200_OK, + ) + + def post(self, request): + serializer = FileAssetSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, asset_key): + file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user) + file_asset.is_deleted = True + file_asset.save(update_fields=["is_deleted"]) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py new file mode 100644 index 00000000..610c5335 --- /dev/null +++ b/apps/api/plane/app/views/asset/v2.py @@ -0,0 +1,747 @@ +# Python imports +import uuid + +# Django imports +from django.conf import settings +from django.http import HttpResponseRedirect +from django.utils import timezone +from django.db import IntegrityError + +# 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", + "image/gif", + ] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF 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, PNG, WebP, JPG and GIF 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, + disposition="attachment", + filename=asset.attributes.get("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, PNG, WebP, JPG and GIF 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, + disposition="attachment", + filename=asset.attributes.get("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: + # For some cases, the bulk api is called after the issue is deleted creating + # an integrity error + try: + assets.update(issue_id=entity_id, project_id=project_id) + except IntegrityError: + pass + + if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: + # For some cases, the bulk api is called after the comment is deleted + # creating an integrity error + try: + assets.update(comment_id=entity_id) + except IntegrityError: + pass + + if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: + assets.update(page_id=entity_id) + + if asset.entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: + # For some cases, the bulk api is called after the draft issue is deleted + # creating an integrity error + try: + assets.update(draft_issue_id=entity_id) + except IntegrityError: + pass + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class AssetCheckEndpoint(BaseAPIView): + """Endpoint to check if an asset exists.""" + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug, asset_id): + asset = FileAsset.all_objects.filter(id=asset_id, workspace__slug=slug, deleted_at__isnull=True).exists() + return Response({"exists": asset}, status=status.HTTP_200_OK) + + +class WorkspaceAssetDownloadEndpoint(BaseAPIView): + """Endpoint to generate a download link for an asset with content-disposition=attachment.""" + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug, asset_id): + try: + asset = FileAsset.objects.get( + id=asset_id, + workspace__slug=slug, + is_uploaded=True, + ) + except FileAsset.DoesNotExist: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + storage = S3Storage(request=request) + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="attachment", + filename=asset.attributes.get("name", uuid.uuid4().hex), + ) + + return HttpResponseRedirect(signed_url) + + +class ProjectAssetDownloadEndpoint(BaseAPIView): + """Endpoint to generate a download link for an asset with content-disposition=attachment.""" + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT") + def get(self, request, slug, project_id, asset_id): + try: + asset = FileAsset.objects.get( + id=asset_id, + workspace__slug=slug, + project_id=project_id, + is_uploaded=True, + ) + except FileAsset.DoesNotExist: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + storage = S3Storage(request=request) + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="attachment", + filename=asset.attributes.get("name", uuid.uuid4().hex), + ) + + return HttpResponseRedirect(signed_url) diff --git a/apps/api/plane/app/views/base.py b/apps/api/plane/app/views/base.py new file mode 100644 index 00000000..0323302c --- /dev/null +++ b/apps/api/plane/app/views/base.py @@ -0,0 +1,232 @@ +# Python imports +import traceback + +import zoneinfo +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import IntegrityError + +# Django imports +from django.urls import resolve +from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend + +# Third part imports +from rest_framework import status +from rest_framework.exceptions import APIException +from rest_framework.filters import SearchFilter +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import ModelViewSet + +# Module imports +from plane.authentication.session import BaseSessionAuthentication +from plane.utils.exception_logger import log_exception +from plane.utils.paginator import BasePaginator +from plane.utils.core.mixins import ReadReplicaControlMixin + + +class TimezoneMixin: + """ + This enables timezone conversion according + to the user set timezone + """ + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if request.user.is_authenticated: + timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone)) + else: + timezone.deactivate() + + +class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePaginator): + model = None + + permission_classes = [IsAuthenticated] + + filter_backends = (DjangoFilterBackend, SearchFilter) + + authentication_classes = [BaseSessionAuthentication] + + filterset_fields = [] + + search_fields = [] + + use_read_replica = False + + def get_queryset(self): + try: + return self.model.objects.all() + except Exception as e: + log_exception(e) + raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + (print(e, traceback.format_exc()) if settings.DEBUG else print("Server Error")) + if isinstance(e, IntegrityError): + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + log_exception(e) + return Response( + {"error": "The required key does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + log_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") + + return response + except Exception as exc: + response = self.handle_exception(exc) + return exc + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def project_id(self): + project_id = self.kwargs.get("project_id", None) + if project_id: + return project_id + + if resolve(self.request.path_info).url_name == "project": + return self.kwargs.get("pk", None) + + @property + def fields(self): + fields = [field for field in self.request.GET.get("fields", "").split(",") if field] + return fields if fields else None + + @property + def expand(self): + expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] + return expand if expand else None + + +class BaseAPIView(TimezoneMixin, ReadReplicaControlMixin, APIView, BasePaginator): + permission_classes = [IsAuthenticated] + + filter_backends = (DjangoFilterBackend, SearchFilter) + + authentication_classes = [BaseSessionAuthentication] + + filterset_fields = [] + + search_fields = [] + + use_read_replica = False + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + return Response( + {"error": "The required key does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + log_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") + return response + + except Exception as exc: + response = self.handle_exception(exc) + return exc + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def project_id(self): + return self.kwargs.get("project_id", None) + + @property + def fields(self): + fields = [field for field in self.request.GET.get("fields", "").split(",") if field] + return fields if fields else None + + @property + def expand(self): + expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] + return expand if expand else None diff --git a/apps/api/plane/app/views/cycle/archive.py b/apps/api/plane/app/views/cycle/archive.py new file mode 100644 index 00000000..a2f89d53 --- /dev/null +++ b/apps/api/plane/app/views/cycle/archive.py @@ -0,0 +1,607 @@ +# 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, + Count, + Exists, + F, + Func, + OuterRef, + Prefetch, + Q, + UUIDField, + Value, + When, + Subquery, + Sum, + FloatField, +) +from django.db.models.functions import Coalesce, Cast, Concat +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from plane.app.permissions import allow_permission, ROLE +from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project +from plane.utils.analytics_plot import burndown_plot + +# Module imports +from .. import BaseAPIView + + +class CycleArchiveUnarchiveEndpoint(BaseAPIView): + def get_queryset(self): + favorite_subquery = UserFavorite.objects.filter( + user=self.request.user, + entity_type="cycle", + entity_identifier=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + backlog_estimate_point = ( + Issue.issue_objects.filter( + 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()))) + .values("backlog_estimate_point")[:1] + ) + unstarted_estimate_point = ( + Issue.issue_objects.filter( + 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(unstarted_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) + .values("unstarted_estimate_point")[:1] + ) + started_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="started", + issue_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()))) + .values("started_estimate_point")[:1] + ) + cancelled_estimate_point = ( + Issue.issue_objects.filter( + 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(cancelled_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) + .values("cancelled_estimate_point")[:1] + ) + completed_estimate_point = ( + Issue.issue_objects.filter( + 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(completed_estimate_points=Sum(Cast("estimate_point__value", FloatField()))) + .values("completed_estimate_points")[:1] + ) + total_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + issue_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()))) + .values("total_estimate_points")[:1] + ) + return ( + Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(archived_at__isnull=False) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("project", "workspace", "owned_by") + .prefetch_related( + Prefetch( + "issue_cycle__issue__assignees", + queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle__issue__labels", + queryset=Label.objects.only("name", "color", "id").distinct(), + ) + ) + .annotate(is_favorite=Exists(favorite_subquery)) + .annotate( + total_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + 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, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + 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, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + 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, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + 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, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + 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, + ), + ) + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + 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), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .annotate( + assignee_ids=Coalesce( + ArrayAgg( + "issue_cycle__issue__assignees__id", + distinct=True, + filter=~Q(issue_cycle__issue__assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .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())) + ) + .order_by("-is_favorite", "name") + .distinct() + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def get(self, request, slug, project_id, pk=None): + if pk is None: + queryset = ( + self.get_queryset().values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "total_issues", + "is_favorite", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + "archived_at", + ) + ).order_by("-is_favorite", "-created_at") + return Response(queryset, status=status.HTTP_200_OK) + else: + queryset = self.get_queryset().filter(archived_at__isnull=False).filter(pk=pk) + data = ( + self.get_queryset() + .filter(pk=pk) + .annotate( + sub_issues=Issue.issue_objects.filter( + 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")) + .values("count") + ) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + "sub_issues", + "logo_props", + # meta fields + "completed_estimate_points", + "total_estimate_points", + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + "created_by", + "archived_at", + ) + .first() + ) + queryset = queryset.first() + + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data["estimate_distribution"] = {} + if estimate_type: + 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_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()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=pk, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + 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, + ) + + # 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_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_url", + "display_name", + ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + # Label Distribution + label_distribution = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=pk, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if queryset.start_date and queryset.end_date: + data["distribution"]["completion_chart"] = burndown_plot( + queryset=queryset, + slug=slug, + project_id=project_id, + plot_type="issues", + cycle_id=pk, + ) + + return Response(data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def post(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug) + + if cycle.end_date >= timezone.now(): + return Response( + {"error": "Only completed cycles can be archived"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + cycle.archived_at = timezone.now() + cycle.save() + UserFavorite.objects.filter( + entity_type="cycle", + entity_identifier=cycle_id, + project_id=project_id, + workspace__slug=slug, + ).delete() + return Response({"archived_at": str(cycle.archived_at)}, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def delete(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug) + cycle.archived_at = None + cycle.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/cycle/base.py b/apps/api/plane/app/views/cycle/base.py new file mode 100644 index 00000000..712d7175 --- /dev/null +++ b/apps/api/plane/app/views/cycle/base.py @@ -0,0 +1,1113 @@ +# Python imports +import json +import pytz + + +# Django imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import ( + Case, + CharField, + Count, + Exists, + F, + Func, + OuterRef, + Prefetch, + Q, + UUIDField, + Value, + When, + Sum, + FloatField, +) +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 + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from plane.app.permissions import allow_permission, ROLE +from plane.app.serializers import ( + CycleSerializer, + CycleUserPropertiesSerializer, + CycleWriteSerializer, +) +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import ( + Cycle, + CycleIssue, + UserFavorite, + CycleUserProperties, + Issue, + Label, + User, + Project, + UserRecentVisit, +) +from plane.utils.analytics_plot import burndown_plot +from plane.bgtasks.recent_visited_task import recent_visited_task +from plane.utils.host import base_host +from plane.utils.cycle_transfer_issues import transfer_cycle_issues +from .. import BaseAPIView, BaseViewSet +from plane.bgtasks.webhook_task import model_activity +from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter + + +class CycleViewSet(BaseViewSet): + serializer_class = CycleSerializer + model = Cycle + webhook_event = "cycle" + + def get_queryset(self): + favorite_subquery = UserFavorite.objects.filter( + user=self.request.user, + entity_identifier=OuterRef("pk"), + entity_type="cycle", + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + + project = Project.objects.get(id=self.kwargs.get("project_id")) + + # Fetch project for the specific record or pass project_id dynamically + project_timezone = project.timezone + + # Convert the current time (timezone.now()) to the project's timezone + local_tz = pytz.timezone(project_timezone) + current_time_in_project_tz = timezone.now().astimezone(local_tz) + + # Convert project local time back to UTC for comparison (start_date is stored in UTC) + current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc) + + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("project", "workspace", "owned_by") + .prefetch_related( + Prefetch( + "issue_cycle__issue__assignees", + queryset=User.objects.only( + "avatar_asset", "first_name", "id" + ).distinct(), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle__issue__labels", + queryset=Label.objects.only("name", "color", "id").distinct(), + ) + ) + .annotate(is_favorite=Exists(favorite_subquery)) + .annotate( + total_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + 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, + issue_cycle__issue__deleted_at__isnull=True, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group__in=["cancelled"], + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, + ), + ) + ) + .annotate( + status=Case( + When( + Q(start_date__lte=current_time_in_utc) + & Q(end_date__gte=current_time_in_utc), + then=Value("CURRENT"), + ), + When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")), + When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")), + When( + Q(start_date__isnull=True) & Q(end_date__isnull=True), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .annotate( + assignee_ids=Coalesce( + ArrayAgg( + "issue_cycle__issue__assignees__id", + distinct=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())), + ) + ) + .order_by("-is_favorite", "name") + .distinct() + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def list(self, request, slug, project_id): + queryset = self.get_queryset().filter(archived_at__isnull=True) + cycle_view = request.GET.get("cycle_view", "all") + + # Update the order by + queryset = queryset.order_by("-is_favorite", "-created_at") + + project = Project.objects.get(id=self.kwargs.get("project_id")) + + # Fetch project for the specific record or pass project_id dynamically + project_timezone = project.timezone + + # Convert the current time (timezone.now()) to the project's timezone + local_tz = pytz.timezone(project_timezone) + current_time_in_project_tz = timezone.now().astimezone(local_tz) + + # Convert project local time back to UTC for comparison (start_date is stored in UTC) + current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc) + + # Current Cycle + if cycle_view == "current": + queryset = queryset.filter( + start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc + ) + + data = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + "logo_props", + "is_favorite", + "total_issues", + "completed_issues", + "cancelled_issues", + "assignee_ids", + "status", + "version", + "created_by", + ) + datetime_fields = ["start_date", "end_date"] + data = user_timezone_converter(data, datetime_fields, project_timezone) + + if data: + return Response(data, status=status.HTTP_200_OK) + + data = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + "logo_props", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "assignee_ids", + "status", + "version", + "created_by", + ) + datetime_fields = ["start_date", "end_date"] + data = user_timezone_converter(data, datetime_fields, project_timezone) + return Response(data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id): + if ( + request.data.get("start_date", None) is None + and request.data.get("end_date", None) is None + ) or ( + request.data.get("start_date", None) is not None + and request.data.get("end_date", None) is not None + ): + serializer = CycleWriteSerializer( + data=request.data, context={"project_id": project_id} + ) + if serializer.is_valid(): + serializer.save(project_id=project_id, owned_by=request.user) + cycle = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + "logo_props", + "version", + # meta fields + "is_favorite", + "total_issues", + "completed_issues", + "assignee_ids", + "status", + "created_by", + ) + .first() + ) + + # Fetch the project timezone + project = Project.objects.get(id=self.kwargs.get("project_id")) + project_timezone = project.timezone + + datetime_fields = ["start_date", "end_date"] + cycle = user_timezone_converter( + cycle, datetime_fields, project_timezone + ) + + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(cycle["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + return Response(cycle, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response( + { + "error": "Both start date and end date are either required or are to be null" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def partial_update(self, request, slug, project_id, pk): + queryset = self.get_queryset().filter( + workspace__slug=slug, project_id=project_id, pk=pk + ) + cycle = queryset.first() + if cycle.archived_at: + return Response( + {"error": "Archived cycle cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + current_instance = json.dumps( + CycleSerializer(cycle).data, cls=DjangoJSONEncoder + ) + + request_data = request.data + + 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) + } + else: + return Response( + { + "error": "The Cycle has already been completed so it cannot be edited" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = CycleWriteSerializer( + cycle, data=request.data, partial=True, context={"project_id": project_id} + ) + if serializer.is_valid(): + serializer.save() + cycle = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + "logo_props", + "version", + # meta fields + "is_favorite", + "total_issues", + "completed_issues", + "assignee_ids", + "status", + "created_by", + ).first() + + # Fetch the project timezone + project = Project.objects.get(id=self.kwargs.get("project_id")) + project_timezone = project.timezone + + datetime_fields = ["start_date", "end_date"] + cycle = user_timezone_converter(cycle, datetime_fields, project_timezone) + + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(cycle["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + + return Response(cycle, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @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) + data = ( + self.get_queryset() + .filter(pk=pk) + .filter(archived_at__isnull=True) + .annotate( + sub_issues=Issue.issue_objects.filter( + 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")) + .values("count") + ) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + "sub_issues", + "logo_props", + "version", + # meta fields + "is_favorite", + "total_issues", + "completed_issues", + "assignee_ids", + "status", + "created_by", + ) + .first() + ) + + if data is None: + return Response( + {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND + ) + + queryset = queryset.first() + # Fetch the project timezone + project = Project.objects.get(id=self.kwargs.get("project_id")) + project_timezone = project.timezone + datetime_fields = ["start_date", "end_date"] + data = user_timezone_converter(data, datetime_fields, project_timezone) + + recent_visited_task.delay( + slug=slug, + entity_name="cycle", + entity_identifier=pk, + user_id=request.user.id, + project_id=project_id, + ) + 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_issues = list( + CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( + "issue", flat=True + ) + ) + + issue_activity.delay( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(pk), + "cycle_name": str(cycle.name), + "issues": [str(issue_id) for issue_id in cycle_issues], + } + ), + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + # TODO: Soft delete the cycle break the onetoone relationship with cycle issue + cycle.delete() + + # Delete the user favorite cycle + UserFavorite.objects.filter( + user=request.user, + entity_type="cycle", + entity_identifier=pk, + project_id=project_id, + ).delete() + # Delete the cycle from recent visits + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="cycle", + ).delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CycleDateCheckEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def post(self, request, slug, project_id): + start_date = request.data.get("start_date", False) + end_date = request.data.get("end_date", False) + cycle_id = request.data.get("cycle_id") + if not start_date or not end_date: + return Response( + {"error": "Start date and end date both are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + start_date = convert_to_utc( + date=str(start_date), project_id=project_id, is_start_date=True + ) + end_date = convert_to_utc( + date=str(end_date), + project_id=project_id, + ) + + # Check if any cycle intersects in the given interval + cycles = Cycle.objects.filter( + Q(workspace__slug=slug) + & Q(project_id=project_id) + & ( + Q(start_date__lte=start_date, end_date__gte=start_date) + | Q(start_date__lte=end_date, end_date__gte=end_date) + | Q(start_date__gte=start_date, end_date__lte=end_date) + ) + ).exclude(pk=cycle_id) + if cycles.exists(): + return Response( + { + "error": "You have a cycle already on the given dates, if you want to create a draft cycle you can do that by removing dates", # noqa: E501 + "status": False, + } + ) + else: + return Response({"status": True}, status=status.HTTP_200_OK) + + +class CycleFavoriteViewSet(BaseViewSet): + model = UserFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("cycle", "cycle__owned_by") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id): + _ = UserFavorite.objects.create( + project_id=project_id, + user=request.user, + entity_type="cycle", + entity_identifier=request.data.get("cycle"), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, cycle_id): + cycle_favorite = UserFavorite.objects.get( + project=project_id, + entity_type="cycle", + user=request.user, + workspace__slug=slug, + entity_identifier=cycle_id, + ) + cycle_favorite.delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TransferCycleIssueEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def post(self, request, slug, project_id, cycle_id): + new_cycle_id = request.data.get("new_cycle_id", False) + + if not new_cycle_id: + return Response( + {"error": "New Cycle Id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Transfer cycle issues and create progress snapshot + result = transfer_cycle_issues( + slug=slug, + project_id=project_id, + cycle_id=cycle_id, + new_cycle_id=new_cycle_id, + request=request, + user_id=request.user.id, + ) + + # Handle error response + if result.get("error"): + return Response( + {"error": result["error"]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response({"message": "Success"}, status=status.HTTP_200_OK) + + +class CycleUserPropertiesEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def patch(self, request, slug, project_id, cycle_id): + cycle_properties = CycleUserProperties.objects.get( + user=request.user, + cycle_id=cycle_id, + project_id=project_id, + workspace__slug=slug, + ) + + cycle_properties.filters = request.data.get("filters", cycle_properties.filters) + cycle_properties.rich_filters = request.data.get( + "rich_filters", cycle_properties.rich_filters + ) + cycle_properties.display_filters = request.data.get( + "display_filters", cycle_properties.display_filters + ) + cycle_properties.display_properties = request.data.get( + "display_properties", cycle_properties.display_properties + ) + cycle_properties.save() + + serializer = CycleUserPropertiesSerializer(cycle_properties) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, cycle_id): + cycle_properties, _ = CycleUserProperties.objects.get_or_create( + user=request.user, + project_id=project_id, + cycle_id=cycle_id, + workspace__slug=slug, + ) + serializer = CycleUserPropertiesSerializer(cycle_properties) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class CycleProgressEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, id=cycle_id + ).first() + if not cycle: + return Response( + {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND + ) + aggregate_estimates = ( + 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())) + .aggregate( + backlog_estimate_point=Sum( + Case( + When(state__group="backlog", then="value_as_float"), + default=Value(0), + output_field=FloatField(), + ) + ), + unstarted_estimate_point=Sum( + Case( + When(state__group="unstarted", then="value_as_float"), + default=Value(0), + output_field=FloatField(), + ) + ), + started_estimate_point=Sum( + Case( + When(state__group="started", then="value_as_float"), + default=Value(0), + output_field=FloatField(), + ) + ), + cancelled_estimate_point=Sum( + Case( + When(state__group="cancelled", then="value_as_float"), + default=Value(0), + output_field=FloatField(), + ) + ), + completed_estimate_points=Sum( + Case( + When(state__group="completed", then="value_as_float"), + default=Value(0), + output_field=FloatField(), + ) + ), + total_estimate_points=Sum( + "value_as_float", default=Value(0), output_field=FloatField() + ), + ) + ) + if cycle.progress_snapshot: + backlog_issues = cycle.progress_snapshot.get("backlog_issues", 0) + unstarted_issues = cycle.progress_snapshot.get("unstarted_issues", 0) + started_issues = cycle.progress_snapshot.get("started_issues", 0) + cancelled_issues = cycle.progress_snapshot.get("cancelled_issues", 0) + completed_issues = cycle.progress_snapshot.get("completed_issues", 0) + total_issues = cycle.progress_snapshot.get("total_issues", 0) + else: + 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", + ).count() + + 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", + ).count() + + 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", + ).count() + + 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", + ).count() + + 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", + ).count() + + 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"] + or 0, + "unstarted_estimate_points": aggregate_estimates[ + "unstarted_estimate_point" + ] + or 0, + "started_estimate_points": aggregate_estimates["started_estimate_point"] + or 0, + "cancelled_estimate_points": aggregate_estimates[ + "cancelled_estimate_point" + ] + or 0, + "completed_estimate_points": aggregate_estimates[ + "completed_estimate_points" + ] + or 0, + "total_estimate_points": aggregate_estimates["total_estimate_points"], + "backlog_issues": backlog_issues, + "total_issues": total_issues, + "completed_issues": completed_issues, + "cancelled_issues": cancelled_issues, + "started_issues": started_issues, + "unstarted_issues": unstarted_issues, + }, + status=status.HTTP_200_OK, + ) + + +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 + ) + .annotate( + total_issues=Count( + "issue_cycle__issue__id", + distinct=True, + 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, + ), + ) + ) + .first() + ) + + if not cycle.start_date or not cycle.end_date: + return Response( + {"error": "Cycle has no start or end date"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # this will tell whether the issues were transferred to the new cycle + """ + if the issues were transferred to the new cycle, then the progress_snapshot will be present + return the progress_snapshot data in the analytics for each date + + else issues were not transferred to the new cycle then generate the stats from the cycle issue bridge tables + """ + + if cycle.progress_snapshot: + distribution = cycle.progress_snapshot.get("distribution", {}) + return Response( + { + "labels": distribution.get("labels", []), + "assignees": distribution.get("assignees", []), + "completion_chart": distribution.get("completion_chart", {}), + }, + status=status.HTTP_200_OK, + ) + + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + assignee_distribution = [] + label_distribution = [] + completion_chart = {} + + if analytic_type == "points" and estimate_type: + 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_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()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + 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, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum(Cast("estimate_point__value", FloatField())) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + completion_chart = burndown_plot( + queryset=cycle, + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=cycle_id, + ) + + if analytic_type == "issues": + 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_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( + "assignee_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + project_id=project_id, + workspace__slug=slug, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "label_id", filter=Q(archived_at__isnull=True, is_draft=False) + ) + ) + .annotate( + completed_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + completion_chart = burndown_plot( + queryset=cycle, + slug=slug, + project_id=project_id, + cycle_id=cycle_id, + plot_type="issues", + ) + + return Response( + { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": completion_chart, + }, + status=status.HTTP_200_OK, + ) diff --git a/apps/api/plane/app/views/cycle/issue.py b/apps/api/plane/app/views/cycle/issue.py new file mode 100644 index 00000000..ad3923b1 --- /dev/null +++ b/apps/api/plane/app/views/cycle/issue.py @@ -0,0 +1,320 @@ +# Python imports +import copy +import json + +# Django imports +from django.core import serializers +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 + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + + +# Module imports +from .. import BaseViewSet +from plane.app.serializers import CycleIssueSerializer +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import Cycle, CycleIssue, Issue, FileAsset, IssueLink +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 plane.app.permissions import allow_permission, ROLE +from plane.utils.host import base_host +from plane.utils.filters import ComplexFilterBackend +from plane.utils.filters import IssueFilterSet + + +class CycleIssueViewSet(BaseViewSet): + serializer_class = CycleIssueSerializer + model = CycleIssue + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet + + webhook_event = "cycle_issue" + bulk = True + + filterset_fields = ["issue__labels__id", "issue__assignees__id"] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .filter(cycle_id=self.kwargs.get("cycle_id")) + .select_related("project") + .select_related("workspace") + .select_related("cycle") + .select_related("issue", "issue__state", "issue__project") + .prefetch_related("issue__assignees", "issue__labels") + .distinct() + ) + + def apply_annotations(self, issues): + return ( + issues.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() + .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") + ) + .prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle") + ) + + @method_decorator(gzip_page) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def list(self, request, slug, project_id, cycle_id): + filters = issue_filters(request.query_params, "GET") + issue_queryset = ( + 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) + ) + + # Apply filtering from filterset + issue_queryset = self.filter_queryset(issue_queryset) + + # Apply legacy filters + issue_queryset = issue_queryset.filter(**filters) + + # Total count queryset + total_issue_queryset = copy.deepcopy(issue_queryset) + + # Applying annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) + + order_by_param = request.GET.get("order_by", "-created_at") + # 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, + total_count_queryset=total_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_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, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + total_count_queryset=total_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_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, + ), + ) + else: + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + total_count_queryset=total_issue_queryset, + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id, cycle_id): + issues = request.data.get("issues", []) + + if not issues: + return Response({"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST) + + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id) + + if cycle.end_date is not None and cycle.end_date < timezone.now(): + return Response( + {"error": "The Cycle has already been completed so no new issues can be added"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get all CycleIssues already created + cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=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 + created_records = CycleIssue.objects.bulk_create( + [ + CycleIssue( + project_id=project_id, + workspace_id=cycle.workspace_id, + created_by_id=request.user.id, + updated_by_id=request.user.id, + cycle_id=cycle_id, + issue_id=issue, + ) + for issue in new_issues + ], + batch_size=10, + ) + + # Updated Issues + updated_records = [] + update_cycle_issue_activity = [] + # Iterate over each cycle_issue in cycle_issues + for cycle_issue in cycle_issues: + old_cycle_id = cycle_issue.cycle_id + # Update the cycle_issue's cycle_id + cycle_issue.cycle_id = cycle_id + # Add the modified cycle_issue to the records_to_update list + updated_records.append(cycle_issue) + # Record the update activity + update_cycle_issue_activity.append( + { + "old_cycle_id": str(old_cycle_id), + "new_cycle_id": str(cycle_id), + "issue_id": str(cycle_issue.issue_id), + } + ) + + # Update the cycle issues + CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100) + # Capture Issue Activity + issue_activity.delay( + type="cycle.activity.created", + requested_data=json.dumps({"cycles_list": issues}), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "updated_cycle_issues": update_cycle_issue_activity, + "created_cycle_issues": serializers.serialize("json", created_records), + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + return Response({"message": "success"}, status=status.HTTP_201_CREATED) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, cycle_id, issue_id): + cycle_issue = CycleIssue.objects.filter( + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + cycle_id=cycle_id, + ) + issue_activity.delay( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(self.kwargs.get("cycle_id")), + "issues": [str(issue_id)], + } + ), + actor_id=str(self.request.user.id), + issue_id=str(issue_id), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + cycle_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/error_404.py b/apps/api/plane/app/views/error_404.py new file mode 100644 index 00000000..97c3c59f --- /dev/null +++ b/apps/api/plane/app/views/error_404.py @@ -0,0 +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/apps/api/plane/app/views/estimate/base.py b/apps/api/plane/app/views/estimate/base.py new file mode 100644 index 00000000..f54115a4 --- /dev/null +++ b/apps/api/plane/app/views/estimate/base.py @@ -0,0 +1,243 @@ +import random +import string +import json + +# Django imports +from django.utils import timezone + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from ..base import BaseViewSet, BaseAPIView +from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE +from plane.db.models import Project, Estimate, EstimatePoint, Issue +from plane.app.serializers import ( + EstimateSerializer, + EstimatePointSerializer, + EstimateReadSerializer, +) +from plane.utils.cache import invalidate_cache +from plane.bgtasks.issue_activities_task import issue_activity + + +def generate_random_name(length=10): + letters = string.ascii_lowercase + return "".join(random.choice(letters) for i in range(length)) + + +class ProjectEstimatePointEndpoint(BaseAPIView): + @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: + estimate_points = EstimatePoint.objects.filter( + estimate_id=project.estimate_id, + project_id=project_id, + workspace__slug=slug, + ) + serializer = EstimatePointSerializer(estimate_points, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response([], status=status.HTTP_200_OK) + + +class BulkEstimatePointEndpoint(BaseViewSet): + 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) + .prefetch_related("points") + .select_related("workspace", "project") + ) + serializer = EstimateReadSerializer(estimates, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) + def create(self, request, slug, project_id): + estimate = request.data.get("estimate") + estimate_name = estimate.get("name", generate_random_name()) + estimate_type = estimate.get("type", "categories") + last_used = estimate.get("last_used", False) + estimate = Estimate.objects.create( + name=estimate_name, + project_id=project_id, + last_used=last_used, + type=estimate_type, + ) + + estimate_points = request.data.get("estimate_points", []) + + serializer = EstimatePointSerializer(data=request.data.get("estimate_points"), many=True) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + estimate_points = EstimatePoint.objects.bulk_create( + [ + EstimatePoint( + estimate=estimate, + key=estimate_point.get("key", 0), + value=estimate_point.get("value", ""), + description=estimate_point.get("description", ""), + project_id=project_id, + workspace_id=estimate.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for estimate_point in estimate_points + ], + batch_size=10, + ignore_conflicts=True, + ) + + serializer = EstimateReadSerializer(estimate) + return Response(serializer.data, status=status.HTTP_200_OK) + + def retrieve(self, request, slug, project_id, estimate_id): + estimate = Estimate.objects.get(pk=estimate_id, workspace__slug=slug, project_id=project_id) + serializer = EstimateReadSerializer(estimate) + 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"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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.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], + workspace__slug=slug, + project_id=project_id, + estimate_id=estimate_id, + ) + + updated_estimate_points = [] + for estimate_point in estimate_points: + # Find the data for that estimate point + estimate_point_data = [point for point in estimate_points_data if point.get("id") == str(estimate_point.id)] + if len(estimate_point_data): + estimate_point.value = estimate_point_data[0].get("value", estimate_point.value) + estimate_point.key = estimate_point_data[0].get("key", estimate_point.key) + updated_estimate_points.append(estimate_point) + + EstimatePoint.objects.bulk_update(updated_estimate_points, ["key", "value"], batch_size=10) + + estimate_serializer = EstimateReadSerializer(estimate) + return Response(estimate_serializer.data, status=status.HTTP_200_OK) + + @invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) + def destroy(self, request, slug, project_id, estimate_id): + estimate = Estimate.objects.get(pk=estimate_id, workspace__slug=slug, project_id=project_id) + estimate.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +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 + if not request.data.get("key") or not request.data.get("value"): + return Response( + {"error": "Key and value are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + 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 + ) + 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): + # TODO: add a key validation if the same key already exists + estimate_point = EstimatePoint.objects.get( + pk=estimate_point_id, + estimate_id=estimate_id, + project_id=project_id, + workspace__slug=slug, + ) + serializer = EstimatePointSerializer(estimate_point, data=request.data, partial=True) + if not serializer.is_valid(): + 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): + 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 + ) + # update all the issues with the new estimate + if new_estimate_id: + issues = Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + estimate_point_id=estimate_point_id, + ) + for issue in issues: + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"estimate_point": (str(new_estimate_id) if new_estimate_id else None)}), + actor_id=str(request.user.id), + issue_id=issue.id, + project_id=str(project_id), + current_instance=json.dumps( + {"estimate_point": (str(issue.estimate_point_id) if issue.estimate_point_id else None)} + ), + epoch=int(timezone.now().timestamp()), + ) + issues.update(estimate_point_id=new_estimate_id) + else: + issues = Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + estimate_point_id=estimate_point_id, + ) + for issue in issues: + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"estimate_point": None}), + actor_id=str(request.user.id), + issue_id=issue.id, + project_id=str(project_id), + current_instance=json.dumps( + {"estimate_point": (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() + + # rearrange the estimate points + updated_estimate_points = [] + for estimate_point in estimate_points: + if estimate_point.key > old_estimate_point.key: + estimate_point.key -= 1 + updated_estimate_points.append(estimate_point) + + EstimatePoint.objects.bulk_update(updated_estimate_points, ["key"], batch_size=10) + + old_estimate_point.delete() + + return Response( + EstimatePointSerializer(updated_estimate_points, many=True).data, + status=status.HTTP_200_OK, + ) diff --git a/apps/api/plane/app/views/exporter/base.py b/apps/api/plane/app/views/exporter/base.py new file mode 100644 index 00000000..5f446ff9 --- /dev/null +++ b/apps/api/plane/app/views/exporter/base.py @@ -0,0 +1,80 @@ +# Third Party imports +from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import allow_permission, ROLE +from plane.app.serializers import ExporterHistorySerializer +from plane.bgtasks.export_task import issue_export_task +from plane.db.models import ExporterHistory, Project, Workspace + +# Module imports +from .. import BaseAPIView + + +class ExportIssuesEndpoint(BaseAPIView): + model = ExporterHistory + serializer_class = ExporterHistorySerializer + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def post(self, request, slug): + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + provider = request.data.get("provider", False) + multiple = request.data.get("multiple", False) + project_ids = request.data.get("project", []) + + if provider in ["csv", "xlsx", "json"]: + if not project_ids: + project_ids = Project.objects.filter( + workspace__slug=slug, + project_projectmember__member=request.user, + project_projectmember__is_active=True, + archived_at__isnull=True, + ).values_list("id", flat=True) + project_ids = [str(project_id) for project_id in project_ids] + + exporter = ExporterHistory.objects.create( + workspace=workspace, + project=project_ids, + initiated_by=request.user, + provider=provider, + type="issue_exports", + ) + + issue_export_task.delay( + provider=exporter.provider, + workspace_id=workspace.id, + project_ids=project_ids, + token_id=exporter.token, + multiple=multiple, + slug=slug, + ) + return Response( + {"message": "Once the export is ready you will be able to download it"}, + status=status.HTTP_200_OK, + ) + else: + return Response( + {"error": f"Provider '{provider}' not found."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @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").select_related( + "workspace", "initiated_by" + ) + + if request.GET.get("per_page", False) and request.GET.get("cursor", False): + return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), + request=request, + queryset=exporter_history, + on_results=lambda exporter_history: ExporterHistorySerializer(exporter_history, many=True).data, + ) + else: + return Response( + {"error": "per_page and cursor are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apps/api/plane/app/views/external/base.py b/apps/api/plane/app/views/external/base.py new file mode 100644 index 00000000..2c554bbc --- /dev/null +++ b/apps/api/plane/app/views/external/base.py @@ -0,0 +1,239 @@ +# Python import +import os +from typing import List, Dict, Tuple + +# Third party import +from openai import OpenAI +import requests + +from rest_framework import status +from rest_framework.response import Response + +# Module import +from plane.app.permissions import ROLE, allow_permission +from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer +from plane.db.models import Project, Workspace +from plane.license.utils.instance_value import get_configuration_value +from plane.utils.exception_logger import log_exception + +from ..base import BaseAPIView + + +class LLMProvider: + """Base class for LLM provider configurations""" + + name: str = "" + models: List[str] = [] + default_model: str = "" + + @classmethod + def get_config(cls) -> Dict[str, str | List[str]]: + return { + "name": cls.name, + "models": cls.models, + "default_model": cls.default_model, + } + + +class OpenAIProvider(LLMProvider): + name = "OpenAI" + models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"] + default_model = "gpt-4o-mini" + + +class AnthropicProvider(LLMProvider): + name = "Anthropic" + models = [ + "claude-3-5-sonnet-20240620", + "claude-3-haiku-20240307", + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "claude-2.1", + "claude-2", + "claude-instant-1.2", + "claude-instant-1", + ] + default_model = "claude-3-sonnet-20240229" + + +class GeminiProvider(LLMProvider): + name = "Gemini" + models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"] + default_model = "gemini-pro" + + +SUPPORTED_PROVIDERS = { + "openai": OpenAIProvider, + "anthropic": AnthropicProvider, + "gemini": GeminiProvider, +} + + +def get_llm_config() -> Tuple[str | None, str | None, str | None]: + """ + Helper to get LLM configuration values, returns: + - api_key, model, provider + """ + api_key, provider_key, model = get_configuration_value( + [ + { + "key": "LLM_API_KEY", + "default": os.environ.get("LLM_API_KEY", None), + }, + { + "key": "LLM_PROVIDER", + "default": os.environ.get("LLM_PROVIDER", "openai"), + }, + { + "key": "LLM_MODEL", + "default": os.environ.get("LLM_MODEL", None), + }, + ] + ) + + provider = SUPPORTED_PROVIDERS.get(provider_key.lower()) + if not provider: + log_exception(ValueError(f"Unsupported provider: {provider_key}")) + return None, None, None + + if not api_key: + log_exception(ValueError(f"Missing API key for provider: {provider.name}")) + return None, None, None + + # If no model specified, use provider's default + if not model: + model = provider.default_model + + # Validate model is supported by provider + if model not in provider.models: + log_exception( + ValueError( + f"Model {model} not supported by {provider.name}. Supported models: {', '.join(provider.models)}" + ) + ) + return None, None, None + + return api_key, model, provider_key + + +def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]: + """Helper to get LLM completion response""" + final_text = task + "\n" + prompt + try: + # For Gemini, prepend provider name to model + if provider.lower() == "gemini": + model = f"gemini/{model}" + + client = OpenAI(api_key=api_key) + chat_completion = client.chat.completions.create( + model=model, messages=[{"role": "user", "content": final_text}] + ) + text = chat_completion.choices[0].message.content + return text, None + except Exception as e: + log_exception(e) + error_type = e.__class__.__name__ + if error_type == "AuthenticationError": + return None, f"Invalid API key for {provider}" + elif error_type == "RateLimitError": + return None, f"Rate limit exceeded for {provider}" + else: + return None, f"Error occurred while generating response from {provider}" + + +class GPTIntegrationEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def post(self, request, slug, project_id): + api_key, model, provider = get_llm_config() + + if not api_key or not model or not provider: + return Response( + {"error": "LLM provider API key and model are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + task = request.data.get("task", False) + if not task: + return Response({"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST) + + text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) + if not text and error: + return Response( + {"error": "An internal error has occurred."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + workspace = Workspace.objects.get(slug=slug) + project = Project.objects.get(pk=project_id) + + return Response( + { + "response": text, + "response_html": text.replace("\n", "
    "), + "project_detail": ProjectLiteSerializer(project).data, + "workspace_detail": WorkspaceLiteSerializer(workspace).data, + }, + status=status.HTTP_200_OK, + ) + + +class WorkspaceGPTIntegrationEndpoint(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def post(self, request, slug): + api_key, model, provider = get_llm_config() + + if not api_key or not model or not provider: + return Response( + {"error": "LLM provider API key and model are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + task = request.data.get("task", False) + if not task: + return Response({"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST) + + text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) + if not text and error: + return Response( + {"error": "An internal error has occurred."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + { + "response": text, + "response_html": text.replace("\n", "
    "), + }, + status=status.HTTP_200_OK, + ) + + +class UnsplashEndpoint(BaseAPIView): + def get(self, request): + (UNSPLASH_ACCESS_KEY,) = get_configuration_value( + [ + { + "key": "UNSPLASH_ACCESS_KEY", + "default": os.environ.get("UNSPLASH_ACCESS_KEY"), + } + ] + ) + # Check unsplash access key + if not UNSPLASH_ACCESS_KEY: + return Response([], status=status.HTTP_200_OK) + + # Query parameters + query = request.GET.get("query", False) + page = request.GET.get("page", 1) + per_page = request.GET.get("per_page", 20) + + url = ( + f"https://api.unsplash.com/search/photos/?client_id={UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}" + if query + else f"https://api.unsplash.com/photos/?client_id={UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}" + ) + + headers = {"Content-Type": "application/json"} + + resp = requests.get(url=url, headers=headers) + return Response(resp.json(), status=resp.status_code) diff --git a/apps/api/plane/app/views/intake/base.py b/apps/api/plane/app/views/intake/base.py new file mode 100644 index 00000000..cc637913 --- /dev/null +++ b/apps/api/plane/app/views/intake/base.py @@ -0,0 +1,623 @@ +# Python imports +import json + +# Django import +from django.utils import timezone +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 +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from ..base import BaseViewSet +from plane.app.permissions import allow_permission, ROLE +from plane.db.models import ( + Intake, + IntakeIssue, + Issue, + State, + IssueLink, + FileAsset, + Project, + ProjectMember, + CycleIssue, + IssueDescriptionVersion, + WorkspaceMember, +) +from plane.app.serializers import ( + IssueCreateSerializer, + IssueDetailSerializer, + IntakeSerializer, + IntakeIssueSerializer, + IntakeIssueDetailSerializer, + IssueDescriptionVersionDetailSerializer, +) +from plane.utils.issue_filters import issue_filters +from plane.bgtasks.issue_activities_task import issue_activity +from plane.bgtasks.issue_description_version_task import issue_description_version_task +from plane.app.views.base import BaseAPIView +from plane.utils.timezone_converter import user_timezone_converter +from plane.utils.global_paginator import paginate +from plane.utils.host import base_host +from plane.db.models.intake import SourceType + + +class IntakeViewSet(BaseViewSet): + serializer_class = IntakeSerializer + model = Intake + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + .annotate(pending_issue_count=Count("issue_intake", filter=Q(issue_intake__status=-2))) + .select_related("workspace", "project") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def list(self, request, slug, project_id): + 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): + serializer.save(project_id=self.kwargs.get("project_id")) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, pk): + intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk).first() + # Handle default intake delete + if intake.is_default: + return Response( + {"error": "You cannot delete the default intake"}, + status=status.HTTP_400_BAD_REQUEST, + ) + intake.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IntakeIssueViewSet(BaseViewSet): + serializer_class = IntakeIssueSerializer + model = IntakeIssue + + filterset_fields = ["status"] + + def get_queryset(self): + return ( + Issue.objects.filter( + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .prefetch_related( + Prefetch( + "issue_intake", + queryset=IntakeIssue.objects.only("status", "duplicate_to", "snoozed_till", "source"), + ) + ) + .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() + .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") + ) + .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())), + ), + ) + ).distinct() + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def list(self, request, slug, project_id): + intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() + if not intake: + return Response({"error": "Intake not found"}, status=status.HTTP_404_NOT_FOUND) + + project = Project.objects.get(pk=project_id) + filters = issue_filters(request.GET, "GET", "issue__") + intake_issue = ( + IntakeIssue.objects.filter(intake_id=intake.id, project_id=project_id, **filters) + .select_related("issue") + .prefetch_related("issue__labels") + .annotate( + label_ids=Coalesce( + ArrayAgg( + "issue__labels__id", + distinct=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")) + # Intake status filter + intake_status = [item for item in request.GET.get("status", "-2").split(",") if item != "null"] + if intake_status: + intake_issue = intake_issue.filter(status__in=intake_status) + + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=ROLE.GUEST.value, + is_active=True, + ).exists() + and not project.guest_view_all_features + ): + intake_issue = intake_issue.filter(created_by=request.user) + return self.paginate( + request=request, + queryset=(intake_issue), + on_results=lambda intake_issues: IntakeIssueSerializer(intake_issues, many=True).data, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + 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) + + # Check for valid priority + if request.data.get("issue", {}).get("priority", "none") not in [ + "low", + "medium", + "high", + "urgent", + "none", + ]: + return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST) + + # create an issue + project = Project.objects.get(pk=project_id) + serializer = IssueCreateSerializer( + data=request.data.get("issue"), + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, + ) + if serializer.is_valid(): + serializer.save() + intake_id = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() + # create an intake issue + intake_issue = IntakeIssue.objects.create( + intake_id=intake_id.id, + project_id=project_id, + issue_id=serializer.data["id"], + source=SourceType.IN_APP, + ) + # Create an Issue Activity + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(serializer.data["id"]), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + intake=str(intake_issue.id), + ) + # updated issue description version + issue_description_version_task.delay( + updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder), + issue_id=str(serializer.data["id"]), + user_id=request.user.id, + is_creating=True, + ) + 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( + ~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True) + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "issue__assignees__id", + distinct=True, + filter=~Q(issue__assignees__id__isnull=True) + & Q(issue__assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .get( + intake_id=intake_id.id, + issue_id=serializer.data["id"], + project_id=project_id, + ) + ) + serializer = IntakeIssueDetailSerializer(intake_issue) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + 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): + intake_id = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() + intake_issue = IntakeIssue.objects.get( + issue_id=pk, + workspace__slug=slug, + project_id=project_id, + intake_id=intake_id, + ) + + project_member = ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ).first() + + is_workspace_admin = WorkspaceMember.objects.filter( + workspace__slug=slug, + is_active=True, + member=request.user, + role=ROLE.ADMIN.value, + ).exists() + + if not project_member and not is_workspace_admin: + return Response( + {"error": "Only admin or creator can update the intake work items"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Only project members admins and created_by users can access this endpoint + if ((project_member and project_member.role <= ROLE.GUEST.value) and not is_workspace_admin) and str( + intake_issue.created_by_id + ) != str(request.user.id): + return Response( + {"error": "You cannot edit intake issues"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get issue data + issue_data = request.data.pop("issue", False) + if bool(issue_data): + issue = Issue.objects.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(issue_assignee__deleted_at__isnull=True)), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ).get(pk=intake_issue.issue_id, workspace__slug=slug, project_id=project_id) + + if project_member and project_member.role <= ROLE.GUEST.value: + issue_data = { + "name": issue_data.get("name", issue.name), + "description_html": issue_data.get("description_html", issue.description_html), + "description": issue_data.get("description", issue.description), + } + + current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder) + + issue_serializer = IssueCreateSerializer( + issue, data=issue_data, partial=True, context={"project_id": project_id} + ) + + if issue_serializer.is_valid(): + # Log all the updates + requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder) + if issue is not None: + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + intake=str(intake_issue.id), + ) + # updated issue description version + issue_description_version_task.delay( + updated_issue=current_instance, + issue_id=str(pk), + user_id=request.user.id, + ) + issue_serializer.save() + else: + return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Only project admins can edit intake issue attributes + if (project_member and project_member.role > ROLE.MEMBER.value) or is_workspace_admin: + serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True) + current_instance = json.dumps(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=intake_issue.issue_id, + workspace__slug=slug, + project_id=project_id, + ) + state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first() + if state is not None: + issue.state = state + issue.save() + + # Update the issue state if it is accepted + if serializer.data["status"] in [1]: + issue = Issue.objects.get( + pk=intake_issue.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).first() + if state is not None: + issue.state = state + issue.save() + # create a activity for status change + issue_activity.delay( + 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), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=False, + origin=base_host(request=request, is_app=True), + intake=(intake_issue.id), + ) + + 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( + ~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True) + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "issue__assignees__id", + distinct=True, + filter=Q( + ~Q(issue__assignees__id__isnull=True) + & Q(issue__issue_assignee__deleted_at__isnull=True) + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .get(intake_id=intake_id.id, issue_id=pk, project_id=project_id) + ) + serializer = IntakeIssueDetailSerializer(intake_issue).data + return Response(serializer, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + 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) + def retrieve(self, request, slug, project_id, pk): + intake_id = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() + project = Project.objects.get(pk=project_id) + 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(~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "issue__assignees__id", + distinct=True, + filter=Q( + ~Q(issue__assignees__id__isnull=True) & Q(issue__issue_assignee__deleted_at__isnull=True) + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .get(intake_id=intake_id.id, issue_id=pk, project_id=project_id) + ) + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=ROLE.GUEST.value, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not intake_issue.created_by == request.user + ): + return Response( + {"error": "You are not allowed to view this issue"}, + status=status.HTTP_403_FORBIDDEN, + ) + 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): + intake_id = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() + intake_issue = IntakeIssue.objects.get( + issue_id=pk, + workspace__slug=slug, + project_id=project_id, + intake_id=intake_id, + ) + + # Check the issue status + 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() + + intake_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IntakeWorkItemDescriptionVersionEndpoint(BaseAPIView): + def process_paginated_result(self, fields, results, timezone): + paginated_data = results.values(*fields) + + datetime_fields = ["created_at", "updated_at"] + paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone) + + return paginated_data + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, work_item_id, pk=None): + project = Project.objects.get(pk=project_id) + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=work_item_id) + + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=ROLE.GUEST.value, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not issue.created_by == request.user + ): + return Response( + {"error": "You are not allowed to view this issue"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if pk: + issue_description_version = IssueDescriptionVersion.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=work_item_id, + pk=pk, + ) + + serializer = IssueDescriptionVersionDetailSerializer(issue_description_version) + return Response(serializer.data, status=status.HTTP_200_OK) + + cursor = request.GET.get("cursor", None) + + required_fields = [ + "id", + "workspace", + "project", + "issue", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + issue_description_versions_queryset = IssueDescriptionVersion.objects.filter( + workspace__slug=slug, project_id=project_id, issue_id=work_item_id + ) + + paginated_data = paginate( + base_queryset=issue_description_versions_queryset, + queryset=issue_description_versions_queryset, + cursor=cursor, + on_result=lambda results: self.process_paginated_result( + required_fields, results, request.user.user_timezone + ), + ) + return Response(paginated_data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/issue/activity.py b/apps/api/plane/app/views/issue/activity.py new file mode 100644 index 00000000..fdfcd129 --- /dev/null +++ b/apps/api/plane/app/views/issue/activity.py @@ -0,0 +1,82 @@ +# Python imports +from itertools import chain + +# Django imports +from django.db.models import Prefetch, Q +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import 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, IntakeIssue + + +class IssueActivityEndpoint(BaseAPIView): + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + @method_decorator(gzip_page) + @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: + filters = {"created_at__gt": request.GET.get("created_at__gt")} + + issue_activities = ( + IssueActivity.objects.filter(issue_id=issue_id) + .filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + workspace__slug=slug, + ) + .filter(**filters) + .select_related("actor", "workspace", "issue", "project") + ).order_by("created_at") + issue_comments = ( + IssueComment.objects.filter(issue_id=issue_id) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + workspace__slug=slug, + ) + .filter(**filters) + .order_by("created_at") + .select_related("actor", "issue", "project", "workspace") + .prefetch_related( + Prefetch( + "comment_reactions", + queryset=CommentReaction.objects.select_related("actor"), + ) + ) + ) + + if request.GET.get("activity_type", None) == "issue-property": + issue_activities = issue_activities.prefetch_related( + Prefetch( + "issue__issue_intake", + queryset=IntakeIssue.objects.only("source_email", "source", "extra"), + to_attr="source_data", + ) + ) + issue_activities = IssueActivitySerializer(issue_activities, many=True).data + return Response(issue_activities, status=status.HTTP_200_OK) + + if request.GET.get("activity_type", None) == "issue-comment": + issue_comments = IssueCommentSerializer(issue_comments, many=True).data + return Response(issue_comments, status=status.HTTP_200_OK) + + result_list = sorted( + chain(issue_activities, issue_comments), + key=lambda instance: instance["created_at"], + ) + + return Response(result_list, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/issue/archive.py b/apps/api/plane/app/views/issue/archive.py new file mode 100644 index 00000000..b8f85896 --- /dev/null +++ b/apps/api/plane/app/views/issue/archive.py @@ -0,0 +1,339 @@ +# Python imports +import copy +import json + +# Django imports +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import OuterRef, Q, Prefetch, Exists, Subquery, Count +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 + +from plane.app.permissions import ProjectEntityPermission +from plane.app.serializers import ( + IssueFlatSerializer, + IssueSerializer, + IssueDetailSerializer, +) +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import ( + Issue, + FileAsset, + IssueLink, + IssueSubscriber, + IssueReaction, + CycleIssue, +) +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 plane.app.permissions import allow_permission, ROLE +from plane.utils.error_codes import ERROR_CODES +from plane.utils.host import base_host + +# Module imports +from .. import BaseViewSet, BaseAPIView +from plane.utils.filters import ComplexFilterBackend +from plane.utils.filters import IssueFilterSet + + +class IssueArchiveViewSet(BaseViewSet): + serializer_class = IssueFlatSerializer + model = Issue + + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet + + def apply_annotations(self, issues): + return ( + issues.annotate( + cycle_id=Subquery( + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] + ) + ) + .annotate( + link_count=Subquery( + IssueLink.objects.filter(issue=OuterRef("id")) + .values("issue") + .annotate(count=Count("id")) + .values("count") + ) + ) + .annotate( + attachment_count=Subquery( + FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .values("issue_id") + .annotate(count=Count("id")) + .values("count") + ) + ) + .annotate( + sub_issues_count=Subquery( + Issue.issue_objects.filter(parent=OuterRef("id")) + .values("parent") + .annotate(count=Count("id")) + .values("count") + ) + ) + .prefetch_related("assignees", "labels", "issue_module__module") + ) + + def get_queryset(self): + return ( + Issue.objects.filter(Q(type__isnull=True) | Q(type__is_epic=False)) + .filter(archived_at__isnull=False) + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + ) + + @method_decorator(gzip_page) + @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") + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = self.get_queryset() + + issue_queryset = issue_queryset if show_sub_issues == "true" else issue_queryset.filter(parent__isnull=True) + # Apply filtering from filterset + issue_queryset = self.filter_queryset(issue_queryset) + + # Apply legacy filters + issue_queryset = issue_queryset.filter(**filters) + + # Total count queryset + total_issue_queryset = copy.deepcopy(issue_queryset) + + # Applying annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) + + # 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, + total_count_queryset=total_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_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, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + total_count_queryset=total_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_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, + ), + ) + else: + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + total_count_queryset=total_issue_queryset, + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def retrieve(self, request, slug, project_id, pk=None): + issue = ( + self.get_queryset() + .filter(pk=pk) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("issue", "actor"), + ) + ) + .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) + + @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) + if issue.state.group not in ["completed", "cancelled"]: + return Response( + {"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}), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + issue.archived_at = timezone.now().date() + issue.save() + + return Response({"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def unarchive(self, request, slug, project_id, pk=None): + issue = Issue.objects.get( + workspace__slug=slug, + project_id=project_id, + archived_at__isnull=False, + pk=pk, + ) + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"archived_at": None}), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + issue.archived_at = None + issue.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class BulkArchiveIssuesEndpoint(BaseAPIView): + permission_classes = [ProjectEntityPermission] + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def post(self, request, slug, project_id): + issue_ids = request.data.get("issue_ids", []) + + if not len(issue_ids): + return Response({"error": "Issue IDs are required"}, status=status.HTTP_400_BAD_REQUEST) + + issues = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issue_ids).select_related( + "state" + ) + bulk_archive_issues = [] + for issue in issues: + if issue.state.group not in ["completed", "cancelled"]: + return Response( + { + "error_code": ERROR_CODES["INVALID_ARCHIVE_STATE_GROUP"], + "error_message": "INVALID_ARCHIVE_STATE_GROUP", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"archived_at": str(timezone.now().date()), "automation": False}), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + issue.archived_at = timezone.now().date() + bulk_archive_issues.append(issue) + Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"]) + + return Response({"archived_at": str(timezone.now().date())}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/issue/attachment.py b/apps/api/plane/app/views/issue/attachment.py new file mode 100644 index 00000000..7b7ecf37 --- /dev/null +++ b/apps/api/plane/app/views/issue/attachment.py @@ -0,0 +1,218 @@ +# 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 +from rest_framework import status +from rest_framework.parsers import MultiPartParser, FormParser + +# Module imports +from .. import BaseAPIView +from plane.app.serializers import IssueAttachmentSerializer +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 +from plane.utils.host import base_host + + +class IssueAttachmentEndpoint(BaseAPIView): + serializer_class = IssueAttachmentSerializer + 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, + 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), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + 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=FileAsset) + def delete(self, request, slug, project_id, issue_id, pk): + issue_attachment = FileAsset.objects.get(pk=pk) + issue_attachment.asset.delete(save=False) + issue_attachment.delete() + issue_activity.delay( + type="attachment.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + + 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): + 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=base_host(request=request, is_app=True), + ) + + 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=base_host(request=request, is_app=True), + ) + + # 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/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py new file mode 100644 index 00000000..c24db616 --- /dev/null +++ b/apps/api/plane/app/views/issue/base.py @@ -0,0 +1,1332 @@ +# Python imports +import copy +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 ( + Count, + Exists, + F, + Func, + OuterRef, + Prefetch, + Q, + Subquery, + 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 ROLE, allow_permission +from plane.app.serializers import ( + IssueCreateSerializer, + IssueDetailSerializer, + IssueListDetailSerializer, + IssueSerializer, + IssueUserPropertySerializer, +) +from plane.bgtasks.issue_activities_task import issue_activity +from plane.bgtasks.issue_description_version_task import issue_description_version_task +from plane.bgtasks.recent_visited_task import recent_visited_task +from plane.bgtasks.webhook_task import model_activity +from plane.db.models import ( + CycleIssue, + FileAsset, + IntakeIssue, + Issue, + IssueAssignee, + IssueLabel, + IssueLink, + IssueReaction, + IssueRelation, + IssueSubscriber, + IssueUserProperty, + ModuleIssue, + Project, + ProjectMember, + UserRecentVisit, +) +from plane.utils.filters import ComplexFilterBackend, IssueFilterSet +from plane.utils.global_paginator import paginate +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) +from plane.utils.host import base_host +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.timezone_converter import user_timezone_converter + +from .. import BaseAPIView, BaseViewSet + + +class IssueListEndpoint(BaseAPIView): + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id): + issue_ids = request.GET.get("issues", False) + + if not issue_ids: + return Response({"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST) + + issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""] + + # Base queryset with basic filters + queryset = Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issue_ids) + + # Apply filtering from filterset + queryset = self.filter_queryset(queryset) + + # Apply legacy filters + filters = issue_filters(request.query_params, "GET") + issue_queryset = queryset.filter(**filters) + + # Add select_related, prefetch_related if fields or expand is not None + if self.fields or self.expand: + issue_queryset = issue_queryset.select_related("workspace", "project", "state", "parent").prefetch_related( + "assignees", "labels", "issue_module__module" + ) + + # Add annotations + issue_queryset = ( + issue_queryset.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() + .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") + ) + .distinct() + ) + + order_by_param = request.GET.get("order_by", "-created_at") + # Issue queryset + issue_queryset, _ = 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) + + recent_visited_task.delay( + slug=slug, + project_id=project_id, + entity_name="project", + entity_identifier=project_id, + user_id=request.user.id, + ) + + if self.fields or self.expand: + issues = IssueSerializer(queryset, many=True, fields=self.fields, expand=self.expand).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + "deleted_at", + ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter(issues, datetime_fields, request.user.user_timezone) + return Response(issues, status=status.HTTP_200_OK) + + +class IssueViewSet(BaseViewSet): + model = Issue + webhook_event = "issue" + search_fields = ["name"] + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet + + def get_serializer_class(self): + return IssueCreateSerializer if self.action in ["create", "update", "partial_update"] else IssueSerializer + + def get_queryset(self): + issues = Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ).distinct() + + return issues + + def apply_annotations(self, issues): + issues = ( + issues.annotate( + cycle_id=Subquery( + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] + ) + ) + .annotate( + link_count=Subquery( + IssueLink.objects.filter(issue=OuterRef("id")) + .values("issue") + .annotate(count=Count("id")) + .values("count") + ) + ) + .annotate( + attachment_count=Subquery( + FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .values("issue_id") + .annotate(count=Count("id")) + .values("count") + ) + ) + .annotate( + sub_issues_count=Subquery( + Issue.issue_objects.filter(parent=OuterRef("id")) + .values("parent") + .annotate(count=Count("id")) + .values("count") + ) + ) + ) + + return issues + + @method_decorator(gzip_page) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + 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")} + + project = Project.objects.get(pk=project_id, workspace__slug=slug) + query_params = request.query_params.copy() + + filters = issue_filters(query_params, "GET") + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = self.get_queryset() + + # Apply rich filters + issue_queryset = self.filter_queryset(issue_queryset) + + # Apply legacy filters + issue_queryset = issue_queryset.filter(**filters, **extra_filters) + + # Keeping a copy of the queryset before applying annotations + filtered_issue_queryset = copy.deepcopy(issue_queryset) + + # Applying annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) + + # 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) + + recent_visited_task.delay( + slug=slug, + project_id=project_id, + entity_name="project", + entity_identifier=project_id, + user_id=request.user.id, + ) + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + ): + issue_queryset = issue_queryset.filter(created_by=request.user) + filtered_issue_queryset = filtered_issue_queryset.filter(created_by=request.user) + + if group_by: + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" # noqa: E501 + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + total_count_queryset=filtered_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, + queryset=filtered_issue_queryset, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + queryset=filtered_issue_queryset, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + 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, + ), + ) + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + total_count_queryset=filtered_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, + queryset=filtered_issue_queryset, + ), + group_by_field_name=group_by, + count_filter=Q( + 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, + ), + ) + else: + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + total_count_queryset=filtered_issue_queryset, + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id): + project = Project.objects.get(pk=project_id) + + serializer = IssueCreateSerializer( + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, + ) + + if serializer.is_valid(): + serializer.save() + + # Track the issue + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(serializer.data.get("id", None)), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + queryset = self.get_queryset() + queryset = self.apply_annotations(queryset) + issue = ( + issue_queryset_grouper( + queryset=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", + "deleted_at", + ) + .first() + ) + datetime_fields = ["created_at", "updated_at"] + issue = user_timezone_converter(issue, datetime_fields, request.user.user_timezone) + # Send the model activity + model_activity.delay( + model_name="issue", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + # updated issue description version + issue_description_version_task.delay( + updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder), + issue_id=str(serializer.data["id"]), + user_id=request.user.id, + is_creating=True, + ) + 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, 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 = ( + Issue.objects.filter( + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + pk=pk, + ) + .select_related("state") + .annotate(cycle_id=Subquery(CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[:1])) + .annotate( + link_count=Subquery( + IssueLink.objects.filter(issue=OuterRef("id")) + .values("issue") + .annotate(count=Count("id")) + .values("count") + ) + ) + .annotate( + attachment_count=Subquery( + FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .values("issue_id") + .annotate(count=Count("id")) + .values("count") + ) + ) + .annotate( + sub_issues_count=Subquery( + Issue.issue_objects.filter(parent=OuterRef("id")) + .values("parent") + .annotate(count=Count("id")) + .values("count") + ) + ) + .annotate( + label_ids=Coalesce( + Subquery( + IssueLabel.objects.filter(issue_id=OuterRef("pk")) + .values("issue_id") + .annotate(arr=ArrayAgg("label_id", distinct=True)) + .values("arr") + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + Subquery( + IssueAssignee.objects.filter( + issue_id=OuterRef("pk"), + assignee__member_project__is_active=True, + ) + .values("issue_id") + .annotate(arr=ArrayAgg("assignee_id", distinct=True)) + .values("arr") + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + Subquery( + ModuleIssue.objects.filter( + issue_id=OuterRef("pk"), + module__archived_at__isnull=True, + ) + .values("issue_id") + .annotate(arr=ArrayAgg("module_id", distinct=True)) + .values("arr") + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("issue", "actor"), + ) + ) + .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, + ) + + """ + if the role is guest and guest_view_all_features is false and owned by is not + the requesting user then dont show the issue + """ + + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not issue.created_by == request.user + ): + return Response( + {"error": "You are not allowed to view this issue"}, + status=status.HTTP_403_FORBIDDEN, + ) + + recent_visited_task.delay( + slug=slug, + entity_name="issue", + entity_identifier=pk, + user_id=request.user.id, + project_id=project_id, + ) + + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], creator=True, model=Issue) + def partial_update(self, request, slug, project_id, pk=None): + queryset = self.get_queryset() + queryset = self.apply_annotations(queryset) + issue = ( + queryset.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())), + ), + ) + .filter(pk=pk) + .first() + ) + + if not issue: + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) + + current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder) + + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + serializer = IssueCreateSerializer(issue, data=request.data, partial=True, context={"project_id": project_id}) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + model_activity.delay( + model_name="issue", + model_id=str(serializer.data.get("id", None)), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + # updated issue description version + issue_description_version_task.delay( + updated_issue=current_instance, + issue_id=str(serializer.data.get("id", None)), + user_id=request.user.id, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @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.delete() + # delete the issue from recent visits + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="issue", + ).delete(soft=False) + issue_activity.delay( + type="issue.activity.deleted", + requested_data=json.dumps({"issue_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance={}, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + subscriber=False, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +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) + + issue_property.rich_filters = request.data.get("rich_filters", issue_property.rich_filters) + issue_property.filters = request.data.get("filters", issue_property.filters) + issue_property.display_filters = request.data.get("display_filters", issue_property.display_filters) + issue_property.display_properties = request.data.get("display_properties", issue_property.display_properties) + issue_property.save() + serializer = IssueUserPropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @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) + serializer = IssueUserPropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class BulkDeleteIssuesEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN]) + def delete(self, request, slug, project_id): + issue_ids = request.data.get("issue_ids", []) + + if not len(issue_ids): + return Response({"error": "Issue IDs are required"}, status=status.HTTP_400_BAD_REQUEST) + + issues = Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issue_ids) + + total_issues = len(issues) + + # First, delete all related cycle issues + CycleIssue.objects.filter(issue_id__in=issue_ids).delete() + + # Then, delete all related module issues + ModuleIssue.objects.filter(issue_id__in=issue_ids).delete() + + # Finally, delete the issues themselves + issues.delete() + + return Response( + {"message": f"{total_issues} issues were deleted"}, + status=status.HTTP_200_OK, + ) + + +class DeletedIssuesListViewSet(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id): + filters = {} + 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) + .filter(Q(archived_at__isnull=False) | Q(deleted_at__isnull=False)) + .filter(**filters) + .values_list("id", flat=True) + ) + + return Response(deleted_issues, status=status.HTTP_200_OK) + + +class IssuePaginatedViewSet(BaseViewSet): + def get_queryset(self): + workspace_slug = self.kwargs.get("slug") + project_id = self.kwargs.get("project_id") + + issue_queryset = Issue.issue_objects.filter(workspace__slug=workspace_slug, project_id=project_id) + + return ( + issue_queryset.select_related("state") + .annotate(cycle_id=Subquery(CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[:1])) + .annotate( + link_count=Subquery( + IssueLink.objects.filter(issue=OuterRef("id")) + .values("issue") + .annotate(count=Count("id")) + .values("count") + ) + ) + .annotate( + attachment_count=Subquery( + FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .values("issue_id") + .annotate(count=Count("id")) + .values("count") + ) + ) + .annotate( + sub_issues_count=Subquery( + Issue.issue_objects.filter(parent=OuterRef("id")) + .values("parent") + .annotate(count=Count("id")) + .values("count") + ) + ) + ) + + def process_paginated_result(self, fields, results, timezone): + paginated_data = results.values(*fields) + + # converting the datetime fields in paginated data + datetime_fields = ["created_at", "updated_at"] + paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone) + + return paginated_data + + @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") + updated_at = request.GET.get("updated_at__gt", None) + + # required fields + required_fields = [ + "id", + "name", + "state_id", + "state__group", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "created_at", + "updated_at", + "created_by", + "updated_by", + "is_draft", + "archived_at", + "module_ids", + "label_ids", + "assignee_ids", + "link_count", + "attachment_count", + "sub_issues_count", + ] + + if str(is_description_required).lower() == "true": + required_fields.append("description_html") + + # querying issues + base_queryset = Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id) + + base_queryset = base_queryset.order_by("updated_at") + queryset = self.get_queryset().order_by("updated_at") + + # validation for guest user + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project_member = ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ) + if project_member.exists() and not project.guest_view_all_features: + base_queryset = base_queryset.filter(created_by=request.user) + queryset = queryset.filter(created_by=request.user) + + # filtering issues by greater then updated_at given by the user + if updated_at: + base_queryset = base_queryset.filter(updated_at__gt=updated_at) + queryset = queryset.filter(updated_at__gt=updated_at) + + queryset = queryset.annotate( + label_ids=Coalesce( + Subquery( + IssueLabel.objects.filter(issue_id=OuterRef("pk")) + .values("issue_id") + .annotate(arr=ArrayAgg("label_id", distinct=True)) + .values("arr") + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + Subquery( + IssueAssignee.objects.filter( + issue_id=OuterRef("pk"), + assignee__member_project__is_active=True, + ) + .values("issue_id") + .annotate(arr=ArrayAgg("assignee_id", distinct=True)) + .values("arr") + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + Subquery( + ModuleIssue.objects.filter( + issue_id=OuterRef("pk"), + module__archived_at__isnull=True, + ) + .values("issue_id") + .annotate(arr=ArrayAgg("module_id", distinct=True)) + .values("arr") + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + + paginated_data = paginate( + base_queryset=base_queryset, + queryset=queryset, + cursor=cursor, + on_result=lambda results: self.process_paginated_result( + required_fields, results, request.user.user_timezone + ), + ) + + return Response(paginated_data, status=status.HTTP_200_OK) + + +class IssueDetailEndpoint(BaseAPIView): + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet + + def apply_annotations(self, issues): + return ( + issues.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() + .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") + ) + .prefetch_related( + Prefetch( + "issue_assignee", + queryset=IssueAssignee.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "label_issue", + queryset=IssueLabel.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.all(), + ) + ) + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id): + filters = issue_filters(request.query_params, "GET") + + # 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 + permission_subquery = ( + Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, id=OuterRef("id")) + .filter( + Q( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__project_projectmember__role__gt=ROLE.GUEST.value, + ) + | Q( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__project_projectmember__role=ROLE.GUEST.value, + project__guest_view_all_features=True, + ) + | Q( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__project_projectmember__role=ROLE.GUEST.value, + project__guest_view_all_features=False, + created_by=self.request.user, + ) + ) + .values("id") + ) + # Main issue query + issue = Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id).filter( + Exists(permission_subquery) + ) + + # Add additional prefetch based on expand parameter + if self.expand: + if "issue_relation" in self.expand: + issue = issue.prefetch_related( + Prefetch( + "issue_relation", + queryset=IssueRelation.objects.select_related("related_issue"), + ) + ) + if "issue_related" in self.expand: + issue = issue.prefetch_related( + Prefetch( + "issue_related", + queryset=IssueRelation.objects.select_related("issue"), + ) + ) + + # Apply filtering from filterset + issue = self.filter_queryset(issue) + + # Apply legacy filters + issue = issue.filter(**filters) + + # Total count queryset + total_issue_queryset = copy.deepcopy(issue) + + # Applying annotations to the issue queryset + issue = self.apply_annotations(issue) + + 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, + total_count_queryset=total_issue_queryset, + on_results=lambda issue: IssueListDetailSerializer( + 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. + """ + from datetime import datetime + + start = new_start or current_start + target = new_target or current_target + + # Convert string dates to datetime objects if they're strings + if isinstance(start, str): + start = datetime.strptime(start, "%Y-%m-%d").date() + if isinstance(target, str): + target = datetime.strptime(target, "%Y-%m-%d").date() + + 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) + + +class IssueMetaEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT") + def get(self, request, slug, project_id, issue_id): + issue = Issue.issue_objects.only("sequence_id", "project__identifier").get( + id=issue_id, project_id=project_id, workspace__slug=slug + ) + return Response( + { + "sequence_id": issue.sequence_id, + "project_identifier": issue.project.identifier, + }, + status=status.HTTP_200_OK, + ) + + +class IssueDetailIdentifierEndpoint(BaseAPIView): + def strict_str_to_int(self, s): + if not s.isdigit() and not (s.startswith("-") and s[1:].isdigit()): + raise ValueError("Invalid integer string") + return int(s) + + def get(self, request, slug, project_identifier, issue_identifier): + # Check if the issue identifier is a valid integer + try: + issue_identifier = self.strict_str_to_int(issue_identifier) + except ValueError: + return Response( + {"error": "Invalid issue identifier"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Fetch the project + project = Project.objects.get(identifier__iexact=project_identifier, workspace__slug=slug) + + # Check if the user is a member of the project + if not ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project.id, + member=request.user, + is_active=True, + ).exists(): + return Response( + {"error": "You are not allowed to view this issue"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Fetch the issue + issue = ( + Issue.objects.filter(project_id=project.id) + .filter(workspace__slug=slug) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=Subquery(CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[:1])) + .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(sequence_id=issue_identifier) + .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())), + ), + ) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("issue", "actor"), + ) + ) + .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__sequence_id=issue_identifier, + subscriber=request.user, + ) + ) + ) + .annotate( + is_intake=Exists( + IntakeIssue.objects.filter( + issue=OuterRef("id"), + status__in=[-2, 0], + workspace__slug=slug, + project_id=project.id, + ) + ) + ) + ).first() + + # Check if the issue exists + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + """ + if the role is guest and guest_view_all_features is false and owned by is not + the requesting user then dont show the issue + """ + + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project.id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not issue.created_by == request.user + ): + return Response( + {"error": "You are not allowed to view this issue"}, + status=status.HTTP_403_FORBIDDEN, + ) + + recent_visited_task.delay( + slug=slug, + entity_name="issue", + entity_identifier=str(issue.id), + user_id=str(request.user.id), + project_id=str(project.id), + ) + + # Serialize the issue + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/issue/comment.py b/apps/api/plane/app/views/issue/comment.py new file mode 100644 index 00000000..72a986fe --- /dev/null +++ b/apps/api/plane/app/views/issue/comment.py @@ -0,0 +1,235 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.db.models import Exists +from django.core.serializers.json import DjangoJSONEncoder +from django.db import IntegrityError + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseViewSet +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.bgtasks.issue_activities_task import issue_activity +from plane.utils.host import base_host +from plane.bgtasks.webhook_task import model_activity + + +class IssueCommentViewSet(BaseViewSet): + serializer_class = IssueCommentSerializer + model = IssueComment + webhook_event = "issue_comment" + + filterset_fields = ["issue__id", "workspace__id"] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member_id=self.request.user.id, + is_active=True, + ) + ) + ) + .distinct() + ) + + @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) + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not issue.created_by == request.user + ): + return Response( + {"error": "You are not allowed to comment on the issue"}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer = IssueCommentSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id, issue_id=issue_id, actor=request.user) + issue_activity.delay( + type="comment.activity.created", + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id")), + project_id=str(self.kwargs.get("project_id")), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + # Send the model activity + model_activity.delay( + model_name="issue_comment", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + 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) + def partial_update(self, request, slug, project_id, issue_id, pk): + issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder) + serializer = IssueCommentSerializer(issue_comment, data=request.data, partial=True) + if serializer.is_valid(): + if "comment_html" in request.data and request.data["comment_html"] != issue_comment.comment_html: + serializer.save(edited_at=timezone.now()) + else: + serializer.save() + issue_activity.delay( + type="comment.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + # Send the model activity + model_activity.delay( + model_name="issue_comment", + model_id=str(pk), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + 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) + def destroy(self, request, slug, project_id, issue_id, pk): + issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder) + issue_comment.delete() + issue_activity.delay( + type="comment.activity.deleted", + requested_data=json.dumps({"comment_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CommentReactionViewSet(BaseViewSet): + serializer_class = CommentReactionSerializer + model = CommentReaction + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(comment_id=self.kwargs.get("comment_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .order_by("-created_at") + .distinct() + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def create(self, request, slug, project_id, comment_id): + try: + serializer = CommentReactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, + actor_id=request.user.id, + comment_id=comment_id, + ) + issue_activity.delay( + type="comment_reaction.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=None, + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response( + {"error": "Reaction already exists for the user"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @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, + project_id=project_id, + comment_id=comment_id, + reaction=reaction_code, + actor=request.user, + ) + issue_activity.delay( + type="comment_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(comment_reaction.id), + "comment_id": str(comment_id), + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + comment_reaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/issue/label.py b/apps/api/plane/app/views/issue/label.py new file mode 100644 index 00000000..85e79c01 --- /dev/null +++ b/apps/api/plane/app/views/issue/label.py @@ -0,0 +1,102 @@ +# Python imports +import random + +# Django imports +from django.db import IntegrityError + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +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.utils.cache import invalidate_cache + + +class LabelViewSet(BaseViewSet): + serializer_class = LabelSerializer + model = Label + permission_classes = [ProjectBasePermission] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("parent") + .distinct() + .order_by("sort_order") + ) + + @invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False, multiple=True) + @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) + except IntegrityError: + return Response( + {"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) + @allow_permission([ROLE.ADMIN]) + def partial_update(self, request, *args, **kwargs): + # Check if the label name is unique within the project + if ( + "name" in request.data + and Label.objects.filter(project_id=kwargs["project_id"], name=request.data["name"]) + .exclude(pk=kwargs["pk"]) + .exists() + ): + return Response( + {"error": "Label with the same name already exists in the project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # call the parent method to perform the update + return super().partial_update(request, *args, **kwargs) + + @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) + + +class BulkCreateIssueLabelsEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN]) + def post(self, request, slug, project_id): + label_data = request.data.get("label_data", []) + project = Project.objects.get(pk=project_id) + + labels = Label.objects.bulk_create( + [ + Label( + name=label.get("name", "Migrated"), + description=label.get("description", "Migrated Issue"), + color=f"#{random.randint(0, 0xFFFFFF + 1):06X}", + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for label in label_data + ], + batch_size=50, + ignore_conflicts=True, + ) + + return Response( + {"labels": LabelSerializer(labels, many=True).data}, + status=status.HTTP_201_CREATED, + ) diff --git a/apps/api/plane/app/views/issue/link.py b/apps/api/plane/app/views/issue/link.py new file mode 100644 index 00000000..ee209f9f --- /dev/null +++ b/apps/api/plane/app/views/issue/link.py @@ -0,0 +1,109 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseViewSet +from plane.app.serializers import IssueLinkSerializer +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import IssueLink +from plane.bgtasks.issue_activities_task import issue_activity +from plane.bgtasks.work_item_link_task import crawl_work_item_link_title +from plane.utils.host import base_host + + +class IssueLinkViewSet(BaseViewSet): + permission_classes = [ProjectEntityPermission] + + model = IssueLink + serializer_class = IssueLinkSerializer + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .order_by("-created_at") + .distinct() + ) + + def create(self, request, slug, project_id, issue_id): + serializer = IssueLinkSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id, issue_id=issue_id) + crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url")) + issue_activity.delay( + type="link.activity.created", + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id")), + project_id=str(self.kwargs.get("project_id")), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + + issue_link = self.get_queryset().get(id=serializer.data.get("id")) + serializer = IssueLinkSerializer(issue_link) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, slug, project_id, issue_id, pk): + issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) + current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder) + + serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url")) + + issue_activity.delay( + type="link.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + issue_link = self.get_queryset().get(id=serializer.data.get("id")) + serializer = IssueLinkSerializer(issue_link) + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, issue_id, pk): + issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder) + issue_activity.delay( + type="link.activity.deleted", + requested_data=json.dumps({"link_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + issue_link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/issue/reaction.py b/apps/api/plane/app/views/issue/reaction.py new file mode 100644 index 00000000..fe8a63b1 --- /dev/null +++ b/apps/api/plane/app/views/issue/reaction.py @@ -0,0 +1,81 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseViewSet +from plane.app.serializers import IssueReactionSerializer +from plane.app.permissions import allow_permission, ROLE +from plane.db.models import IssueReaction +from plane.bgtasks.issue_activities_task import issue_activity +from plane.utils.host import base_host + + +class IssueReactionViewSet(BaseViewSet): + serializer_class = IssueReactionSerializer + model = IssueReaction + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .order_by("-created_at") + .distinct() + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def create(self, request, slug, project_id, issue_id): + serializer = IssueReactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(issue_id=issue_id, project_id=project_id, actor=request.user) + issue_activity.delay( + type="issue_reaction.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + 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]) + def destroy(self, request, slug, project_id, issue_id, reaction_code): + issue_reaction = IssueReaction.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + reaction=reaction_code, + actor=request.user, + ) + issue_activity.delay( + type="issue_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps({"reaction": str(reaction_code), "identifier": str(issue_reaction.id)}), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + issue_reaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/issue/relation.py b/apps/api/plane/app/views/issue/relation.py new file mode 100644 index 00000000..0dfd686d --- /dev/null +++ b/apps/api/plane/app/views/issue/relation.py @@ -0,0 +1,280 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +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 +from django.contrib.postgres.fields import ArrayField + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseViewSet +from plane.app.serializers import IssueRelationSerializer, RelatedIssueSerializer +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + Project, + IssueRelation, + Issue, + FileAsset, + IssueLink, + CycleIssue, +) +from plane.bgtasks.issue_activities_task import issue_activity +from plane.utils.issue_relation_mapper import get_actual_relation +from plane.utils.host import base_host + + +class IssueRelationViewSet(BaseViewSet): + serializer_class = IssueRelationSerializer + model = IssueRelation + permission_classes = [ProjectEntityPermission] + + def list(self, request, slug, project_id, issue_id): + issue_relations = ( + IssueRelation.objects.filter(Q(issue_id=issue_id) | Q(related_issue=issue_id)) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .order_by("-created_at") + .distinct() + ) + # get all blocking issues + blocking_issues = issue_relations.filter(relation_type="blocked_by", related_issue_id=issue_id).values_list( + "issue_id", flat=True + ) + + # get all blocked by issues + blocked_by_issues = issue_relations.filter(relation_type="blocked_by", issue_id=issue_id).values_list( + "related_issue_id", flat=True + ) + + # get all duplicate issues + duplicate_issues = issue_relations.filter(issue_id=issue_id, relation_type="duplicate").values_list( + "related_issue_id", flat=True + ) + + # get all relates to issues + duplicate_issues_related = issue_relations.filter( + related_issue_id=issue_id, relation_type="duplicate" + ).values_list("issue_id", flat=True) + + # get all relates to issues + relates_to_issues = issue_relations.filter(issue_id=issue_id, relation_type="relates_to").values_list( + "related_issue_id", flat=True + ) + + # get all relates to issues + relates_to_issues_related = issue_relations.filter( + 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=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() + .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") + ) + .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())), + ), + ) + ).distinct() + + # Fields + fields = [ + "id", + "name", + "state_id", + "sort_order", + "priority", + "sequence_id", + "project_id", + "label_ids", + "assignee_ids", + "created_at", + "updated_at", + "created_by", + "updated_by", + "relation_type", + ] + + response_data = { + "blocking": queryset.filter(pk__in=blocking_issues) + .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())) + .values(*fields), + "duplicate": queryset.filter(pk__in=duplicate_issues) + .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())) + .values(*fields), + "relates_to": queryset.filter(pk__in=relates_to_issues) + .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())) + .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), + } + + return Response(response_data, status=status.HTTP_200_OK) + + 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) + + issue_relation = IssueRelation.objects.bulk_create( + [ + IssueRelation( + issue_id=(issue if relation_type in ["blocking", "start_after", "finish_after"] else issue_id), + related_issue_id=( + 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, + updated_by=request.user, + ) + for issue in issues + ], + batch_size=10, + ignore_conflicts=True, + ) + + issue_activity.delay( + type="issue_relation.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + + if relation_type in ["blocking", "start_after", "finish_after"]: + return Response( + RelatedIssueSerializer(issue_relation, many=True).data, + status=status.HTTP_201_CREATED, + ) + else: + return Response( + IssueRelationSerializer(issue_relation, many=True).data, + status=status.HTTP_201_CREATED, + ) + + def remove_relation(self, request, slug, project_id, issue_id): + related_issue = request.data.get("related_issue", None) + + issue_relations = IssueRelation.objects.filter( + workspace__slug=slug, + ).filter( + Q(issue_id=related_issue, related_issue_id=issue_id) | Q(issue_id=issue_id, related_issue_id=related_issue) + ) + issue_relations = issue_relations.first() + current_instance = json.dumps(IssueRelationSerializer(issue_relations).data, cls=DjangoJSONEncoder) + issue_relations.delete() + issue_activity.delay( + type="issue_relation.activity.deleted", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/issue/sub_issue.py b/apps/api/plane/app/views/issue/sub_issue.py new file mode 100644 index 00000000..2fa244dc --- /dev/null +++ b/apps/api/plane/app/views/issue/sub_issue.py @@ -0,0 +1,213 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +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 +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseAPIView +from plane.app.serializers import IssueSerializer +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue +from plane.bgtasks.issue_activities_task import issue_activity +from plane.utils.timezone_converter import user_timezone_converter +from collections import defaultdict +from plane.utils.host import base_host +from plane.utils.order_queryset import order_issue_queryset + + +class SubIssuesEndpoint(BaseAPIView): + permission_classes = [ProjectEntityPermission] + + @method_decorator(gzip_page) + def get(self, request, slug, project_id, issue_id): + sub_issues = ( + Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug) + .select_related("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( + 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") + ) + .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(state_group=F("state__group")) + .order_by("-created_at") + ) + + # Ordering + order_by_param = request.GET.get("order_by", "-created_at") + group_by = request.GET.get("group_by", False) + + if order_by_param: + sub_issues, order_by_param = order_issue_queryset(sub_issues, order_by_param) + + # create's a dict with state group name with their respective issue id's + result = defaultdict(list) + for sub_issue in sub_issues: + result[sub_issue.state_group].append(str(sub_issue.id)) + + sub_issues = sub_issues.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + datetime_fields = ["created_at", "updated_at"] + sub_issues = user_timezone_converter(sub_issues, datetime_fields, request.user.user_timezone) + # Grouping + if group_by: + result_dict = defaultdict(list) + + for issue in sub_issues: + if group_by == "assignees__ids": + if issue["assignee_ids"]: + assignee_ids = issue["assignee_ids"] + for assignee_id in assignee_ids: + result_dict[str(assignee_id)].append(issue) + elif issue["assignee_ids"] == []: + result_dict["None"].append(issue) + + elif group_by: + result_dict[str(issue[group_by])].append(issue) + + return Response( + {"sub_issues": result_dict, "state_distribution": result}, + status=status.HTTP_200_OK, + ) + return Response( + {"sub_issues": sub_issues, "state_distribution": result}, + status=status.HTTP_200_OK, + ) + + # Assign multiple sub issues + def post(self, request, slug, project_id, issue_id): + parent_issue = Issue.issue_objects.get(pk=issue_id) + sub_issue_ids = request.data.get("sub_issue_ids", []) + + if not len(sub_issue_ids): + return Response( + {"error": "Sub Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) + + for sub_issue in sub_issues: + sub_issue.parent = parent_issue + + _ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10) + + updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids).annotate(state_group=F("state__group")) + + # Track the issue + _ = [ + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"parent": str(issue_id)}), + actor_id=str(request.user.id), + issue_id=str(sub_issue_id), + project_id=str(project_id), + current_instance=json.dumps({"parent": str(sub_issue_id)}), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + for sub_issue_id in sub_issue_ids + ] + + # create's a dict with state group name with their respective issue id's + result = defaultdict(list) + for sub_issue in updated_sub_issues: + result[sub_issue.state_group].append(str(sub_issue.id)) + + serializer = IssueSerializer(updated_sub_issues, many=True) + return Response( + {"sub_issues": serializer.data, "state_distribution": result}, + status=status.HTTP_200_OK, + ) diff --git a/apps/api/plane/app/views/issue/subscriber.py b/apps/api/plane/app/views/issue/subscriber.py new file mode 100644 index 00000000..58f3ba4c --- /dev/null +++ b/apps/api/plane/app/views/issue/subscriber.py @@ -0,0 +1,100 @@ +# Third Party imports +from rest_framework.response import Response +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 + + +class IssueSubscriberViewSet(BaseViewSet): + serializer_class = IssueSubscriberSerializer + model = IssueSubscriber + + permission_classes = [ProjectEntityPermission] + + def get_permissions(self): + if self.action in ["subscribe", "unsubscribe", "subscription_status"]: + self.permission_classes = [ProjectLitePermission] + else: + self.permission_classes = [ProjectEntityPermission] + + return super(IssueSubscriberViewSet, self).get_permissions() + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + issue_id=self.kwargs.get("issue_id"), + ) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .order_by("-created_at") + .distinct() + ) + + def list(self, request, slug, project_id, issue_id): + members = ProjectMember.objects.filter( + workspace__slug=slug, project_id=project_id, is_active=True + ).select_related("member") + serializer = ProjectMemberLiteSerializer(members, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, slug, project_id, issue_id, subscriber_id): + issue_subscriber = IssueSubscriber.objects.get( + project=project_id, + subscriber=subscriber_id, + workspace__slug=slug, + issue=issue_id, + ) + issue_subscriber.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def subscribe(self, request, slug, project_id, issue_id): + if IssueSubscriber.objects.filter( + issue_id=issue_id, + subscriber=request.user, + workspace__slug=slug, + project=project_id, + ).exists(): + return Response( + {"message": "User already subscribed to the issue."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + subscriber = IssueSubscriber.objects.create( + issue_id=issue_id, subscriber_id=request.user.id, project_id=project_id + ) + serializer = IssueSubscriberSerializer(subscriber) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def unsubscribe(self, request, slug, project_id, issue_id): + issue_subscriber = IssueSubscriber.objects.get( + project=project_id, + subscriber=request.user, + workspace__slug=slug, + issue=issue_id, + ) + issue_subscriber.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def subscription_status(self, request, slug, project_id, issue_id): + issue_subscriber = IssueSubscriber.objects.filter( + issue=issue_id, + subscriber=request.user, + workspace__slug=slug, + project=project_id, + ).exists() + return Response({"subscribed": issue_subscriber}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/issue/version.py b/apps/api/plane/app/views/issue/version.py new file mode 100644 index 00000000..358271ac --- /dev/null +++ b/apps/api/plane/app/views/issue/version.py @@ -0,0 +1,140 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.db.models import ( + IssueVersion, + IssueDescriptionVersion, + Project, + ProjectMember, + Issue, +) +from ..base import BaseAPIView +from plane.app.serializers import ( + IssueVersionDetailSerializer, + IssueDescriptionVersionDetailSerializer, +) +from plane.app.permissions import allow_permission, ROLE +from plane.utils.global_paginator import paginate +from plane.utils.timezone_converter import user_timezone_converter + + +class IssueVersionEndpoint(BaseAPIView): + def process_paginated_result(self, fields, results, timezone): + paginated_data = results.values(*fields) + + datetime_fields = ["created_at", "updated_at"] + paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone) + + return paginated_data + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, issue_id, pk=None): + if pk: + issue_version = IssueVersion.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + + serializer = IssueVersionDetailSerializer(issue_version) + return Response(serializer.data, status=status.HTTP_200_OK) + + cursor = request.GET.get("cursor", None) + + required_fields = [ + "id", + "workspace", + "project", + "issue", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + issue_versions_queryset = IssueVersion.objects.filter( + workspace__slug=slug, project_id=project_id, issue_id=issue_id + ) + + paginated_data = paginate( + base_queryset=issue_versions_queryset, + queryset=issue_versions_queryset, + cursor=cursor, + on_result=lambda results: self.process_paginated_result( + required_fields, results, request.user.user_timezone + ), + ) + + return Response(paginated_data, status=status.HTTP_200_OK) + + +class WorkItemDescriptionVersionEndpoint(BaseAPIView): + def process_paginated_result(self, fields, results, timezone): + paginated_data = results.values(*fields) + + datetime_fields = ["created_at", "updated_at"] + paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone) + + return paginated_data + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, work_item_id, pk=None): + project = Project.objects.get(pk=project_id) + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=work_item_id) + + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=ROLE.GUEST.value, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not issue.created_by == request.user + ): + return Response( + {"error": "You are not allowed to view this issue"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if pk: + issue_description_version = IssueDescriptionVersion.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=work_item_id, + pk=pk, + ) + + serializer = IssueDescriptionVersionDetailSerializer(issue_description_version) + return Response(serializer.data, status=status.HTTP_200_OK) + + cursor = request.GET.get("cursor", None) + + required_fields = [ + "id", + "workspace", + "project", + "issue", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + issue_description_versions_queryset = IssueDescriptionVersion.objects.filter( + workspace__slug=slug, project_id=project_id, issue_id=work_item_id + ).order_by("-created_at") + paginated_data = paginate( + base_queryset=issue_description_versions_queryset, + queryset=issue_description_versions_queryset, + cursor=cursor, + on_result=lambda results: self.process_paginated_result( + required_fields, results, request.user.user_timezone + ), + ) + return Response(paginated_data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/module/archive.py b/apps/api/plane/app/views/module/archive.py new file mode 100644 index 00000000..129ff0da --- /dev/null +++ b/apps/api/plane/app/views/module/archive.py @@ -0,0 +1,561 @@ +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import ( + Count, + Exists, + F, + Func, + IntegerField, + OuterRef, + Prefetch, + Q, + Subquery, + UUIDField, + Value, + Sum, + FloatField, + Case, + When, +) +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.db.models import Issue, Module, ModuleLink, UserFavorite, Project +from plane.utils.analytics_plot import burndown_plot +from plane.utils.timezone_converter import user_timezone_converter + + +# Module imports +from .. import BaseAPIView + + +class ModuleArchiveUnarchiveEndpoint(BaseAPIView): + permission_classes = [ProjectEntityPermission] + + def get_queryset(self): + favorite_subquery = UserFavorite.objects.filter( + user=self.request.user, + entity_type="module", + entity_identifier=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + cancelled_issues = ( + 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")) + .values("cnt") + ) + completed_issues = ( + 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")) + .values("cnt") + ) + started_issues = ( + 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")) + .values("cnt") + ) + unstarted_issues = ( + 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")) + .values("cnt") + ) + backlog_issues = ( + 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")) + .values("cnt") + ) + 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")) + .values("cnt") + ) + completed_estimate_point = ( + Issue.issue_objects.filter( + 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(completed_estimate_points=Sum(Cast("estimate_point__value", FloatField()))) + .values("completed_estimate_points")[:1] + ) + + total_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, + ) + .values("issue_module__module_id") + .annotate(total_estimate_points=Sum(Cast("estimate_point__value", FloatField()))) + .values("total_estimate_points")[:1] + ) + backlog_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="backlog", + issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, + ) + .values("issue_module__module_id") + .annotate(backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) + .values("backlog_estimate_point")[:1] + ) + unstarted_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="unstarted", + issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, + ) + .values("issue_module__module_id") + .annotate(unstarted_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) + .values("unstarted_estimate_point")[:1] + ) + started_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="started", + issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, + ) + .values("issue_module__module_id") + .annotate(started_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) + .values("started_estimate_point")[:1] + ) + cancelled_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="cancelled", + issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, + ) + .values("issue_module__module_id") + .annotate(cancelled_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) + .values("cancelled_estimate_point")[:1] + ) + return ( + Module.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(archived_at__isnull=False) + .annotate(is_favorite=Exists(favorite_subquery)) + .select_related("workspace", "project", "lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related("module", "created_by"), + ) + ) + .annotate( + completed_issues=Coalesce( + Subquery(completed_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + cancelled_issues=Coalesce( + Subquery(cancelled_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate(started_issues=Coalesce(Subquery(started_issues[:1]), Value(0, output_field=IntegerField()))) + .annotate( + unstarted_issues=Coalesce( + Subquery(unstarted_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate(backlog_issues=Coalesce(Subquery(backlog_issues[:1]), Value(0, output_field=IntegerField()))) + .annotate(total_issues=Coalesce(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())) + ) + .annotate( + member_ids=Coalesce( + ArrayAgg( + "members__id", + distinct=True, + filter=~Q(members__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .order_by("-is_favorite", "-created_at") + ) + + def get(self, request, slug, project_id, pk=None): + if pk is None: + queryset = self.get_queryset() + modules = queryset.values( # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "total_issues", + "is_favorite", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + "archived_at", + ) + datetime_fields = ["created_at", "updated_at"] + modules = user_timezone_converter(modules, datetime_fields, request.user.user_timezone) + return Response(modules, status=status.HTTP_200_OK) + else: + queryset = ( + self.get_queryset() + .filter(pk=pk) + .annotate( + sub_issues=Issue.issue_objects.filter( + 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")) + .values("count") + ) + ) + + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data = ModuleDetailSerializer(queryset.first()).data + modules = queryset.first() + + data["estimate_distribution"] = {} + + if estimate_type: + assignee_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + issue_module__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(display_name=F("assignees__display_name")) + .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_url", + "display_name", + ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"]["assignees"] = assignee_distribution + data["estimate_distribution"]["labels"] = label_distribution + + if modules and modules.start_date and modules.target_date: + data["estimate_distribution"]["completion_chart"] = burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + plot_type="points", + module_id=pk, + ) + + assignee_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + issue_module__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(display_name=F("assignees__display_name")) + .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_url", + "display_name", + ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + if modules and modules.start_date and modules.target_date: + data["distribution"]["completion_chart"] = burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + plot_type="issues", + module_id=pk, + ) + + return Response(data, status=status.HTTP_200_OK) + + def post(self, request, slug, project_id, module_id): + module = Module.objects.get(pk=module_id, 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"}, + status=status.HTTP_400_BAD_REQUEST, + ) + module.archived_at = timezone.now() + module.save() + UserFavorite.objects.filter( + entity_type="module", + entity_identifier=module_id, + project_id=project_id, + workspace__slug=slug, + ).delete() + return Response({"archived_at": str(module.archived_at)}, status=status.HTTP_200_OK) + + def delete(self, request, slug, project_id, module_id): + module = Module.objects.get(pk=module_id, project_id=project_id, workspace__slug=slug) + module.archived_at = None + module.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/module/base.py b/apps/api/plane/app/views/module/base.py new file mode 100644 index 00000000..ae429e75 --- /dev/null +++ b/apps/api/plane/app/views/module/base.py @@ -0,0 +1,851 @@ +# Python imports +import json + +# Django Imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import ( + Count, + Exists, + F, + Func, + IntegerField, + OuterRef, + Prefetch, + Q, + Subquery, + UUIDField, + Value, + Sum, + FloatField, + Case, + When, +) +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 + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import ( + ProjectEntityPermission, + ProjectLitePermission, + allow_permission, + ROLE, +) + +from plane.app.serializers import ( + ModuleDetailSerializer, + ModuleLinkSerializer, + ModuleSerializer, + ModuleUserPropertiesSerializer, + ModuleWriteSerializer, +) +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import ( + Issue, + Module, + UserFavorite, + ModuleIssue, + ModuleLink, + ModuleUserProperties, + Project, + UserRecentVisit, +) +from plane.utils.analytics_plot import burndown_plot +from plane.utils.timezone_converter import user_timezone_converter +from plane.bgtasks.webhook_task import model_activity +from .. import BaseAPIView, BaseViewSet +from plane.bgtasks.recent_visited_task import recent_visited_task +from plane.utils.host import base_host + + +class ModuleViewSet(BaseViewSet): + model = Module + webhook_event = "module" + + def get_serializer_class(self): + return ModuleWriteSerializer if self.action in ["create", "update", "partial_update"] else ModuleSerializer + + def get_queryset(self): + favorite_subquery = UserFavorite.objects.filter( + user=self.request.user, + entity_type="module", + entity_identifier=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + cancelled_issues = ( + 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")) + .values("cnt") + ) + completed_issues = ( + 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")) + .values("cnt") + ) + started_issues = ( + 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")) + .values("cnt") + ) + unstarted_issues = ( + 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")) + .values("cnt") + ) + backlog_issues = ( + 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")) + .values("cnt") + ) + 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")) + .values("cnt") + ) + completed_estimate_point = ( + Issue.issue_objects.filter( + 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(completed_estimate_points=Sum(Cast("estimate_point__value", FloatField()))) + .values("completed_estimate_points")[:1] + ) + + total_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, + ) + .values("issue_module__module_id") + .annotate(total_estimate_points=Sum(Cast("estimate_point__value", FloatField()))) + .values("total_estimate_points")[:1] + ) + backlog_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="backlog", + issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, + ) + .values("issue_module__module_id") + .annotate(backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) + .values("backlog_estimate_point")[:1] + ) + unstarted_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="unstarted", + issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, + ) + .values("issue_module__module_id") + .annotate(unstarted_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) + .values("unstarted_estimate_point")[:1] + ) + started_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="started", + issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, + ) + .values("issue_module__module_id") + .annotate(started_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) + .values("started_estimate_point")[:1] + ) + cancelled_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="cancelled", + issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, + ) + .values("issue_module__module_id") + .annotate(cancelled_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) + .values("cancelled_estimate_point")[:1] + ) + return ( + super() + .get_queryset() + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .annotate(is_favorite=Exists(favorite_subquery)) + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related("module", "created_by"), + ) + ) + .annotate( + completed_issues=Coalesce( + Subquery(completed_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + cancelled_issues=Coalesce( + Subquery(cancelled_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate(started_issues=Coalesce(Subquery(started_issues[:1]), Value(0, output_field=IntegerField()))) + .annotate( + unstarted_issues=Coalesce( + Subquery(unstarted_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate(backlog_issues=Coalesce(Subquery(backlog_issues[:1]), Value(0, output_field=IntegerField()))) + .annotate(total_issues=Coalesce(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())) + ) + .annotate( + member_ids=Coalesce( + ArrayAgg( + "members__id", + distinct=True, + filter=Q( + members__id__isnull=False, + modulemember__deleted_at__isnull=True, + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .order_by("-is_favorite", "-created_at") + ) + + @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(data=request.data, context={"project": project}) + + if serializer.is_valid(): + serializer.save() + + module = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .values( # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + "logo_props", + # computed fields + "is_favorite", + "cancelled_issues", + "completed_issues", + "total_issues", + "started_issues", + "unstarted_issues", + "completed_estimate_points", + "total_estimate_points", + "backlog_issues", + "created_at", + "updated_at", + ) + ).first() + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(module["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + datetime_fields = ["created_at", "updated_at"] + module = user_timezone_converter(module, datetime_fields, request.user.user_timezone) + 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]) + 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 + else: + modules = queryset.values( # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + "logo_props", + # computed fields + "completed_estimate_points", + "total_estimate_points", + "total_issues", + "is_favorite", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + ) + datetime_fields = ["created_at", "updated_at"] + modules = user_timezone_converter(modules, datetime_fields, request.user.user_timezone) + return Response(modules, status=status.HTTP_200_OK) + + @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) + .annotate( + sub_issues=Issue.issue_objects.filter( + 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")) + .values("count") + ) + ) + + if not queryset.exists(): + return Response({"error": "Module not found"}, status=status.HTTP_404_NOT_FOUND) + + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data = ModuleDetailSerializer(queryset.first()).data + modules = queryset.first() + + data["estimate_distribution"] = {} + + if estimate_type: + assignee_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + issue_module__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(display_name=F("assignees__display_name")) + .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_url", + "display_name", + ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"]["assignees"] = assignee_distribution + data["estimate_distribution"]["labels"] = label_distribution + + if modules and modules.start_date and modules.target_date: + data["estimate_distribution"]["completion_chart"] = burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + plot_type="points", + module_id=pk, + ) + + assignee_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + issue_module__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(display_name=F("assignees__display_name")) + .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_url", "display_name") + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + 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, + project_id=project_id, + plot_type="issues", + module_id=pk, + ) + + recent_visited_task.delay( + slug=slug, + entity_name="module", + entity_identifier=pk, + user_id=request.user.id, + project_id=project_id, + ) + + return Response(data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def partial_update(self, request, slug, project_id, pk): + module_queryset = self.get_queryset().filter(pk=pk) + + current_module = module_queryset.first() + + if not current_module: + return Response( + {"error": "Module not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if current_module.archived_at: + return Response( + {"error": "Archived module cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + current_instance = json.dumps(ModuleSerializer(current_module).data, cls=DjangoJSONEncoder) + serializer = ModuleWriteSerializer(current_module, data=request.data, partial=True) + + if serializer.is_valid(): + serializer.save() + module = module_queryset.values( + # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + "logo_props", + # computed fields + "completed_estimate_points", + "total_estimate_points", + "is_favorite", + "cancelled_issues", + "completed_issues", + "started_issues", + "total_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + ).first() + + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(module["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + + datetime_fields = ["created_at", "updated_at"] + module = user_timezone_converter(module, datetime_fields, request.user.user_timezone) + return Response(module, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @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_issues = list(ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)) + _ = [ + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps({"module_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue), + project_id=project_id, + current_instance=json.dumps({"module_name": str(module.name)}), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + for issue in module_issues + ] + module.delete() + # Delete the module issues + ModuleIssue.objects.filter(module=pk, project_id=project_id).delete() + # Delete the user favorite module + UserFavorite.objects.filter( + user=request.user, + entity_type="module", + entity_identifier=pk, + project_id=project_id, + ).delete() + # delete the module from recent visits + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="module", + ).delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ModuleLinkViewSet(BaseViewSet): + permission_classes = [ProjectEntityPermission] + + model = ModuleLink + serializer_class = ModuleLinkSerializer + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + module_id=self.kwargs.get("module_id"), + ) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(module_id=self.kwargs.get("module_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .order_by("-created_at") + .distinct() + ) + + +class ModuleFavoriteViewSet(BaseViewSet): + model = UserFavorite + permission_classes = [ProjectLitePermission] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("module") + ) + + def create(self, request, slug, project_id): + _ = UserFavorite.objects.create( + project_id=project_id, + user=request.user, + entity_type="module", + entity_identifier=request.data.get("module"), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + def destroy(self, request, slug, project_id, module_id): + module_favorite = UserFavorite.objects.get( + project_id=project_id, + user=request.user, + workspace__slug=slug, + entity_type="module", + entity_identifier=module_id, + ) + module_favorite.delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) + + +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( + user=request.user, + module_id=module_id, + project_id=project_id, + workspace__slug=slug, + ) + + module_properties.filters = request.data.get("filters", module_properties.filters) + module_properties.rich_filters = request.data.get("rich_filters", module_properties.rich_filters) + module_properties.display_filters = request.data.get("display_filters", module_properties.display_filters) + module_properties.display_properties = request.data.get( + "display_properties", module_properties.display_properties + ) + module_properties.save() + + serializer = ModuleUserPropertiesSerializer(module_properties) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, module_id): + module_properties, _ = ModuleUserProperties.objects.get_or_create( + user=request.user, + project_id=project_id, + module_id=module_id, + workspace__slug=slug, + ) + serializer = ModuleUserPropertiesSerializer(module_properties) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/module/issue.py b/apps/api/plane/app/views/module/issue.py new file mode 100644 index 00000000..672bf4e1 --- /dev/null +++ b/apps/api/plane/app/views/module/issue.py @@ -0,0 +1,333 @@ +# Python imports +import copy +import json + +from django.db.models import F, Func, OuterRef, Q, Subquery + +# Django Imports +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 + +from plane.app.permissions import allow_permission, ROLE +from plane.app.serializers import ModuleIssueSerializer +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import ( + Issue, + FileAsset, + IssueLink, + ModuleIssue, + Project, + CycleIssue, +) +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 plane.utils.filters import ComplexFilterBackend +from plane.utils.filters import IssueFilterSet +from .. import BaseViewSet +from plane.utils.host import base_host + + +class ModuleIssueViewSet(BaseViewSet): + serializer_class = ModuleIssueSerializer + model = ModuleIssue + webhook_event = "module_issue" + bulk = True + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet + + def apply_annotations(self, issues): + return ( + issues.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() + .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") + ) + .prefetch_related("assignees", "labels", "issue_module__module") + ) + + def get_queryset(self): + return ( + Issue.issue_objects.filter( + 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, + ) + ).distinct() + + @method_decorator(gzip_page) + @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() + + # Apply filtering from filterset + issue_queryset = self.filter_queryset(issue_queryset) + + # Apply legacy filters + issue_queryset = issue_queryset.filter(**filters) + + # Total count queryset + total_issue_queryset = copy.deepcopy(issue_queryset) + + # Apply annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) + + order_by_param = request.GET.get("order_by", "created_at") + + # 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, + total_count_queryset=total_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, + queryset=total_issue_queryset, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + queryset=total_issue_queryset, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + 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, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + total_count_queryset=total_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, + queryset=total_issue_queryset, + ), + group_by_field_name=group_by, + count_filter=Q( + 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, + ), + ) + else: + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + total_count_queryset=total_issue_queryset, + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + # create multiple issues inside a module + def create_module_issues(self, request, slug, project_id, module_id): + issues = request.data.get("issues", []) + if not issues: + return Response({"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST) + project = Project.objects.get(pk=project_id) + _ = ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + issue_id=str(issue), + module_id=module_id, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for issue in issues + ], + batch_size=10, + ignore_conflicts=True, + ) + # Bulk Update the activity + _ = [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": str(module_id)}), + actor_id=str(request.user.id), + issue_id=str(issue), + project_id=project_id, + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + for issue in issues + ] + return Response({"message": "success"}, status=status.HTTP_201_CREATED) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + # add multiple module inside an issue and remove multiple modules from an issue + def create_issue_modules(self, request, slug, project_id, issue_id): + modules = request.data.get("modules", []) + removed_modules = request.data.get("removed_modules", []) + project = Project.objects.get(pk=project_id) + + if modules: + _ = ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + issue_id=issue_id, + module_id=module, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for module in modules + ], + batch_size=10, + ignore_conflicts=True, + ) + # Bulk Update the activity + _ = [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": module}), + actor_id=str(request.user.id), + issue_id=issue_id, + project_id=project_id, + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + for module in modules + ] + + for module_id in removed_modules: + module_issue = ModuleIssue.objects.filter( + workspace__slug=slug, + project_id=project_id, + module_id=module_id, + issue_id=issue_id, + ) + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps({"module_id": str(module_id)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=json.dumps( + { + "module_name": ( + module_issue.first().module.name + if (module_issue.first() and module_issue.first().module) + else None + ) + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + module_issue.delete() + + return Response({"message": "success"}, status=status.HTTP_201_CREATED) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, module_id, issue_id): + module_issue = ModuleIssue.objects.filter( + workspace__slug=slug, + project_id=project_id, + module_id=module_id, + issue_id=issue_id, + ) + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps({"module_id": str(module_id)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=json.dumps({"module_name": module_issue.first().module.name}), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + module_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/notification/base.py b/apps/api/plane/app/views/notification/base.py new file mode 100644 index 00000000..a11c12d1 --- /dev/null +++ b/apps/api/plane/app/views/notification/base.py @@ -0,0 +1,304 @@ +# Django imports +from django.db.models import Exists, OuterRef, Q, Case, When, BooleanField +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +from plane.app.serializers import ( + NotificationSerializer, + UserNotificationPreferenceSerializer, +) +from plane.db.models import ( + Issue, + IssueAssignee, + IssueSubscriber, + Notification, + UserNotificationPreference, + WorkspaceMember, +) +from plane.utils.paginator import BasePaginator +from plane.app.permissions import allow_permission, ROLE + +# Module imports +from ..base import BaseAPIView, BaseViewSet + + +class NotificationViewSet(BaseViewSet, BasePaginator): + model = Notification + serializer_class = NotificationSerializer + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + receiver_id=self.request.user.id, + ) + .select_related("workspace", "project", "triggered_by", "receiver") + ) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def list(self, request, slug): + # Get query parameters + snoozed = request.GET.get("snoozed", "false") + archived = request.GET.get("archived", "false") + read = request.GET.get("read", None) + type = request.GET.get("type", "all") + mentioned = request.GET.get("mentioned", False) + q_filters = Q() + + intake_issue = Issue.objects.filter( + pk=OuterRef("entity_identifier"), + issue_intake__status__in=[0, 2, -2], + workspace__slug=self.kwargs.get("slug"), + ) + + notifications = ( + Notification.objects.filter(workspace__slug=slug, receiver_id=request.user.id) + .filter(entity_name="issue") + .annotate(is_inbox_issue=Exists(intake_issue)) + .annotate(is_intake_issue=Exists(intake_issue)) + .annotate( + is_mentioned_notification=Case( + When(sender__icontains="mentioned", then=True), + default=False, + output_field=BooleanField(), + ) + ) + .select_related("workspace", "project", "triggered_by", "receiver") + .order_by("snoozed_till", "-created_at") + ) + + # 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), + } + + notifications = notifications.filter(snoozed_filters[snoozed]) + + archived_filters = { + "true": Q(archived_at__isnull=False), + "false": Q(archived_at__isnull=True), + } + + notifications = notifications.filter(archived_filters[archived]) + + if read == "false": + notifications = notifications.filter(read_at__isnull=True) + + if read == "true": + notifications = notifications.filter(read_at__isnull=False) + + if mentioned: + notifications = notifications.filter(sender__icontains="mentioned") + else: + notifications = notifications.exclude(sender__icontains="mentioned") + + type = type.split(",") + # Subscribed issues + if "subscribed" in type: + issue_ids = ( + IssueSubscriber.objects.filter(workspace__slug=slug, subscriber_id=request.user.id) + .annotate(created=Exists(Issue.objects.filter(created_by=request.user, pk=OuterRef("issue_id")))) + .annotate(assigned=Exists(IssueAssignee.objects.filter(pk=OuterRef("issue_id"), assignee=request.user))) + .filter(created=False, assigned=False) + .values_list("issue_id", flat=True) + ) + q_filters |= Q(entity_identifier__in=issue_ids) + + # Assigned Issues + if "assigned" in type: + issue_ids = IssueAssignee.objects.filter(workspace__slug=slug, assignee_id=request.user.id).values_list( + "issue_id", flat=True + ) + q_filters |= Q(entity_identifier__in=issue_ids) + + # Created issues + if "created" in type: + if WorkspaceMember.objects.filter( + workspace__slug=slug, member=request.user, role__lt=15, is_active=True + ).exists(): + notifications = notifications.none() + else: + issue_ids = Issue.objects.filter(workspace__slug=slug, created_by=request.user).values_list( + "pk", flat=True + ) + q_filters |= Q(entity_identifier__in=issue_ids) + + # Apply the combined Q object filters + notifications = notifications.filter(q_filters) + + # Pagination + if request.GET.get("per_page", False) and request.GET.get("cursor", False): + return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), + request=request, + queryset=(notifications), + on_results=lambda notifications: NotificationSerializer(notifications, many=True).data, + ) + + serializer = NotificationSerializer(notifications, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission(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)} + serializer = NotificationSerializer(notification, data=notification_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, ROLE.GUEST], level="WORKSPACE") + def mark_read(self, request, slug, pk): + notification = Notification.objects.get(receiver=request.user, workspace__slug=slug, pk=pk) + notification.read_at = timezone.now() + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def mark_unread(self, request, slug, pk): + notification = Notification.objects.get(receiver=request.user, workspace__slug=slug, pk=pk) + notification.read_at = None + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def archive(self, request, slug, pk): + notification = Notification.objects.get(receiver=request.user, workspace__slug=slug, pk=pk) + notification.archived_at = timezone.now() + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def unarchive(self, request, slug, pk): + notification = Notification.objects.get(receiver=request.user, workspace__slug=slug, pk=pk) + notification.archived_at = None + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class UnreadNotificationEndpoint(BaseAPIView): + use_read_replica = True + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug): + # Watching Issues Count + unread_notifications_count = ( + Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + read_at__isnull=True, + archived_at__isnull=True, + snoozed_till__isnull=True, + ) + .exclude(sender__icontains="mentioned") + .count() + ) + + mention_notifications_count = Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + read_at__isnull=True, + archived_at__isnull=True, + snoozed_till__isnull=True, + sender__icontains="mentioned", + ).count() + + return Response( + { + "total_unread_notifications_count": int(unread_notifications_count), + "mention_unread_notifications_count": int(mention_notifications_count), + }, + status=status.HTTP_200_OK, + ) + + +class MarkAllReadNotificationViewSet(BaseViewSet): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def create(self, request, slug): + snoozed = request.data.get("snoozed", False) + archived = request.data.get("archived", False) + type = request.data.get("type", "all") + + notifications = ( + Notification.objects.filter(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") + ) + + # Filter for snoozed notifications + if snoozed: + notifications = notifications.filter(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)) + + # Filter for archived or unarchive + if archived: + notifications = notifications.filter(archived_at__isnull=False) + else: + notifications = notifications.filter(archived_at__isnull=True) + + # Subscribed issues + if type == "watching": + 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) + + # 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) + + # Created issues + if type == "created": + if WorkspaceMember.objects.filter( + 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) + + updated_notifications = [] + for notification in notifications: + notification.read_at = timezone.now() + updated_notifications.append(notification) + Notification.objects.bulk_update(updated_notifications, ["read_at"], batch_size=100) + return Response({"message": "Successful"}, status=status.HTTP_200_OK) + + +class UserNotificationPreferenceEndpoint(BaseAPIView): + model = UserNotificationPreference + serializer_class = UserNotificationPreferenceSerializer + + # request the object + def get(self, request): + user_notification_preference = UserNotificationPreference.objects.get(user=request.user) + serializer = UserNotificationPreferenceSerializer(user_notification_preference) + return Response(serializer.data, status=status.HTTP_200_OK) + + # update the object + def patch(self, request): + user_notification_preference = UserNotificationPreference.objects.get(user=request.user) + serializer = UserNotificationPreferenceSerializer(user_notification_preference, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py new file mode 100644 index 00000000..b8946d22 --- /dev/null +++ b/apps/api/plane/app/views/page/base.py @@ -0,0 +1,584 @@ +# Python imports +import json +from datetime import datetime +from django.core.serializers.json import DjangoJSONEncoder + +# Django imports +from django.db import connection +from django.db.models import ( + Exists, + OuterRef, + Q, + Value, + UUIDField, + Count, + Case, + When, + IntegerField, +) +from django.http import StreamingHttpResponse +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import allow_permission, ROLE +from plane.app.serializers import ( + PageSerializer, + PageDetailSerializer, + PageBinaryUpdateSerializer, +) +from plane.db.models import ( + Page, + PageLog, + UserFavorite, + ProjectMember, + ProjectPage, + Project, + UserRecentVisit, +) +from plane.utils.error_codes import ERROR_CODES + +# Local 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 +from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets +from plane.app.permissions import ProjectPagePermission + + +def unarchive_archive_page_and_descendants(page_id, archived_at): + # Your SQL query + sql = """ + WITH RECURSIVE descendants AS ( + SELECT id FROM pages WHERE id = %s + UNION ALL + SELECT pages.id FROM pages, descendants WHERE pages.parent_id = descendants.id + ) + UPDATE pages SET archived_at = %s WHERE id IN (SELECT id FROM descendants); + """ + + # Execute the SQL query + with connection.cursor() as cursor: + cursor.execute(sql, [page_id, archived_at]) + + +class PageViewSet(BaseViewSet): + serializer_class = PageSerializer + model = Page + permission_classes = [ProjectPagePermission] + search_fields = ["name"] + + def get_queryset(self): + subquery = UserFavorite.objects.filter( + user=self.request.user, + entity_type="page", + entity_identifier=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ) + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter( + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, + ) + .filter(parent__isnull=True) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .prefetch_related("projects") + .select_related("workspace") + .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .order_by(self.request.GET.get("order_by", "-created_at")) + .prefetch_related("labels") + .order_by("-is_favorite", "-created_at") + .annotate( + project=Exists( + ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")) + ) + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "page_labels__label_id", + distinct=True, + filter=~Q(page_labels__label_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + project_ids=Coalesce( + ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .filter(project=True) + .distinct() + ) + + def create(self, request, slug, project_id): + serializer = PageSerializer( + data=request.data, + context={ + "project_id": project_id, + "owned_by_id": request.user.id, + "description": request.data.get("description", {}), + "description_binary": request.data.get("description_binary", None), + "description_html": request.data.get("description_html", "

    "), + }, + ) + + if serializer.is_valid(): + serializer.save() + # capture the page transaction + page_transaction.delay( + new_description_html=request.data.get("description_html", "

    "), + old_description_html=None, + page_id=serializer.data["id"], + ) + page = self.get_queryset().get(pk=serializer.data["id"]) + serializer = PageDetailSerializer(page) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, slug, project_id, page_id): + try: + page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id) + + if page.is_locked: + return Response({"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST) + + parent = request.data.get("parent", None) + if parent: + _ = Page.objects.get(pk=parent, workspace__slug=slug, projects__id=project_id) + + # Only update access if the page owner is the requesting user + if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id: + return Response( + {"error": "Access cannot be updated since this page is owned by someone else"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = PageDetailSerializer(page, data=request.data, partial=True) + page_description = page.description_html + if serializer.is_valid(): + serializer.save() + # capture the page transaction + if request.data.get("description_html"): + page_transaction.delay( + new_description_html=request.data.get("description_html", "

    "), + old_description_html=page_description, + page_id=page_id, + ) + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Page.DoesNotExist: + return Response( + {"error": "Access cannot be updated since this page is owned by someone else"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def retrieve(self, request, slug, project_id, page_id=None): + page = self.get_queryset().filter(pk=page_id).first() + project = Project.objects.get(pk=project_id) + track_visit = request.query_params.get("track_visit", "true").lower() == "true" + + """ + if the role is guest and guest_view_all_features is false and owned by is not + the requesting user then dont show the page + """ + + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not page.owned_by == request.user + ): + return Response( + {"error": "You are not allowed to view this page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if page is None: + return Response({"error": "Page not found"}, status=status.HTTP_404_NOT_FOUND) + else: + issue_ids = PageLog.objects.filter(page_id=page_id, entity_name="issue").values_list( + "entity_identifier", flat=True + ) + data = PageDetailSerializer(page).data + data["issue_ids"] = issue_ids + if track_visit: + recent_visited_task.delay( + slug=slug, + entity_name="page", + entity_identifier=page_id, + user_id=request.user.id, + project_id=project_id, + ) + return Response(data, status=status.HTTP_200_OK) + + def lock(self, request, slug, project_id, page_id): + page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first() + + page.is_locked = True + page.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def unlock(self, request, slug, project_id, page_id): + page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first() + + page.is_locked = False + page.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + + def access(self, request, slug, project_id, page_id): + access = request.data.get("access", 0) + page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first() + + # Only update access if the page owner is the requesting user + if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id: + return Response( + {"error": "Access cannot be updated since this page is owned by someone else"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + page.access = access + page.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def list(self, request, slug, project_id): + queryset = self.get_queryset() + project = Project.objects.get(pk=project_id) + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + ): + queryset = queryset.filter(owned_by=request.user) + pages = PageSerializer(queryset, many=True).data + return Response(pages, status=status.HTTP_200_OK) + + def archive(self, request, slug, project_id, page_id): + page = Page.objects.get(pk=page_id, 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 + ).exists() + and request.user.id != page.owned_by_id + ): + return Response( + {"error": "Only the owner or admin can archive the page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + UserFavorite.objects.filter( + entity_type="page", + entity_identifier=page_id, + project_id=project_id, + workspace__slug=slug, + ).delete() + + unarchive_archive_page_and_descendants(page_id, datetime.now()) + + return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK) + + def unarchive(self, request, slug, project_id, page_id): + page = Page.objects.get(pk=page_id, 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 + ).exists() + and request.user.id != page.owned_by_id + ): + return Response( + {"error": "Only the owner or admin can un archive the page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # if parent archived then page will be un archived breaking hierarchy + if page.parent_id and page.parent.archived_at: + page.parent = None + page.save(update_fields=["parent"]) + + unarchive_archive_page_and_descendants(page_id, None) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def destroy(self, request, slug, project_id, page_id): + page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id) + + if page.archived_at is None: + return Response( + {"error": "The page should be archived before deleting"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if page.owned_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 owner can delete the page"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # remove parent from all the children + _ = Page.objects.filter(parent_id=page_id, projects__id=project_id, workspace__slug=slug).update(parent=None) + + page.delete() + # Delete the user favorite page + UserFavorite.objects.filter( + project=project_id, + workspace__slug=slug, + entity_identifier=page_id, + entity_type="page", + ).delete() + # Delete the page from recent visit + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=page_id, + entity_name="page", + ).delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) + + def summary(self, request, slug, project_id): + queryset = ( + Page.objects.filter(workspace__slug=slug) + .filter( + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, + ) + .filter(parent__isnull=True) + .filter(Q(owned_by=request.user) | Q(access=0)) + .annotate( + project=Exists( + ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")) + ) + ) + .filter(project=True) + .distinct() + ) + + project = Project.objects.get(pk=project_id) + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=ROLE.GUEST.value, + is_active=True, + ).exists() + and not project.guest_view_all_features + ): + queryset = queryset.filter(owned_by=request.user) + + stats = queryset.aggregate( + public_pages=Count( + Case( + When(access=Page.PUBLIC_ACCESS, archived_at__isnull=True, then=1), + output_field=IntegerField(), + ) + ), + private_pages=Count( + Case( + When(access=Page.PRIVATE_ACCESS, archived_at__isnull=True, then=1), + output_field=IntegerField(), + ) + ), + archived_pages=Count(Case(When(archived_at__isnull=False, then=1), output_field=IntegerField())), + ) + + return Response(stats, status=status.HTTP_200_OK) + + +class PageFavoriteViewSet(BaseViewSet): + model = UserFavorite + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id, page_id): + _ = UserFavorite.objects.create( + project_id=project_id, + entity_identifier=page_id, + entity_type="page", + user=request.user, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, page_id): + page_favorite = UserFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + entity_identifier=page_id, + entity_type="page", + ) + page_favorite.delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PagesDescriptionViewSet(BaseViewSet): + permission_classes = [ProjectPagePermission] + + def retrieve(self, request, slug, project_id, page_id): + page = ( + Page.objects.filter(pk=page_id, 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) + binary_data = page.description_binary + + def stream_data(): + if binary_data: + yield binary_data + else: + yield b"" + + response = StreamingHttpResponse(stream_data(), content_type="application/octet-stream") + response["Content-Disposition"] = 'attachment; filename="page_description.bin"' + return response + + def partial_update(self, request, slug, project_id, page_id): + page = ( + Page.objects.filter(pk=page_id, 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) + + if page.is_locked: + return Response( + { + "error_code": ERROR_CODES["PAGE_LOCKED"], + "error_message": "PAGE_LOCKED", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if page.archived_at: + return Response( + { + "error_code": ERROR_CODES["PAGE_ARCHIVED"], + "error_message": "PAGE_ARCHIVED", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Serialize the existing instance + existing_instance = json.dumps({"description_html": page.description_html}, cls=DjangoJSONEncoder) + + # Use serializer for validation and update + serializer = PageBinaryUpdateSerializer(page, data=request.data, partial=True) + if serializer.is_valid(): + # Capture the page transaction + if request.data.get("description_html"): + page_transaction.delay( + new_description_html=request.data.get("description_html", "

    "), + old_description_html=page.description_html, + page_id=page_id, + ) + + # Update the page using serializer + updated_page = serializer.save() + + # Run background tasks + page_version.delay( + page_id=updated_page.id, + existing_instance=existing_instance, + user_id=request.user.id, + ) + return Response({"message": "Updated successfully"}) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class PageDuplicateEndpoint(BaseAPIView): + permission_classes = [ProjectPagePermission] + + def post(self, request, slug, project_id, page_id): + page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first() + + # check for permission + if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id: + return Response({"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN) + + # get all the project ids where page is present + project_ids = ProjectPage.objects.filter(page_id=page_id).values_list("project_id", flat=True) + + page.pk = None + page.name = f"{page.name} (Copy)" + page.description_binary = None + page.owned_by = request.user + page.created_by = request.user + page.updated_by = request.user + page.save() + + for project_id in project_ids: + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + + page_transaction.delay( + new_description_html=page.description_html, + old_description_html=None, + page_id=page.id, + ) + + # Copy the s3 objects uploaded in the page + copy_s3_objects_of_description_and_assets.delay( + entity_name="PAGE", + entity_identifier=page.id, + project_id=project_id, + slug=slug, + user_id=request.user.id, + ) + + page = ( + Page.objects.filter(pk=page.id) + .annotate( + project_ids=Coalesce( + ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .first() + ) + serializer = PageDetailSerializer(page) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/apps/api/plane/app/views/page/version.py b/apps/api/plane/app/views/page/version.py new file mode 100644 index 00000000..1b285c96 --- /dev/null +++ b/apps/api/plane/app/views/page/version.py @@ -0,0 +1,27 @@ +# Third party imports +from rest_framework import status +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.permissions import ProjectPagePermission + + +class PageVersionEndpoint(BaseAPIView): + permission_classes = [ProjectPagePermission] + + 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) + # 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) + # Serialize the page versions + serializer = PageVersionSerializer(page_versions, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py new file mode 100644 index 00000000..84b2a562 --- /dev/null +++ b/apps/api/plane/app/views/project/base.py @@ -0,0 +1,640 @@ +# Python imports +import boto3 +from django.conf import settings +from django.utils import timezone +import json + +# Django imports +from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery +from django.core.serializers.json import DjangoJSONEncoder + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny + +# Module imports +from plane.app.views.base import BaseViewSet, BaseAPIView +from plane.app.serializers import ( + ProjectSerializer, + ProjectListSerializer, + DeployBoardSerializer, +) + +from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE +from plane.db.models import ( + UserFavorite, + Intake, + DeployBoard, + IssueUserProperty, + Project, + ProjectIdentifier, + ProjectMember, + State, + Workspace, + WorkspaceMember, +) +from plane.utils.cache import cache_response +from plane.bgtasks.webhook_task import model_activity, webhook_activity +from plane.bgtasks.recent_visited_task import recent_visited_task +from plane.utils.exception_logger import log_exception +from plane.utils.host import base_host + + +class ProjectViewSet(BaseViewSet): + serializer_class = ProjectListSerializer + model = Project + webhook_event = "project" + use_read_replica = True + + def get_queryset(self): + sort_order = ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ).values("sort_order") + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "workspace__owner", "default_assignee", "project_lead") + .annotate( + is_favorite=Exists( + UserFavorite.objects.filter( + user=self.request.user, + entity_identifier=OuterRef("pk"), + entity_type="project", + project_id=OuterRef("pk"), + ) + ) + ) + .annotate( + member_role=ProjectMember.objects.filter( + project_id=OuterRef("pk"), + member_id=self.request.user.id, + is_active=True, + ).values("role") + ) + .annotate( + anchor=DeployBoard.objects.filter( + entity_name="project", + entity_identifier=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ).values("anchor") + ) + .annotate(sort_order=Subquery(sort_order)) + .prefetch_related( + Prefetch( + "project_projectmember", + queryset=ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), is_active=True + ).select_related("member"), + to_attr="members_list", + ) + ) + .distinct() + ) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def list_detail(self, request, slug): + 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=ROLE.GUEST.value, + ).exists(): + projects = projects.filter( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + + if WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.MEMBER.value, + ).exists(): + projects = projects.filter( + Q( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + | Q(network=2) + ) + + if request.GET.get("per_page", False) and request.GET.get("cursor", False): + return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), + request=request, + queryset=(projects), + on_results=lambda projects: ProjectListSerializer(projects, many=True).data, + ) + + projects = ProjectListSerializer(projects, many=True, fields=fields if fields else None).data + return Response(projects, status=status.HTTP_200_OK) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def list(self, request, slug): + sort_order = ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ).values("sort_order") + + projects = ( + Project.objects.filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "workspace__owner", "default_assignee", "project_lead") + .annotate( + member_role=ProjectMember.objects.filter( + project_id=OuterRef("pk"), + member_id=self.request.user.id, + is_active=True, + ).values("role") + ) + .annotate(inbox_view=F("intake_view")) + .annotate(sort_order=Subquery(sort_order)) + .distinct() + ).values( + "id", + "name", + "identifier", + "sort_order", + "logo_props", + "member_role", + "archived_at", + "workspace", + "cycle_view", + "issue_views_view", + "module_view", + "page_view", + "inbox_view", + "guest_view_all_features", + "project_lead", + "network", + "created_at", + "updated_at", + "created_by", + "updated_by", + ) + + if WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.GUEST.value, + ).exists(): + projects = projects.filter( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + + if WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.MEMBER.value, + ).exists(): + projects = projects.filter( + Q( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + | Q(network=2) + ) + return Response(projects, status=status.HTTP_200_OK) + + @allow_permission(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) + ).first() + + if project is None: + return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND) + + recent_visited_task.delay( + slug=slug, + project_id=pk, + entity_name="project", + entity_identifier=pk, + user_id=request.user.id, + ) + + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + serializer = ProjectSerializer(data={**request.data}, context={"workspace_id": workspace.id}) + if serializer.is_valid(): + serializer.save() + + # Add the user as Administrator to the project + _ = ProjectMember.objects.create( + project_id=serializer.data["id"], + member=request.user, + role=ROLE.ADMIN.value, + ) + # Also create the issue property for the user + _ = IssueUserProperty.objects.create(project_id=serializer.data["id"], user=request.user) + + if serializer.data["project_lead"] is not None and str(serializer.data["project_lead"]) != str( + request.user.id + ): + ProjectMember.objects.create( + project_id=serializer.data["id"], + member_id=serializer.data["project_lead"], + role=ROLE.ADMIN.value, + ) + # Also create the issue property for the user + IssueUserProperty.objects.create( + project_id=serializer.data["id"], + user_id=serializer.data["project_lead"], + ) + + # Default states + states = [ + { + "name": "Backlog", + "color": "#60646C", + "sequence": 15000, + "group": "backlog", + "default": True, + }, + { + "name": "Todo", + "color": "#60646C", + "sequence": 25000, + "group": "unstarted", + }, + { + "name": "In Progress", + "color": "#F59E0B", + "sequence": 35000, + "group": "started", + }, + { + "name": "Done", + "color": "#46A758", + "sequence": 45000, + "group": "completed", + }, + { + "name": "Cancelled", + "color": "#9AA4BC", + "sequence": 55000, + "group": "cancelled", + }, + ] + + State.objects.bulk_create( + [ + State( + name=state["name"], + color=state["color"], + project=serializer.instance, + sequence=state["sequence"], + workspace=serializer.instance.workspace, + group=state["group"], + default=state.get("default", False), + created_by=request.user, + ) + for state in states + ] + ) + + project = self.get_queryset().filter(pk=serializer.data["id"]).first() + + # Create the model activity + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, slug, pk=None): + # try: + is_workspace_admin = WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.ADMIN.value, + ).exists() + + is_project_admin = ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + project_id=pk, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + + # Return error for if the user is neither workspace admin nor project admin + if not is_project_admin and not is_workspace_admin: + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) + + 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) + if project.archived_at: + return Response( + {"error": "Archived projects cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = ProjectSerializer( + project, + data={**request.data, "intake_view": intake_view}, + context={"workspace_id": workspace.id}, + partial=True, + ) + + if serializer.is_valid(): + serializer.save() + if intake_view: + intake = Intake.objects.filter(project=project, is_default=True).first() + if not intake: + Intake.objects.create( + name=f"{project.name} Intake", + project=project, + is_default=True, + ) + + project = self.get_queryset().filter(pk=serializer.data["id"]).first() + + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, pk): + if ( + WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.ADMIN.value, + ).exists() + or ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + project_id=pk, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ): + project = Project.objects.get(pk=pk, workspace__slug=slug) + project.delete() + webhook_activity.delay( + event="project", + verb="deleted", + field=None, + old_value=None, + new_value=None, + actor_id=request.user.id, + slug=slug, + current_site=base_host(request=request, is_app=True), + event_id=project.id, + old_identifier=None, + new_identifier=None, + ) + # Delete the project members + DeployBoard.objects.filter(project_id=pk, workspace__slug=slug).delete() + + # Delete the user favorite + UserFavorite.objects.filter(project_id=pk, workspace__slug=slug).delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + else: + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) + + +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() + return Response({"archived_at": str(project.archived_at)}, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def delete(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.archived_at = None + project.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectIdentifierEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request, slug): + name = request.GET.get("name", "").strip().upper() + + if name == "": + return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST) + + exists = ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).values("id", "name", "project") + + return Response({"exists": len(exists), "identifiers": exists}, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def delete(self, request, slug): + name = request.data.get("name", "").strip().upper() + + if name == "": + return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST) + + if Project.objects.filter(identifier=name, workspace__slug=slug).exists(): + return Response( + {"error": "Cannot delete an identifier of an existing project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectUserViewsEndpoint(BaseAPIView): + def post(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + project_member = ProjectMember.objects.filter(member=request.user, project=project, is_active=True).first() + + if project_member is None: + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + + view_props = project_member.view_props + default_props = project_member.default_props + preferences = project_member.preferences + sort_order = project_member.sort_order + + project_member.view_props = request.data.get("view_props", view_props) + project_member.default_props = request.data.get("default_props", default_props) + project_member.preferences = request.data.get("preferences", preferences) + project_member.sort_order = request.data.get("sort_order", sort_order) + + project_member.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectFavoritesViewSet(BaseViewSet): + model = UserFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("project", "project__project_lead", "project__default_assignee") + .select_related("workspace", "workspace__owner") + ) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def create(self, request, slug): + _ = UserFavorite.objects.create( + user=request.user, + entity_type="project", + entity_identifier=request.data.get("project"), + project_id=request.data.get("project"), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + def destroy(self, request, slug, project_id): + project_favorite = UserFavorite.objects.get( + entity_identifier=project_id, + entity_type="project", + project=project_id, + user=request.user, + workspace__slug=slug, + ) + project_favorite.delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectPublicCoverImagesEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + # Cache the below api for 24 hours + @cache_response(60 * 60 * 24, user=False) + def get(self, request): + files = [] + if settings.USE_MINIO: + s3 = boto3.client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) + else: + s3 = boto3.client( + "s3", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) + params = { + "Bucket": settings.AWS_STORAGE_BUCKET_NAME, + "Prefix": "static/project-cover/", + } + + 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) + except Exception as e: + log_exception(e) + return Response([], status=status.HTTP_200_OK) + + +class DeployBoardViewSet(BaseViewSet): + 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 + ).first() + + serializer = DeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) + + def create(self, request, slug, project_id): + comments = request.data.get("is_comments_enabled", False) + reactions = request.data.get("is_reactions_enabled", False) + intake = request.data.get("intake", None) + votes = request.data.get("is_votes_enabled", False) + views = request.data.get( + "views", + { + "list": True, + "kanban": True, + "calendar": True, + "gantt": True, + "spreadsheet": True, + }, + ) + + project_deploy_board, _ = DeployBoard.objects.get_or_create( + entity_name="project", entity_identifier=project_id, project_id=project_id + ) + 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 + project_deploy_board.is_reactions_enabled = reactions + + project_deploy_board.save() + + serializer = DeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/project/invite.py b/apps/api/plane/app/views/project/invite.py new file mode 100644 index 00000000..cc5b3f4b --- /dev/null +++ b/apps/api/plane/app/views/project/invite.py @@ -0,0 +1,250 @@ +# Python imports +import jwt +from datetime import datetime + +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.conf import settings +from django.utils import timezone + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny + +# Module imports +from .base import BaseViewSet, BaseAPIView +from plane.app.serializers import ProjectMemberInviteSerializer +from plane.app.permissions import allow_permission, ROLE +from plane.db.models import ( + ProjectMember, + Workspace, + ProjectMemberInvite, + User, + WorkspaceMember, + Project, + IssueUserProperty, +) +from plane.db.models.project import ProjectNetwork +from plane.utils.host import base_host + + +class ProjectInvitationsViewset(BaseViewSet): + serializer_class = ProjectMemberInviteSerializer + model = ProjectMemberInvite + + search_fields = [] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .select_related("project") + .select_related("workspace", "workspace__owner") + ) + + @allow_permission([ROLE.ADMIN]) + def create(self, request, slug, project_id): + emails = request.data.get("emails", []) + + # Check if email is provided + if not emails: + return Response({"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST) + + for email in emails: + workspace_role = WorkspaceMember.objects.filter( + 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): + return Response({"error": "You cannot invite a user with different role than workspace role"}) + + workspace = Workspace.objects.get(slug=slug) + + project_invitations = [] + for email in emails: + try: + validate_email(email.get("email")) + project_invitations.append( + ProjectMemberInvite( + email=email.get("email").strip().lower(), + project_id=project_id, + workspace_id=workspace.id, + token=jwt.encode( + {"email": email, "timestamp": datetime.now().timestamp()}, + settings.SECRET_KEY, + algorithm="HS256", + ), + role=email.get("role", 5), + created_by=request.user, + ) + ) + except ValidationError: + return Response( + { + "error": f"Invalid email - {email} provided a valid email address is required to send the invite" # noqa: E501 + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create workspace member invite + project_invitations = ProjectMemberInvite.objects.bulk_create( + project_invitations, batch_size=10, ignore_conflicts=True + ) + current_site = base_host(request=request, is_app=True) + + # Send invitations + for invitation in project_invitations: + project_invitations.delay( + invitation.email, + project_id, + invitation.token, + current_site, + request.user.email, + ) + + return Response({"message": "Email sent successfully"}, status=status.HTTP_200_OK) + + +class UserProjectInvitationsViewset(BaseViewSet): + serializer_class = ProjectMemberInviteSerializer + model = ProjectMemberInvite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(email=self.request.user.email) + .select_related("workspace", "workspace__owner", "project") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def create(self, request, slug): + project_ids = request.data.get("project_ids", []) + + # Get the workspace user role + workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True) + + # Get all the projects + projects = Project.objects.filter(id__in=project_ids, workspace__slug=slug).only("id", "network") + # Check if user has permission to join each project + for project in projects: + if project.network == ProjectNetwork.SECRET.value and workspace_member.role != ROLE.ADMIN.value: + return Response( + {"error": "Only workspace admins can join private 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).update( + is_active=True + ) + + ProjectMember.objects.bulk_create( + [ + ProjectMember( + project_id=project_id, + member=request.user, + role=workspace_role, + workspace=workspace, + created_by=request.user, + ) + for project_id in project_ids + ], + ignore_conflicts=True, + ) + + IssueUserProperty.objects.bulk_create( + [ + IssueUserProperty( + project_id=project_id, + user=request.user, + workspace=workspace, + created_by=request.user, + ) + for project_id in project_ids + ], + ignore_conflicts=True, + ) + + return Response({"message": "Projects joined successfully"}, status=status.HTTP_201_CREATED) + + +class ProjectJoinEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def post(self, request, slug, project_id, pk): + project_invite = ProjectMemberInvite.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) + + email = request.data.get("email", "") + + if email == "" or project_invite.email != email: + return Response( + {"error": "You do not have permission to join the project"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if project_invite.responded_at is None: + project_invite.accepted = request.data.get("accepted", False) + project_invite.responded_at = timezone.now() + project_invite.save() + + if project_invite.accepted: + # Check if the user account exists + user = User.objects.filter(email=email).first() + + # Check if user is a part of workspace + workspace_member = WorkspaceMember.objects.filter(workspace__slug=slug, member=user).first() + # Add him to workspace + if workspace_member is None: + _ = WorkspaceMember.objects.create( + workspace_id=project_invite.workspace_id, + member=user, + role=(15 if project_invite.role >= 15 else project_invite.role), + ) + else: + # Else make him active + workspace_member.is_active = True + workspace_member.save() + + # Check if the user was already a member of project then activate the user + project_member = ProjectMember.objects.filter( + workspace_id=project_invite.workspace_id, member=user + ).first() + if project_member is None: + # Create a Project Member + _ = ProjectMember.objects.create( + workspace_id=project_invite.workspace_id, + member=user, + role=project_invite.role, + ) + else: + project_member.is_active = True + project_member.role = project_member.role + project_member.save() + + return Response( + {"message": "Project Invitation Accepted"}, + status=status.HTTP_200_OK, + ) + + return Response( + {"message": "Project Invitation was not accepted"}, + status=status.HTTP_200_OK, + ) + + return Response( + {"error": "You have already responded to the invitation request"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def get(self, request, slug, project_id, pk): + project_invitation = ProjectMemberInvite.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + serializer = ProjectMemberInviteSerializer(project_invitation) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py new file mode 100644 index 00000000..0fc19ade --- /dev/null +++ b/apps/api/plane/app/views/project/member.py @@ -0,0 +1,302 @@ +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .base import BaseViewSet, BaseAPIView +from plane.app.serializers import ( + ProjectMemberSerializer, + ProjectMemberAdminSerializer, + ProjectMemberRoleSerializer, +) + +from plane.app.permissions import WorkspaceUserPermission + +from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember +from plane.bgtasks.project_add_user_email_task import project_add_user_email +from plane.utils.host import base_host +from plane.app.permissions.base import allow_permission, ROLE + + +class ProjectMemberViewSet(BaseViewSet): + serializer_class = ProjectMemberAdminSerializer + model = ProjectMember + + search_fields = ["member__display_name", "member__first_name"] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(member__is_bot=False) + .filter() + .select_related("project") + .select_related("member") + .select_related("workspace", "workspace__owner") + ) + + @allow_permission([ROLE.ADMIN]) + def create(self, request, slug, project_id): + # Get the list of members to be added to the project and their roles i.e. the user_id and the role + members = request.data.get("members", []) + + # get the project + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + # Check if the members array is empty + if not len(members): + return Response( + {"error": "At least one member is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Initialize the bulk arrays + bulk_project_members = [] + bulk_issue_props = [] + + # Create a dictionary of the member_id and their roles + member_roles = {member.get("member_id"): member.get("role") for member in members} + + # 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 + ).role + if workspace_member_role in [20] and member_roles.get(member) in [5, 15]: + return Response( + {"error": "You cannot add a user with role lower than the workspace role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if workspace_member_role in [5] and member_roles.get(member) in [15, 20]: + return Response( + {"error": "You cannot add a user with role higher than the workspace role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Update roles in the members array based on the member_roles dictionary and set is_active to True + for project_member in ProjectMember.objects.filter( + project_id=project_id, + member_id__in=[member.get("member_id") for member in members], + ): + project_member.role = member_roles[str(project_member.member_id)] + project_member.is_active = True + bulk_project_members.append(project_member) + + # Update the roles of the existing members + ProjectMember.objects.bulk_update(bulk_project_members, ["is_active", "role"], batch_size=100) + + # Get the list of project members of the requested workspace with the given slug + project_members = ( + ProjectMember.objects.filter( + workspace__slug=slug, + member_id__in=[member.get("member_id") for member in members], + ) + .values("member_id", "sort_order") + .order_by("sort_order") + ) + + # Loop through requested members + for member in members: + # Get the sort orders of the member + sort_order = [ + project_member.get("sort_order") + for project_member in project_members + if str(project_member.get("member_id")) == str(member.get("member_id")) + ] + # Create a new project member + bulk_project_members.append( + ProjectMember( + member_id=member.get("member_id"), + role=member.get("role", 5), + project_id=project_id, + workspace_id=project.workspace_id, + sort_order=(sort_order[0] - 10000 if len(sort_order) else 65535), + ) + ) + # Create a new issue property + bulk_issue_props.append( + IssueUserProperty( + user_id=member.get("member_id"), + project_id=project_id, + workspace_id=project.workspace_id, + ) + ) + + # Bulk create the project members and issue properties + project_members = ProjectMember.objects.bulk_create(bulk_project_members, batch_size=10, ignore_conflicts=True) + + _ = IssueUserProperty.objects.bulk_create(bulk_issue_props, batch_size=10, ignore_conflicts=True) + + project_members = ProjectMember.objects.filter( + project_id=project_id, + member_id__in=[member.get("member_id") for member in members], + ) + # Send emails to notify the users + [ + project_add_user_email.delay( + base_host(request=request, is_app=True), + project_member.id, + request.user.id, + ) + for project_member in project_members + ] + # Serialize the project members + serializer = ProjectMemberRoleSerializer(project_members, many=True) + # Return the serialized data + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def list(self, request, slug, project_id): + # Get the list of project members for the project + project_members = ProjectMember.objects.filter( + project_id=project_id, + workspace__slug=slug, + member__is_bot=False, + is_active=True, + member__member_workspace__workspace__slug=slug, + member__member_workspace__is_active=True, + ).select_related("project", "member", "workspace") + + serializer = ProjectMemberRoleSerializer(project_members, fields=("id", "member", "role"), many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + 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) + + # Fetch the workspace role of the project member + workspace_role = WorkspaceMember.objects.get( + workspace__slug=slug, member=project_member.member, is_active=True + ).role + is_workspace_admin = workspace_role == ROLE.ADMIN.value + + # Check if the user is not editing their own role if they are not an admin + if request.user.id == project_member.member_id and not is_workspace_admin: + return Response( + {"error": "You cannot update your own role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Check while updating user roles + requested_project_member = ProjectMember.objects.get( + project_id=project_id, + workspace__slug=slug, + member=request.user, + is_active=True, + ) + + if workspace_role in [5] and int(request.data.get("role", project_member.role)) in [15, 20]: + return Response( + {"error": "You cannot add a user with role higher than the workspace role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if ( + "role" in request.data + and int(request.data.get("role", project_member.role)) > requested_project_member.role + and not is_workspace_admin + ): + return Response( + {"error": "You cannot update a role that is higher than your own role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission([ROLE.ADMIN]) + def destroy(self, request, slug, project_id, pk): + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + pk=pk, + member__is_bot=False, + is_active=True, + ) + # check requesting user role + requesting_project_member = ProjectMember.objects.get( + workspace__slug=slug, + member=request.user, + project_id=project_id, + is_active=True, + ) + # User cannot remove himself + if str(project_member.id) == str(requesting_project_member.id): + return Response( + {"error": "You cannot remove yourself from the workspace. Please use leave workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # User cannot deactivate higher role + if requesting_project_member.role < project_member.role: + return Response( + {"error": "You cannot remove a user having role higher than you"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + project_member.is_active = False + project_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def leave(self, request, slug, project_id): + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) + + # Check if the leaving user is the only admin of the project + if ( + project_member.role == 20 + and not ProjectMember.objects.filter( + workspace__slug=slug, project_id=project_id, role=20, is_active=True + ).count() + > 1 + ): + return Response( + { + "error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin" # noqa: E501 + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Deactivate the user + project_member.is_active = False + project_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectMemberUserEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + project_member = ProjectMember.objects.get( + project_id=project_id, + workspace__slug=slug, + member=request.user, + is_active=True, + ) + serializer = ProjectMemberSerializer(project_member) + + return Response(serializer.data, status=status.HTTP_200_OK) + + +class UserProjectRolesEndpoint(BaseAPIView): + permission_classes = [WorkspaceUserPermission] + use_read_replica = True + + def get(self, request, slug): + project_members = ProjectMember.objects.filter( + workspace__slug=slug, + member_id=request.user.id, + is_active=True, + member__member_workspace__workspace__slug=slug, + member__member_workspace__is_active=True, + ).values("project_id", "role") + + project_members = {str(member["project_id"]): member["role"] for member in project_members} + return Response(project_members, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py new file mode 100644 index 00000000..a598d1ee --- /dev/null +++ b/apps/api/plane/app/views/search/base.py @@ -0,0 +1,685 @@ +# Python imports +import re + +# Django imports +from django.db import models +from django.db.models import ( + Q, + OuterRef, + Subquery, + Value, + UUIDField, + CharField, + When, + Case, +) +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce, Concat +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.views.base import BaseAPIView +from plane.db.models import ( + Workspace, + Project, + Issue, + Cycle, + Module, + Page, + IssueView, + ProjectMember, + ProjectPage, + WorkspaceMember, +) + + +class GlobalSearchEndpoint(BaseAPIView): + """Endpoint to search across multiple fields in the workspace and + also show related workspace if found + """ + + def filter_workspaces(self, query, slug, project_id, workspace_search): + fields = ["name"] + q = Q() + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + return ( + Workspace.objects.filter(q, workspace_member__member=self.request.user) + .distinct() + .values("name", "id", "slug") + ) + + def filter_projects(self, query, slug, project_id, workspace_search): + fields = ["name", "identifier"] + q = Q() + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + return ( + Project.objects.filter( + q, + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + archived_at__isnull=True, + workspace__slug=slug, + ) + .distinct() + .values("name", "id", "identifier", "workspace__slug") + ) + + def filter_issues(self, query, slug, project_id, workspace_search): + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + workspace__slug=slug, + ) + + if workspace_search == "false" and project_id: + issues = issues.filter(project_id=project_id) + + return issues.distinct().values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "workspace__slug", + )[:100] + + def filter_cycles(self, query, slug, project_id, workspace_search): + fields = ["name"] + q = Q() + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + cycles = Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + workspace__slug=slug, + ) + + if workspace_search == "false" and project_id: + cycles = cycles.filter(project_id=project_id) + + return cycles.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") + + def filter_modules(self, query, slug, project_id, workspace_search): + fields = ["name"] + q = Q() + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + modules = Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + workspace__slug=slug, + ) + + if workspace_search == "false" and project_id: + modules = modules.filter(project_id=project_id) + + return modules.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") + + def filter_pages(self, query, slug, project_id, workspace_search): + fields = ["name"] + q = Q() + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, + workspace__slug=slug, + ) + .annotate( + project_ids=Coalesce( + ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .annotate( + project_identifiers=Coalesce( + ArrayAgg( + "projects__identifier", + distinct=True, + filter=~Q(projects__id=True), + ), + Value([], output_field=ArrayField(CharField())), + ) + ) + ) + + if workspace_search == "false" and project_id: + project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list( + "project_id", flat=True + )[:1] + + pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id) + + return pages.distinct().values("name", "id", "project_ids", "project_identifiers", "workspace__slug") + + def filter_views(self, query, slug, project_id, workspace_search): + fields = ["name"] + q = Q() + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + issue_views = IssueView.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + workspace__slug=slug, + ) + + if workspace_search == "false" and project_id: + issue_views = issue_views.filter(project_id=project_id) + + return issue_views.distinct().values("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") + project_id = request.query_params.get("project_id", False) + + if not query: + return Response( + { + "results": { + "workspace": [], + "project": [], + "issue": [], + "cycle": [], + "module": [], + "issue_view": [], + "page": [], + } + }, + status=status.HTTP_200_OK, + ) + + MODELS_MAPPER = { + "workspace": self.filter_workspaces, + "project": self.filter_projects, + "issue": self.filter_issues, + "cycle": self.filter_cycles, + "module": self.filter_modules, + "issue_view": self.filter_views, + "page": self.filter_pages, + } + + results = {} + + for model in MODELS_MAPPER.keys(): + func = MODELS_MAPPER.get(model, None) + results[model] = func(query, slug, project_id, workspace_search) + return Response({"results": results}, status=status.HTTP_200_OK) + + +class SearchEndpoint(BaseAPIView): + def get(self, request, slug): + query = request.query_params.get("query", False) + query_types = request.query_params.get("query_type", "user_mention").split(",") + query_types = [qt.strip() for qt in query_types] + count = int(request.query_params.get("count", 5)) + project_id = request.query_params.get("project_id", None) + issue_id = request.query_params.get("issue_id", None) + + response_data = {} + + if project_id: + for query_type in query_types: + if query_type == "user_mention": + fields = [ + "member__first_name", + "member__last_name", + "member__display_name", + ] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + users = ( + ProjectMember.objects.filter( + q, + is_active=True, + workspace__slug=slug, + member__is_bot=False, + project_id=project_id, + ) + .annotate( + member__avatar_url=Case( + When( + member__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "member__avatar_asset", + Value("/"), + ), + ), + When( + member__avatar_asset__isnull=True, + then="member__avatar", + ), + default=Value(None), + output_field=CharField(), + ) + ) + .order_by("-created_at") + ) + + if issue_id: + issue_created_by = ( + Issue.objects.filter(id=issue_id).values_list("created_by_id", flat=True).first() + ) + users = ( + users.filter(Q(role__gt=10) | Q(member_id=issue_created_by)) + .distinct() + .values( + "member__avatar_url", + "member__display_name", + "member__id", + ) + ) + else: + users = ( + users.filter(Q(role__gt=10)) + .distinct() + .values( + "member__avatar_url", + "member__display_name", + "member__id", + ) + ) + + response_data["user_mention"] = list(users[:count]) + + elif query_type == "project": + fields = ["name", "identifier"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + projects = ( + Project.objects.filter( + q, + Q(project_projectmember__member=self.request.user) | Q(network=2), + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] + ) + response_data["project"] = list(projects) + + elif query_type == "issue": + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + + if query: + for field in fields: + if field == "sequence_id": + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = ( + Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "priority", + "state_id", + "type_id", + )[:count] + ) + response_data["issue"] = list(issues) + + elif query_type == "cycle": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + cycles = ( + Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + 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), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["cycle"] = list(cycles) + + elif query_type == "module": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + modules = ( + Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["module"] = list(modules) + + elif query_type == "page": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__id=project_id, + workspace__slug=slug, + access=0, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "logo_props", + "projects__id", + "workspace__slug", + )[:count] + ) + response_data["page"] = list(pages) + return Response(response_data, status=status.HTTP_200_OK) + + else: + for query_type in query_types: + if query_type == "user_mention": + fields = [ + "member__first_name", + "member__last_name", + "member__display_name", + ] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + users = ( + WorkspaceMember.objects.filter( + q, + is_active=True, + workspace__slug=slug, + member__is_bot=False, + ) + .annotate( + member__avatar_url=Case( + When( + member__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "member__avatar_asset", + Value("/"), + ), + ), + When( + member__avatar_asset__isnull=True, + then="member__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .order_by("-created_at") + .values("member__avatar_url", "member__display_name", "member__id")[:count] + ) + response_data["user_mention"] = list(users) + + elif query_type == "project": + fields = ["name", "identifier"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + projects = ( + Project.objects.filter( + q, + Q(project_projectmember__member=self.request.user) | Q(network=2), + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] + ) + response_data["project"] = list(projects) + + elif query_type == "issue": + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + + if query: + for field in fields: + if field == "sequence_id": + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = ( + Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "priority", + "state_id", + "type_id", + )[:count] + ) + response_data["issue"] = list(issues) + + elif query_type == "cycle": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + cycles = ( + Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + 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), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["cycle"] = list(cycles) + + elif query_type == "module": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + modules = ( + Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["module"] = list(modules) + + elif query_type == "page": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + workspace__slug=slug, + access=0, + is_global=True, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "logo_props", + "projects__id", + "workspace__slug", + )[:count] + ) + response_data["page"] = list(pages) + return Response(response_data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/search/issue.py b/apps/api/plane/app/views/search/issue.py new file mode 100644 index 00000000..ac9d783a --- /dev/null +++ b/apps/api/plane/app/views/search/issue.py @@ -0,0 +1,157 @@ +# Django imports +from django.db.models import Q, QuerySet + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import Issue, ProjectMember, IssueRelation +from plane.utils.issue_search import search_issues + + +class IssueSearchEndpoint(BaseAPIView): + def filter_issues_by_project(self, project_id: int, issues: QuerySet) -> QuerySet: + """ + Filter issues by project + """ + + issues = issues.filter(project_id=project_id) + + return issues + + def search_issues_by_query(self, query: str, issues: QuerySet) -> QuerySet: + """ + Search issues by query + """ + + issues = search_issues(query, issues) + + return issues + + def search_issues_and_excluding_parent(self, issues: QuerySet, issue_id: str) -> QuerySet: + """ + Search issues and epics by query excluding the parent + """ + + 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)) + return issues + + def filter_issues_excluding_related_issues(self, issue_id: str, issues: QuerySet) -> QuerySet: + """ + Filter issues excluding related issues + """ + + issue = Issue.issue_objects.filter(pk=issue_id).first() + related_issue_ids = ( + IssueRelation.objects.filter(Q(related_issue=issue) | Q(issue=issue)) + .values_list("issue_id", "related_issue_id") + .distinct() + ) + + related_issue_ids = [item for sublist in related_issue_ids for item in sublist] + related_issue_ids.append(issue_id) + + if issue: + issues = issues.exclude(pk__in=related_issue_ids) + + return issues + + def filter_root_issues_only(self, issue_id: str, issues: QuerySet) -> QuerySet: + """ + Filter root issues only + """ + 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)) + return issues + + def exclude_issues_in_cycles(self, issues: QuerySet) -> QuerySet: + """ + Exclude issues in cycles + """ + issues = issues.exclude(Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True)) + return issues + + def exclude_issues_in_module(self, issues: QuerySet, module: str) -> QuerySet: + """ + Exclude issues in a module + """ + issues = issues.exclude(Q(issue_module__module=module) & Q(issue_module__deleted_at__isnull=True)) + return issues + + def filter_issues_without_target_date(self, issues: QuerySet) -> QuerySet: + """ + Filter issues without a target date + """ + issues = issues.filter(target_date__isnull=True) + return issues + + def get(self, request, slug, project_id): + query = request.query_params.get("search", False) + workspace_search = request.query_params.get("workspace_search", "false") + parent = request.query_params.get("parent", "false") + issue_relation = request.query_params.get("issue_relation", "false") + cycle = request.query_params.get("cycle", "false") + module = request.query_params.get("module", False) + sub_issue = request.query_params.get("sub_issue", "false") + target_date = request.query_params.get("target_date", True) + issue_id = request.query_params.get("issue_id", False) + + issues = Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + + if workspace_search == "false": + issues = self.filter_issues_by_project(project_id, issues) + + if query: + issues = self.search_issues_by_query(query, issues) + + if parent == "true" and issue_id: + issues = self.search_issues_and_excluding_parent(issues, issue_id) + + if issue_relation == "true" and issue_id: + issues = self.filter_issues_excluding_related_issues(issue_id, issues) + + if sub_issue == "true" and issue_id: + issues = self.filter_root_issues_only(issue_id, issues) + + if cycle == "true": + issues = self.exclude_issues_in_cycles(issues) + + if module: + issues = self.exclude_issues_in_module(issues, module) + + if target_date == "none": + issues = self.filter_issues_without_target_date(issues) + + if ProjectMember.objects.filter( + project_id=project_id, member=self.request.user, is_active=True, role=5 + ).exists(): + issues = issues.filter(created_by=self.request.user) + + return Response( + issues.values( + "name", + "id", + "start_date", + "sequence_id", + "project__name", + "project__identifier", + "project_id", + "workspace__slug", + "state__name", + "state__group", + "state__color", + )[:100], + status=status.HTTP_200_OK, + ) diff --git a/apps/api/plane/app/views/state/base.py b/apps/api/plane/app/views/state/base.py new file mode 100644 index 00000000..de8d9395 --- /dev/null +++ b/apps/api/plane/app/views/state/base.py @@ -0,0 +1,129 @@ +# Python imports +from itertools import groupby +from collections import defaultdict + +# Django imports +from django.db.utils import IntegrityError + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseViewSet +from plane.app.serializers import StateSerializer +from plane.app.permissions import ROLE, allow_permission +from plane.db.models import State, Issue +from plane.utils.cache import invalidate_cache + + +class StateViewSet(BaseViewSet): + serializer_class = StateSerializer + model = State + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .filter(is_triage=False) + .select_related("project") + .select_related("workspace") + .distinct() + ) + + @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) + @allow_permission([ROLE.ADMIN]) + def create(self, request, slug, project_id): + try: + serializer = StateSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The state name is already taken"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def partial_update(self, request, slug, project_id, pk): + try: + state = State.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) + serializer = StateSerializer(state, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The state name is already taken"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def list(self, request, slug, project_id): + states = StateSerializer(self.get_queryset(), many=True).data + + grouped_states = defaultdict(list) + for state in states: + grouped_states[state["group"]].append(state) + + for group, group_states in grouped_states.items(): + count = len(group_states) + + for index, state in enumerate(group_states, start=1): + state["order"] = index / count + + grouped = request.GET.get("grouped", False) + + if grouped == "true": + state_dict = {} + for key, value in groupby( + sorted(states, key=lambda state: state["group"]), + lambda state: state.get("group"), + ): + state_dict[str(key)] = list(value) + return Response(state_dict, status=status.HTTP_200_OK) + + return Response(states, status=status.HTTP_200_OK) + + @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 + _ = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).update(default=False) + _ = State.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk).update(default=True) + return Response(status=status.HTTP_204_NO_CONTENT) + + @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) + + if state.default: + return Response( + {"error": "Default state cannot be deleted"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check for any issues in the state + issue_exist = Issue.issue_objects.filter(state=pk).exists() + + if issue_exist: + return Response( + {"error": "The state is not empty, only empty states can be deleted"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + state.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/timezone/base.py b/apps/api/plane/app/views/timezone/base.py new file mode 100644 index 00000000..fc012117 --- /dev/null +++ b/apps/api/plane/app/views/timezone/base.py @@ -0,0 +1,211 @@ +# Python imports +import pytz +from datetime import datetime + +# Django imports +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView + +# Module imports +from plane.authentication.rate_limit import AuthenticationThrottle + + +class TimezoneEndpoint(APIView): + permission_classes = [AllowAny] + + throttle_classes = [AuthenticationThrottle] + + @method_decorator(cache_page(60 * 60 * 2)) + def get(self, request): + timezone_locations = [ + ("Midway Island", "Pacific/Midway"), # UTC-11:00 + ("American Samoa", "Pacific/Pago_Pago"), # UTC-11:00 + ("Hawaii", "Pacific/Honolulu"), # UTC-10:00 + ("Aleutian Islands", "America/Adak"), # UTC-10:00 (DST: UTC-09:00) + ("Marquesas Islands", "Pacific/Marquesas"), # UTC-09:30 + ("Alaska", "America/Anchorage"), # UTC-09:00 (DST: UTC-08:00) + ("Gambier Islands", "Pacific/Gambier"), # UTC-09:00 + ( + "Pacific Time (US and Canada)", + "America/Los_Angeles", + ), # UTC-08:00 (DST: UTC-07:00) + ("Baja California", "America/Tijuana"), # UTC-08:00 (DST: UTC-07:00) + ( + "Mountain Time (US and Canada)", + "America/Denver", + ), # UTC-07:00 (DST: UTC-06:00) + ("Arizona", "America/Phoenix"), # UTC-07:00 + ("Chihuahua, Mazatlan", "America/Chihuahua"), # UTC-07:00 (DST: UTC-06:00) + ( + "Central Time (US and Canada)", + "America/Chicago", + ), # UTC-06:00 (DST: UTC-05:00) + ("Saskatchewan", "America/Regina"), # UTC-06:00 + ( + "Guadalajara, Mexico City, Monterrey", + "America/Mexico_City", + ), # UTC-06:00 (DST: UTC-05:00) + ("Tegucigalpa, Honduras", "America/Tegucigalpa"), # UTC-06:00 + ("Costa Rica", "America/Costa_Rica"), # UTC-06:00 + ( + "Eastern Time (US and Canada)", + "America/New_York", + ), # UTC-05:00 (DST: UTC-04:00) + ("Lima", "America/Lima"), # UTC-05:00 + ("Bogota", "America/Bogota"), # UTC-05:00 + ("Quito", "America/Guayaquil"), # UTC-05:00 + ("Chetumal", "America/Cancun"), # UTC-05:00 (DST: UTC-04:00) + ("Caracas (Old Venezuela Time)", "America/Caracas"), # UTC-04:30 + ("Atlantic Time (Canada)", "America/Halifax"), # UTC-04:00 (DST: UTC-03:00) + ("Caracas", "America/Caracas"), # UTC-04:00 + ("Santiago", "America/Santiago"), # UTC-04:00 (DST: UTC-03:00) + ("La Paz", "America/La_Paz"), # UTC-04:00 + ("Manaus", "America/Manaus"), # UTC-04:00 + ("Georgetown", "America/Guyana"), # UTC-04:00 + ("Bermuda", "Atlantic/Bermuda"), # UTC-04:00 (DST: UTC-03:00) + ( + "Newfoundland Time (Canada)", + "America/St_Johns", + ), # UTC-03:30 (DST: UTC-02:30) + ("Buenos Aires", "America/Argentina/Buenos_Aires"), # UTC-03:00 + ("Brasilia", "America/Sao_Paulo"), # UTC-03:00 + ("Greenland", "America/Godthab"), # UTC-03:00 (DST: UTC-02:00) + ("Montevideo", "America/Montevideo"), # UTC-03:00 + ("Falkland Islands", "Atlantic/Stanley"), # UTC-03:00 + ( + "South Georgia and the South Sandwich Islands", + "Atlantic/South_Georgia", + ), # UTC-02:00 + ("Azores", "Atlantic/Azores"), # UTC-01:00 (DST: UTC+00:00) + ("Cape Verde Islands", "Atlantic/Cape_Verde"), # UTC-01:00 + ("Dublin", "Europe/Dublin"), # UTC+00:00 (DST: UTC+01:00) + ("Reykjavik", "Atlantic/Reykjavik"), # UTC+00:00 + ("Lisbon", "Europe/Lisbon"), # UTC+00:00 (DST: UTC+01:00) + ("Monrovia", "Africa/Monrovia"), # UTC+00:00 + ("Casablanca", "Africa/Casablanca"), # UTC+00:00 (DST: UTC+01:00) + ( + "Central European Time (Berlin, Rome, Paris)", + "Europe/Paris", + ), # UTC+01:00 (DST: UTC+02:00) + ("West Central Africa", "Africa/Lagos"), # UTC+01:00 + ("Algiers", "Africa/Algiers"), # UTC+01:00 + ("Lagos", "Africa/Lagos"), # UTC+01:00 + ("Tunis", "Africa/Tunis"), # UTC+01:00 + ( + "Eastern European Time (Cairo, Helsinki, Kyiv)", + "Europe/Kyiv", + ), # UTC+02:00 (DST: UTC+03:00) + ("Athens", "Europe/Athens"), # UTC+02:00 (DST: UTC+03:00) + ("Jerusalem", "Asia/Jerusalem"), # UTC+02:00 (DST: UTC+03:00) + ("Johannesburg", "Africa/Johannesburg"), # UTC+02:00 + ("Harare, Pretoria", "Africa/Harare"), # UTC+02:00 + ("Moscow Time", "Europe/Moscow"), # UTC+03:00 + ("Baghdad", "Asia/Baghdad"), # UTC+03:00 + ("Nairobi", "Africa/Nairobi"), # UTC+03:00 + ("Kuwait, Riyadh", "Asia/Riyadh"), # UTC+03:00 + ("Tehran", "Asia/Tehran"), # UTC+03:30 (DST: UTC+04:30) + ("Abu Dhabi", "Asia/Dubai"), # UTC+04:00 + ("Baku", "Asia/Baku"), # UTC+04:00 (DST: UTC+05:00) + ("Yerevan", "Asia/Yerevan"), # UTC+04:00 (DST: UTC+05:00) + ("Astrakhan", "Europe/Astrakhan"), # UTC+04:00 + ("Tbilisi", "Asia/Tbilisi"), # UTC+04:00 + ("Mauritius", "Indian/Mauritius"), # UTC+04:00 + ("Kabul", "Asia/Kabul"), # UTC+04:30 + ("Islamabad", "Asia/Karachi"), # UTC+05:00 + ("Karachi", "Asia/Karachi"), # UTC+05:00 + ("Tashkent", "Asia/Tashkent"), # UTC+05:00 + ("Yekaterinburg", "Asia/Yekaterinburg"), # UTC+05:00 + ("Maldives", "Indian/Maldives"), # UTC+05:00 + ("Chagos", "Indian/Chagos"), # UTC+05:00 + ("Chennai", "Asia/Kolkata"), # UTC+05:30 + ("Kolkata", "Asia/Kolkata"), # UTC+05:30 + ("Mumbai", "Asia/Kolkata"), # UTC+05:30 + ("New Delhi", "Asia/Kolkata"), # UTC+05:30 + ("Sri Jayawardenepura", "Asia/Colombo"), # UTC+05:30 + ("Kathmandu", "Asia/Kathmandu"), # UTC+05:45 + ("Dhaka", "Asia/Dhaka"), # UTC+06:00 + ("Almaty", "Asia/Almaty"), # UTC+06:00 + ("Bishkek", "Asia/Bishkek"), # UTC+06:00 + ("Thimphu", "Asia/Thimphu"), # UTC+06:00 + ("Yangon (Rangoon)", "Asia/Yangon"), # UTC+06:30 + ("Cocos Islands", "Indian/Cocos"), # UTC+06:30 + ("Bangkok", "Asia/Bangkok"), # UTC+07:00 + ("Hanoi", "Asia/Ho_Chi_Minh"), # UTC+07:00 + ("Jakarta", "Asia/Jakarta"), # UTC+07:00 + ("Novosibirsk", "Asia/Novosibirsk"), # UTC+07:00 + ("Krasnoyarsk", "Asia/Krasnoyarsk"), # UTC+07:00 + ("Beijing", "Asia/Shanghai"), # UTC+08:00 + ("Singapore", "Asia/Singapore"), # UTC+08:00 + ("Perth", "Australia/Perth"), # UTC+08:00 + ("Hong Kong", "Asia/Hong_Kong"), # UTC+08:00 + ("Ulaanbaatar", "Asia/Ulaanbaatar"), # UTC+08:00 + ("Palau", "Pacific/Palau"), # UTC+08:00 + ("Eucla", "Australia/Eucla"), # UTC+08:45 + ("Tokyo", "Asia/Tokyo"), # UTC+09:00 + ("Seoul", "Asia/Seoul"), # UTC+09:00 + ("Yakutsk", "Asia/Yakutsk"), # UTC+09:00 + ("Adelaide", "Australia/Adelaide"), # UTC+09:30 (DST: UTC+10:30) + ("Darwin", "Australia/Darwin"), # UTC+09:30 + ("Sydney", "Australia/Sydney"), # UTC+10:00 (DST: UTC+11:00) + ("Brisbane", "Australia/Brisbane"), # UTC+10:00 + ("Guam", "Pacific/Guam"), # UTC+10:00 + ("Vladivostok", "Asia/Vladivostok"), # UTC+10:00 + ("Tahiti", "Pacific/Tahiti"), # UTC+10:00 + ("Lord Howe Island", "Australia/Lord_Howe"), # UTC+10:30 (DST: UTC+11:00) + ("Solomon Islands", "Pacific/Guadalcanal"), # UTC+11:00 + ("Magadan", "Asia/Magadan"), # UTC+11:00 + ("Norfolk Island", "Pacific/Norfolk"), # UTC+11:00 + ("Bougainville Island", "Pacific/Bougainville"), # UTC+11:00 + ("Chokurdakh", "Asia/Srednekolymsk"), # UTC+11:00 + ("Auckland", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00) + ("Wellington", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00) + ("Fiji Islands", "Pacific/Fiji"), # UTC+12:00 (DST: UTC+13:00) + ("Anadyr", "Asia/Anadyr"), # UTC+12:00 + ("Chatham Islands", "Pacific/Chatham"), # UTC+12:45 (DST: UTC+13:45) + ("Nuku'alofa", "Pacific/Tongatapu"), # UTC+13:00 + ("Samoa", "Pacific/Apia"), # UTC+13:00 (DST: UTC+14:00) + ("Kiritimati Island", "Pacific/Kiritimati"), # UTC+14:00 + ] + + timezone_list = [] + now = datetime.now() + + # Process timezone mapping + for friendly_name, tz_identifier in timezone_locations: + try: + tz = pytz.timezone(tz_identifier) + current_offset = now.astimezone(tz).strftime("%z") + + # converting and formatting UTC offset to GMT offset + current_utc_offset = now.astimezone(tz).utcoffset() + total_seconds = int(current_utc_offset.total_seconds()) + hours_offset = total_seconds // 3600 + minutes_offset = abs(total_seconds % 3600) // 60 + offset = f"{'+' if hours_offset >= 0 else '-'}{abs(hours_offset):02}:{minutes_offset:02}" + + timezone_value = { + "offset": int(current_offset), + "utc_offset": f"UTC{offset}", + "gmt_offset": f"GMT{offset}", + "value": tz_identifier, + "label": f"{friendly_name}", + } + + timezone_list.append(timezone_value) + except pytz.exceptions.UnknownTimeZoneError: + continue + + # Sort by offset and then by label + timezone_list.sort(key=lambda x: (x["offset"], x["label"])) + + # Remove offset from final output + for tz in timezone_list: + del tz["offset"] + + return Response({"timezones": timezone_list}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/user/base.py b/apps/api/plane/app/views/user/base.py new file mode 100644 index 00000000..c26b63c1 --- /dev/null +++ b/apps/api/plane/app/views/user/base.py @@ -0,0 +1,246 @@ +# Python imports +import uuid + +# Django imports +from django.db.models import Case, Count, IntegerField, Q, When +from django.contrib.auth import logout +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 plane.app.serializers import ( + AccountSerializer, + IssueActivitySerializer, + ProfileSerializer, + UserMeSerializer, + UserMeSettingsSerializer, + UserSerializer, +) +from plane.app.views.base import BaseAPIView, BaseViewSet +from plane.db.models import ( + Account, + IssueActivity, + Profile, + ProjectMember, + User, + WorkspaceMember, + WorkspaceMemberInvite, + Session, +) +from plane.license.models import Instance, InstanceAdmin +from plane.utils.paginator import BasePaginator +from plane.authentication.utils.host import user_ip +from plane.bgtasks.user_deactivation_email_task import user_deactivation_email +from plane.utils.host import base_host +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_control +from django.views.decorators.vary import vary_on_cookie + + +class UserEndpoint(BaseViewSet): + serializer_class = UserSerializer + model = User + use_read_replica = True + + def get_object(self): + return self.request.user + + @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) + + @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) + + def retrieve_instance_admin(self, request): + instance = Instance.objects.first() + is_admin = InstanceAdmin.objects.filter(instance=instance, user=request.user).exists() + return Response({"is_instance_admin": is_admin}, status=status.HTTP_200_OK) + + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + def deactivate(self, request): + # Check all workspace user is active + user = self.get_object() + + # Instance admin check + if InstanceAdmin.objects.filter(user=user).exists(): + return Response( + {"error": "You cannot deactivate your account since you are an instance admin"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + projects_to_deactivate = [] + workspaces_to_deactivate = [] + + projects = ProjectMember.objects.filter(member=request.user, is_active=True).annotate( + other_admin_exists=Count( + Case( + When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), + default=0, + output_field=IntegerField(), + ) + ), + total_members=Count("id"), + ) + + for project in projects: + if project.other_admin_exists > 0 or (project.total_members == 1): + project.is_active = False + projects_to_deactivate.append(project) + else: + return Response( + {"error": "You cannot deactivate account as you are the only admin in some projects."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspaces = WorkspaceMember.objects.filter(member=request.user, is_active=True).annotate( + other_admin_exists=Count( + Case( + When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), + default=0, + output_field=IntegerField(), + ) + ), + total_members=Count("id"), + ) + + for workspace in workspaces: + if workspace.other_admin_exists > 0 or (workspace.total_members == 1): + workspace.is_active = False + workspaces_to_deactivate.append(workspace) + else: + return Response( + {"error": "You cannot deactivate account as you are the only admin in some workspaces."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ProjectMember.objects.bulk_update(projects_to_deactivate, ["is_active"], batch_size=100) + + WorkspaceMember.objects.bulk_update(workspaces_to_deactivate, ["is_active"], batch_size=100) + + # Delete all workspace invites + WorkspaceMemberInvite.objects.filter(email=user.email).delete() + + # Delete all sessions + Session.objects.filter(user_id=request.user.id).delete() + + # Profile updates + profile = Profile.objects.get(user=user) + + # Reset onboarding + profile.last_workspace_id = None + profile.is_tour_completed = False + profile.is_onboarded = False + profile.onboarding_step = { + "workspace_join": False, + "profile_complete": False, + "workspace_create": False, + "workspace_invite": False, + } + profile.save() + + # Reset password + user.is_password_autoset = True + user.set_password(uuid.uuid4().hex) + + # Deactivate the user + user.is_active = False + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save() + + # Send an email to the user + user_deactivation_email.delay(base_host(request=request, is_app=True), user.id) + + # Logout the user + logout(request) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class UserSessionEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def get(self, request): + if request.user.is_authenticated: + user = User.objects.get(pk=request.user.id) + serializer = UserMeSerializer(user) + data = {"is_authenticated": True} + data["user"] = serializer.data + return Response(data, status=status.HTTP_200_OK) + else: + return Response({"is_authenticated": False}, status=status.HTTP_200_OK) + + +class UpdateUserOnBoardedEndpoint(BaseAPIView): + 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) + + +class UpdateUserTourCompletedEndpoint(BaseAPIView): + 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.save() + return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) + + +class UserActivityEndpoint(BaseAPIView, BasePaginator): + def get(self, request): + queryset = IssueActivity.objects.filter(actor=request.user).select_related( + "actor", "workspace", "issue", "project" + ) + + return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), + request=request, + queryset=queryset, + on_results=lambda issue_activities: IssueActivitySerializer(issue_activities, many=True).data, + ) + + +class AccountEndpoint(BaseAPIView): + def get(self, request, pk=None): + if pk: + account = Account.objects.get(pk=pk, user=request.user) + serializer = AccountSerializer(account) + return Response(serializer.data, status=status.HTTP_200_OK) + + account = Account.objects.filter(user=request.user) + serializer = AccountSerializer(account, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, pk): + account = Account.objects.get(pk=pk, user=request.user) + account.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProfileEndpoint(BaseAPIView): + @method_decorator(cache_control(private=True, max_age=12)) + @method_decorator(vary_on_cookie) + def get(self, request): + profile = Profile.objects.get(user=request.user) + serializer = ProfileSerializer(profile) + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request): + profile = Profile.objects.get(user=request.user) + serializer = ProfileSerializer(profile, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py new file mode 100644 index 00000000..98fe04c6 --- /dev/null +++ b/apps/api/plane/app/views/view/base.py @@ -0,0 +1,429 @@ +import copy + +# Django imports +from django.db.models import ( + Exists, + F, + Func, + OuterRef, + Q, + Subquery, + Prefetch, +) +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page +from django.db import transaction + +# 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 IssueViewSerializer, ViewIssueListSerializer +from plane.db.models import ( + Issue, + FileAsset, + IssueLink, + IssueView, + Workspace, + WorkspaceMember, + ProjectMember, + Project, + CycleIssue, + UserRecentVisit, + IssueAssignee, + IssueLabel, + ModuleIssue, +) +from plane.utils.issue_filters import issue_filters +from plane.utils.order_queryset import order_issue_queryset +from plane.bgtasks.recent_visited_task import recent_visited_task +from .. import BaseViewSet +from plane.db.models import UserFavorite +from plane.utils.filters import ComplexFilterBackend +from plane.utils.filters import IssueFilterSet + + +class WorkspaceViewViewSet(BaseViewSet): + serializer_class = IssueViewSerializer + model = IssueView + + def perform_create(self, serializer): + workspace = Workspace.objects.get(slug=self.kwargs.get("slug")) + serializer.save(workspace_id=workspace.id, owned_by=self.request.user) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project__isnull=True) + .filter(Q(owned_by=self.request.user) | Q(access=1)) + .order_by(self.request.GET.get("order_by", "-created_at")) + .distinct() + ) + + @allow_permission(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] + if WorkspaceMember.objects.filter(workspace__slug=slug, member=request.user, role=5, is_active=True).exists(): + queryset = queryset.filter(owned_by=request.user) + views = IssueViewSerializer(queryset, many=True, fields=fields if fields else None).data + return Response(views, status=status.HTTP_200_OK) + + @allow_permission(allowed_roles=[], level="WORKSPACE", creator=True, model=IssueView) + def partial_update(self, request, slug, pk): + with transaction.atomic(): + workspace_view = IssueView.objects.select_for_update().get(pk=pk, workspace__slug=slug) + + if workspace_view.is_locked: + return Response({"error": "view is locked"}, status=status.HTTP_400_BAD_REQUEST) + + # Only update the view if owner is updating + if workspace_view.owned_by_id != request.user.id: + return Response( + {"error": "Only the owner of the view can update the view"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueViewSerializer(workspace_view, data=request.data, partial=True) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, slug, pk): + issue_view = self.get_queryset().filter(pk=pk).first() + serializer = IssueViewSerializer(issue_view) + recent_visited_task.delay( + slug=slug, + project_id=None, + entity_name="view", + entity_identifier=pk, + user_id=request.user.id, + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE", creator=True, model=IssueView) + def destroy(self, request, slug, pk): + workspace_view = IssueView.objects.get(pk=pk, workspace__slug=slug) + + workspace_member = WorkspaceMember.objects.filter( + workspace__slug=slug, member=request.user, role=20, is_active=True + ) + if workspace_member.exists() or workspace_view.owned_by == request.user: + workspace_view.delete() + # Delete the user favorite view + UserFavorite.objects.filter( + workspace__slug=slug, + entity_identifier=pk, + project__isnull=True, + entity_type="view", + ).delete() + else: + return Response( + {"error": "Only admin or owner can delete the view"}, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WorkspaceViewIssuesViewSet(BaseViewSet): + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet + + def _get_project_permission_filters(self): + """ + Get common project permission filters for guest users and role-based access control. + Returns Q object for filtering issues based on user role and project settings. + """ + return Q( + Q( + project__project_projectmember__role=5, + project__guest_view_all_features=True, + ) + | Q( + project__project_projectmember__role=5, + project__guest_view_all_features=False, + created_by=self.request.user, + ) + | + # For other roles (role > 5), show all issues + Q(project__project_projectmember__role__gt=5), + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + + def apply_annotations(self, issues): + return ( + issues.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() + .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") + ) + .prefetch_related( + Prefetch( + "issue_assignee", + queryset=IssueAssignee.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "label_issue", + queryset=IssueLabel.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.all(), + ) + ) + ) + + def get_queryset(self): + return Issue.issue_objects.filter(workspace__slug=self.kwargs.get("slug")) + + @method_decorator(gzip_page) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def list(self, request, slug): + issue_queryset = self.get_queryset() + + # Apply filtering from filterset + issue_queryset = self.filter_queryset(issue_queryset) + + order_by_param = request.GET.get("order_by", "-created_at") + + # Apply legacy filters + filters = issue_filters(request.query_params, "GET") + issue_queryset = issue_queryset.filter(**filters) + + # Get common project permission filters + permission_filters = self._get_project_permission_filters() + # Apply project permission filters to the issue queryset + issue_queryset = issue_queryset.filter(permission_filters) + + # Base query for the counts + total_issue_count_queryset = copy.deepcopy(issue_queryset) + total_issue_count_queryset = total_issue_count_queryset.only("id") + + # Apply annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) + + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, order_by_param=order_by_param + ) + + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data, + total_count_queryset=total_issue_count_queryset, + ) + + +class IssueViewViewSet(BaseViewSet): + serializer_class = IssueViewSerializer + model = IssueView + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id"), owned_by=self.request.user) + + def get_queryset(self): + subquery = UserFavorite.objects.filter( + user=self.request.user, + entity_identifier=OuterRef("pk"), + entity_type="view", + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .filter(Q(owned_by=self.request.user) | Q(access=1)) + .select_related("project") + .select_related("workspace") + .annotate(is_favorite=Exists(subquery)) + .order_by("-is_favorite", "name") + .distinct() + ) + + @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) + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists() + 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] + 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]) + def retrieve(self, request, slug, project_id, pk): + 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 + the requesting user then dont show the view + """ + + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not issue_view.owned_by == request.user + ): + return Response( + {"error": "You are not allowed to view this issue"}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = IssueViewSerializer(issue_view) + recent_visited_task.delay( + slug=slug, + project_id=project_id, + entity_name="view", + 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) + def partial_update(self, request, slug, project_id, pk): + with transaction.atomic(): + issue_view = IssueView.objects.select_for_update().get(pk=pk, workspace__slug=slug, project_id=project_id) + + if issue_view.is_locked: + return Response({"error": "view is locked"}, status=status.HTTP_400_BAD_REQUEST) + + # Only update the view if owner is updating + if issue_view.owned_by_id != request.user.id: + return Response( + {"error": "Only the owner of the view can update the view"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueViewSerializer(issue_view, data=request.data, partial=True) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @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) + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=20, + is_active=True, + ).exists() + or project_view.owned_by_id == request.user.id + ): + project_view.delete() + # Delete the user favorite view + UserFavorite.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_type="view", + ).delete() + # Delete the page from recent visit + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="view", + ).delete(soft=False) + else: + return Response( + {"error": "Only admin or owner can delete the view"}, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueViewFavoriteViewSet(BaseViewSet): + model = UserFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("view") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id): + _ = UserFavorite.objects.create( + user=request.user, + entity_identifier=request.data.get("view"), + entity_type="view", + project_id=project_id, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, view_id): + view_favorite = UserFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + entity_type="view", + entity_identifier=view_id, + ) + view_favorite.delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/webhook/base.py b/apps/api/plane/app/views/webhook/base.py new file mode 100644 index 00000000..e857c3e0 --- /dev/null +++ b/apps/api/plane/app/views/webhook/base.py @@ -0,0 +1,122 @@ +# Django imports +from django.db import IntegrityError + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.db.models import Webhook, WebhookLog, Workspace +from plane.db.models.webhook import generate_token +from ..base import BaseAPIView +from plane.app.permissions import allow_permission, ROLE +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) + try: + serializer = WebhookSerializer(data=request.data, context={"request": request}) + if serializer.is_valid(): + serializer.save(workspace_id=workspace.id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"error": "URL already exists for the workspace"}, + status=status.HTTP_409_CONFLICT, + ) + raise IntegrityError + + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def get(self, request, slug, pk=None): + if pk is None: + webhooks = Webhook.objects.filter(workspace__slug=slug) + serializer = WebhookSerializer( + webhooks, + fields=( + "id", + "url", + "is_active", + "created_at", + "updated_at", + "project", + "issue", + "cycle", + "module", + "issue_comment", + ), + many=True, + ) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) + serializer = WebhookSerializer( + webhook, + fields=( + "id", + "url", + "is_active", + "created_at", + "updated_at", + "project", + "issue", + "cycle", + "module", + "issue_comment", + ), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def patch(self, request, slug, pk): + webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) + serializer = WebhookSerializer( + webhook, + data=request.data, + context={request: request}, + partial=True, + fields=( + "id", + "url", + "is_active", + "created_at", + "updated_at", + "project", + "issue", + "cycle", + "module", + "issue_comment", + ), + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def delete(self, request, slug, pk): + webhook = Webhook.objects.get(pk=pk, workspace__slug=slug) + webhook.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WebhookSecretRegenerateEndpoint(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def post(self, request, slug, pk): + webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) + webhook.secret_key = generate_token() + webhook.save() + serializer = WebhookSerializer(webhook) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class WebhookLogsEndpoint(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def get(self, request, slug, webhook_id): + webhook_logs = WebhookLog.objects.filter(workspace__slug=slug, webhook=webhook_id) + serializer = WebhookLogSerializer(webhook_logs, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/base.py b/apps/api/plane/app/views/workspace/base.py new file mode 100644 index 00000000..c27b7adb --- /dev/null +++ b/apps/api/plane/app/views/workspace/base.py @@ -0,0 +1,386 @@ +# Python imports +import csv +import io +import os +from datetime import date +import uuid + +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.fields import DateField +from django.db.models.functions import Cast, ExtractDay, ExtractWeek + + +# Django imports +from django.http import HttpResponse +from django.utils import timezone + +# Third party modules +from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import ( + WorkSpaceAdminPermission, + WorkSpaceBasePermission, + WorkspaceEntityPermission, +) + +# Module imports +from plane.app.serializers import WorkSpaceSerializer, WorkspaceThemeSerializer +from plane.app.views.base import BaseAPIView, BaseViewSet +from plane.db.models import ( + Issue, + IssueActivity, + Workspace, + WorkspaceMember, + WorkspaceTheme, + Profile, +) +from plane.app.permissions import ROLE, allow_permission +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +from plane.license.utils.instance_value import get_configuration_value +from plane.bgtasks.workspace_seed_task import workspace_seed +from plane.utils.url import contains_url + + +class WorkSpaceViewSet(BaseViewSet): + model = Workspace + serializer_class = WorkSpaceSerializer + permission_classes = [WorkSpaceBasePermission] + + 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) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + return ( + self.filter_queryset(super().get_queryset().select_related("owner")) + .order_by("name") + .filter( + workspace_member__member=self.request.user, + workspace_member__is_active=True, + ) + .annotate(total_members=member_count) + ) + + 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) + 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 contains_url(name): + return Response( + {"error": "Name cannot contain a URL"}, + 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", ""), + ) + + # Get total members and role + total_members = WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count() + data = serializer.data + data["total_members"] = total_members + data["role"] = 20 + + workspace_seed.delay(serializer.data["id"]) + + return Response(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_409_CONFLICT, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @allow_permission([ROLE.ADMIN], level="WORKSPACE") + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None: + """ + Remove the last workspace id from the user settings + """ + Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None) + return + + @allow_permission([ROLE.ADMIN], level="WORKSPACE") + def destroy(self, request, *args, **kwargs): + # Get the workspace + workspace = self.get_object() + self.remove_last_workspace_ids_from_user_settings(workspace.id) + return super().destroy(request, *args, **kwargs) + + +class UserWorkSpacesEndpoint(BaseAPIView): + search_fields = ["name"] + filterset_fields = ["owner"] + use_read_replica = True + + def get(self, request): + 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) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + role = WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True).values( + "role" + ) + + workspace = ( + Workspace.objects.prefetch_related( + Prefetch( + "workspace_member", + queryset=WorkspaceMember.objects.filter(member=request.user, is_active=True), + ) + ) + .annotate(role=role, total_members=member_count) + .filter(workspace_member__member=request.user, workspace_member__is_active=True) + .distinct() + ) + + workspaces = WorkSpaceSerializer( + self.filter_queryset(workspace), + fields=fields if fields else None, + many=True, + ).data + + return Response(workspaces, status=status.HTTP_200_OK) + + +class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView): + 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=slug).exists() or slug in RESTRICTED_WORKSPACE_SLUGS + return Response({"status": not workspace}, status=status.HTTP_200_OK) + + +class WeekInMonth(Func): + function = "FLOOR" + template = "(((%(expressions)s - 1) / 7) + 1)::INTEGER" + + +class UserWorkspaceDashboardEndpoint(BaseAPIView): + def get(self, request, slug): + issue_activities = ( + IssueActivity.objects.filter( + actor=request.user, + workspace__slug=slug, + created_at__date__gte=date.today() + relativedelta(months=-3), + ) + .annotate(created_date=Cast("created_at", DateField())) + .values("created_date") + .annotate(activity_count=Count("created_date")) + .order_by("created_date") + ) + + month = request.GET.get("month", 1) + + completed_issues = ( + Issue.issue_objects.filter( + assignees__in=[request.user], + workspace__slug=slug, + completed_at__month=month, + completed_at__isnull=False, + ) + .annotate(day_of_month=ExtractDay("completed_at")) + .annotate(week_in_month=WeekInMonth(F("day_of_month"))) + .values("week_in_month") + .annotate(completed_count=Count("id")) + .order_by("week_in_month") + ) + + assigned_issues = Issue.issue_objects.filter(workspace__slug=slug, assignees__in=[request.user]).count() + + pending_issues_count = Issue.issue_objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + workspace__slug=slug, + assignees__in=[request.user], + ).count() + + completed_issues_count = Issue.issue_objects.filter( + 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]) + .annotate(target_week=ExtractWeek("target_date")) + .filter(target_week=timezone.now().date().isocalendar()[1]) + .count() + ) + + state_distribution = ( + Issue.issue_objects.filter(workspace__slug=slug, assignees__in=[request.user]) + .annotate(state_group=F("state__group")) + .values("state_group") + .annotate(state_count=Count("state_group")) + .order_by("state_group") + ) + + overdue_issues = Issue.issue_objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + workspace__slug=slug, + assignees__in=[request.user], + target_date__lt=timezone.now(), + completed_at__isnull=True, + ).values("id", "name", "workspace__slug", "project_id", "target_date") + + upcoming_issues = Issue.issue_objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + start_date__gte=timezone.now(), + workspace__slug=slug, + assignees__in=[request.user], + completed_at__isnull=True, + ).values("id", "name", "workspace__slug", "project_id", "start_date") + + return Response( + { + "issue_activities": issue_activities, + "completed_issues": completed_issues, + "assigned_issues_count": assigned_issues, + "pending_issues_count": pending_issues_count, + "completed_issues_count": completed_issues_count, + "issues_due_week_count": issues_due_week, + "state_distribution": state_distribution, + "overdue_issues": overdue_issues, + "upcoming_issues": upcoming_issues, + }, + status=status.HTTP_200_OK, + ) + + +class WorkspaceThemeViewSet(BaseViewSet): + permission_classes = [WorkSpaceAdminPermission] + model = WorkspaceTheme + serializer_class = WorkspaceThemeSerializer + + def get_queryset(self): + return super().get_queryset().filter(workspace__slug=self.kwargs.get("slug")) + + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + serializer = WorkspaceThemeSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(workspace=workspace, actor=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ExportWorkspaceUserActivityEndpoint(BaseAPIView): + permission_classes = [WorkspaceEntityPermission] + + def generate_csv_from_rows(self, rows): + """Generate CSV buffer from rows.""" + csv_buffer = io.StringIO() + writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL) + [writer.writerow(row) for row in rows] + csv_buffer.seek(0) + 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) + + user_activities = IssueActivity.objects.filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + 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] + + header = [ + "Actor name", + "Issue ID", + "Project", + "Created at", + "Updated at", + "Action", + "Field", + "Old value", + "New value", + ] + rows = [ + ( + activity.actor.display_name, + f"{activity.project.identifier} - {activity.issue.sequence_id if activity.issue else ''}", + activity.project.name, + activity.created_at, + activity.updated_at, + activity.verb, + activity.field, + activity.old_value, + activity.new_value, + ) + for activity in user_activities + ] + csv_buffer = self.generate_csv_from_rows([header] + rows) + response = HttpResponse(csv_buffer.getvalue(), content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="workspace-user-activity.csv"' + return response diff --git a/apps/api/plane/app/views/workspace/cycle.py b/apps/api/plane/app/views/workspace/cycle.py new file mode 100644 index 00000000..73deca05 --- /dev/null +++ b/apps/api/plane/app/views/workspace/cycle.py @@ -0,0 +1,100 @@ +# Django imports +from django.db.models import Q, Count + +# Third party modules +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.views.base import BaseAPIView +from plane.db.models import Cycle +from plane.app.permissions import WorkspaceViewerPermission +from plane.app.serializers.cycle import CycleSerializer + + +class WorkspaceCyclesEndpoint(BaseAPIView): + permission_classes = [WorkspaceViewerPermission] + + def get(self, request, slug): + cycles = ( + Cycle.objects.filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .filter(archived_at__isnull=True) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + serializer = CycleSerializer(cycles, many=True).data + return Response(serializer, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/draft.py b/apps/api/plane/app/views/workspace/draft.py new file mode 100644 index 00000000..c89fe4a7 --- /dev/null +++ b/apps/api/plane/app/views/workspace/draft.py @@ -0,0 +1,307 @@ +# 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 +from plane.utils.host import base_host + + +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) + + project_id = request.data.get("project_id", issue.project_id) + + serializer = DraftIssueCreateSerializer( + issue, + data=request.data, + partial=True, + context={ + "project_id": project_id, + "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=base_host(request=request, is_app=True), + ) + + 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=base_host(request=request, is_app=True), + ) + + 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=base_host(request=request, is_app=True), + ) + 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/apps/api/plane/app/views/workspace/estimate.py b/apps/api/plane/app/views/workspace/estimate.py new file mode 100644 index 00000000..8cba3d17 --- /dev/null +++ b/apps/api/plane/app/views/workspace/estimate.py @@ -0,0 +1,29 @@ +# Third party modules +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import WorkspaceEntityPermission +from plane.app.serializers import WorkspaceEstimateSerializer +from plane.app.views.base import BaseAPIView +from plane.db.models import Estimate, Project +from plane.utils.cache import cache_response + + +class WorkspaceEstimatesEndpoint(BaseAPIView): + permission_classes = [WorkspaceEntityPermission] + use_read_replica = True + + @cache_response(60 * 60 * 2) + def get(self, request, slug): + estimate_ids = Project.objects.filter(workspace__slug=slug, estimate__isnull=False).values_list( + "estimate_id", flat=True + ) + estimates = ( + Estimate.objects.filter(pk__in=estimate_ids, workspace__slug=slug) + .prefetch_related("points") + .select_related("workspace", "project") + ) + + serializer = WorkspaceEstimateSerializer(estimates, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/favorite.py b/apps/api/plane/app/views/workspace/favorite.py new file mode 100644 index 00000000..8a8bfed6 --- /dev/null +++ b/apps/api/plane/app/views/workspace/favorite.py @@ -0,0 +1,93 @@ +# Third party modules +from rest_framework import status +from rest_framework.response import Response + +# Django modules +from django.db.models import Q +from django.db import IntegrityError + +# Module imports +from plane.app.views.base import BaseAPIView +from plane.db.models import UserFavorite, Workspace +from plane.app.serializers import UserFavoriteSerializer +from plane.app.permissions import allow_permission, ROLE + + +class WorkspaceFavoriteEndpoint(BaseAPIView): + use_read_replica = True + + @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).filter( + Q(project__isnull=True) & ~Q(entity_type="page") + | ( + Q(project__isnull=False) + & Q(project__project_projectmember__member=request.user) + & Q(project__project_projectmember__is_active=True) + ) + ) + serializer = UserFavoriteSerializer(favorites, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def post(self, request, slug): + try: + workspace = Workspace.objects.get(slug=slug) + + # If the favorite exists return + if request.data.get("entity_identifier"): + user_favorites = UserFavorite.objects.filter( + workspace=workspace, + user_id=request.user.id, + entity_type=request.data.get("entity_type"), + entity_identifier=request.data.get("entity_identifier"), + ).first() + + # If the favorite exists return + if user_favorites: + serializer = UserFavoriteSerializer(user_favorites) + return Response(serializer.data, status=status.HTTP_200_OK) + + # else create a new favorite + serializer = UserFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + user_id=request.user.id, + workspace=workspace, + project_id=request.data.get("project_id", None), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response({"error": "Favorite already exists"}, status=status.HTTP_400_BAD_REQUEST) + + @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) + 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") + def delete(self, request, slug, favorite_id): + favorite = UserFavorite.objects.get(user=request.user, workspace__slug=slug, pk=favorite_id) + favorite.delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WorkspaceFavoriteGroupEndpoint(BaseAPIView): + @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).filter( + Q(project__isnull=True) + | ( + Q(project__isnull=False) + & Q(project__project_projectmember__member=request.user) + & Q(project__project_projectmember__is_active=True) + ) + ) + serializer = UserFavoriteSerializer(favorites, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/home.py b/apps/api/plane/app/views/workspace/home.py new file mode 100644 index 00000000..731164eb --- /dev/null +++ b/apps/api/plane/app/views/workspace/home.py @@ -0,0 +1,75 @@ +# Module imports +from ..base import BaseAPIView +from plane.db.models.workspace import WorkspaceHomePreference +from plane.app.permissions import allow_permission, ROLE +from plane.db.models import Workspace +from plane.app.serializers.workspace import WorkspaceHomePreferenceSerializer + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + + +class WorkspaceHomePreferenceViewSet(BaseAPIView): + model = WorkspaceHomePreference + + def get_serializer_class(self): + return WorkspaceHomePreferenceSerializer + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + get_preference = WorkspaceHomePreference.objects.filter(user=request.user, workspace_id=workspace.id) + + create_preference_keys = [] + + keys = [ + key + for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices + if key not in ["quick_tutorial", "new_at_plane"] + ] + + sort_order_counter = 1 + + for preference in keys: + if preference not in get_preference.values_list("key", flat=True): + create_preference_keys.append(preference) + + sort_order = 1000 - sort_order_counter + + preference = WorkspaceHomePreference.objects.bulk_create( + [ + WorkspaceHomePreference( + key=key, + user=request.user, + workspace=workspace, + sort_order=sort_order, + ) + for key in create_preference_keys + ], + batch_size=10, + ignore_conflicts=True, + ) + sort_order_counter += 1 + + preference = WorkspaceHomePreference.objects.filter(user=request.user, workspace_id=workspace.id) + + return Response( + preference.values("key", "is_enabled", "config", "sort_order"), + status=status.HTTP_200_OK, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def patch(self, request, slug, key): + preference = WorkspaceHomePreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first() + + if preference: + serializer = WorkspaceHomePreferenceSerializer(preference, 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) + + return Response({"detail": "Preference not found"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/app/views/workspace/invite.py b/apps/api/plane/app/views/workspace/invite.py new file mode 100644 index 00000000..48bcf7eb --- /dev/null +++ b/apps/api/plane/app/views/workspace/invite.py @@ -0,0 +1,272 @@ +# Python imports +from datetime import datetime + +import jwt + +# Django imports +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.utils import timezone + +# Third party modules +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import WorkSpaceAdminPermission +from plane.app.serializers import ( + WorkSpaceMemberInviteSerializer, + WorkSpaceMemberSerializer, +) +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.utils.cache import invalidate_cache, invalidate_cache_directly +from plane.utils.host import base_host +from plane.utils.ip_address import get_client_ip +from .. import BaseViewSet + + +class WorkspaceInvitationsViewset(BaseViewSet): + """Endpoint for creating, listing and deleting workspaces""" + + serializer_class = WorkSpaceMemberInviteSerializer + model = WorkspaceMemberInvite + + permission_classes = [WorkSpaceAdminPermission] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "workspace__owner", "created_by") + ) + + def create(self, request, slug): + emails = request.data.get("emails", []) + # Check if email is provided + if not emails: + return Response({"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST) + + # check for role level of the requesting user + requesting_user = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user, is_active=True) + + # Check if any invited user has an higher role + if len([email for email in emails if int(email.get("role", 5)) > requesting_user.role]): + return Response( + {"error": "You cannot invite a user with higher role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the workspace object + workspace = Workspace.objects.get(slug=slug) + + # Check if user is already a member of workspace + workspace_members = WorkspaceMember.objects.filter( + workspace_id=workspace.id, + member__email__in=[email.get("email") for email in emails], + is_active=True, + ).select_related("member", "member__avatar_asset") + + if workspace_members: + return Response( + { + "error": "Some users are already member of workspace", + "workspace_users": WorkSpaceMemberSerializer(workspace_members, many=True).data, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace_invitations = [] + for email in emails: + try: + validate_email(email.get("email")) + workspace_invitations.append( + WorkspaceMemberInvite( + email=email.get("email").strip().lower(), + workspace_id=workspace.id, + token=jwt.encode( + {"email": email, "timestamp": datetime.now().timestamp()}, + settings.SECRET_KEY, + algorithm="HS256", + ), + role=email.get("role", 5), + created_by=request.user, + ) + ) + except ValidationError: + return Response( + { + "error": f"Invalid email - {email} provided a valid email address is required to send the invite" # noqa: E501 + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Create workspace member invite + workspace_invitations = WorkspaceMemberInvite.objects.bulk_create( + workspace_invitations, batch_size=10, ignore_conflicts=True + ) + + current_site = base_host(request=request, is_app=True) + + # Send invitations + for invitation in workspace_invitations: + workspace_invitation.delay( + invitation.email, + workspace.id, + invitation.token, + current_site, + request.user.email, + ) + + return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK) + + def destroy(self, request, slug, pk): + workspace_member_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug) + workspace_member_invite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WorkspaceJoinEndpoint(BaseAPIView): + permission_classes = [AllowAny] + """Invitation response endpoint the user can respond to the invitation""" + + @invalidate_cache(path="/api/workspaces/", user=False) + @invalidate_cache(path="/api/users/me/workspaces/", multiple=True) + @invalidate_cache( + path="/api/workspaces/:slug/members/", + user=False, + multiple=True, + url_params=True, + ) + @invalidate_cache(path="/api/users/me/settings/", multiple=True) + def post(self, request, slug, pk): + workspace_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug) + + email = request.data.get("email", "") + + # Check the email + if email == "" or workspace_invite.email != email: + return Response( + {"error": "You do not have permission to join the workspace"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # If already responded then return error + if workspace_invite.responded_at is None: + workspace_invite.accepted = request.data.get("accepted", False) + workspace_invite.responded_at = timezone.now() + workspace_invite.save() + + if workspace_invite.accepted: + # Check if the user created account after invitation + user = User.objects.filter(email=email).first() + + # If the user is present then create the workspace member + if user is not None: + # Check if the user was already a member of workspace then activate the user + workspace_member = WorkspaceMember.objects.filter( + workspace=workspace_invite.workspace, member=user + ).first() + if workspace_member is not None: + workspace_member.is_active = True + workspace_member.role = workspace_invite.role + workspace_member.save() + else: + # Create a Workspace + _ = WorkspaceMember.objects.create( + workspace=workspace_invite.workspace, + member=user, + role=workspace_invite.role, + ) + + # Set the user last_workspace_id to the accepted workspace + user.last_workspace_id = workspace_invite.workspace.id + user.save() + + # Delete the invitation + workspace_invite.delete() + + # Send event + workspace_invite_event.delay( + user=user.id if user is not None else None, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=get_client_ip(request=request), + event_name="MEMBER_ACCEPTED", + accepted_from="EMAIL", + ) + + return Response( + {"message": "Workspace Invitation Accepted"}, + status=status.HTTP_200_OK, + ) + + # Workspace invitation rejected + return Response( + {"message": "Workspace Invitation was not accepted"}, + status=status.HTTP_200_OK, + ) + + return Response( + {"error": "You have already responded to the invitation request"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def get(self, request, slug, pk): + workspace_invitation = WorkspaceMemberInvite.objects.get(workspace__slug=slug, pk=pk) + serializer = WorkSpaceMemberInviteSerializer(workspace_invitation) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class UserWorkspaceInvitationsViewSet(BaseViewSet): + serializer_class = WorkSpaceMemberInviteSerializer + model = WorkspaceMemberInvite + + def get_queryset(self): + return self.filter_queryset( + super().get_queryset().filter(email=self.request.user.email).select_related("workspace") + ) + + @invalidate_cache(path="/api/workspaces/", user=False) + @invalidate_cache(path="/api/users/me/workspaces/", multiple=True) + def create(self, request): + invitations = request.data.get("invitations", []) + workspace_invitations = WorkspaceMemberInvite.objects.filter( + pk__in=invitations, email=request.user.email + ).order_by("-created_at") + + # If the user is already a member of workspace and was deactivated then activate the user + for invitation in workspace_invitations: + invalidate_cache_directly( + path=f"/api/workspaces/{invitation.workspace.slug}/members/", + user=False, + request=request, + multiple=True, + ) + # Update the WorkspaceMember for this specific invitation + WorkspaceMember.objects.filter(workspace_id=invitation.workspace_id, member=request.user).update( + is_active=True, role=invitation.role + ) + + # Bulk create the user for all the workspaces + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace=invitation.workspace, + member=request.user, + role=invitation.role, + created_by=request.user, + ) + for invitation in workspace_invitations + ], + ignore_conflicts=True, + ) + + # Delete joined workspace invites + workspace_invitations.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/workspace/label.py b/apps/api/plane/app/views/workspace/label.py new file mode 100644 index 00000000..11ca6b91 --- /dev/null +++ b/apps/api/plane/app/views/workspace/label.py @@ -0,0 +1,26 @@ +# Third party modules +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.serializers import LabelSerializer +from plane.app.views.base import BaseAPIView +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] + use_read_replica = True + + @cache_response(60 * 60 * 2) + def get(self, request, slug): + labels = Label.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + serializer = LabelSerializer(labels, many=True).data + return Response(serializer, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/member.py b/apps/api/plane/app/views/workspace/member.py new file mode 100644 index 00000000..d81a647f --- /dev/null +++ b/apps/api/plane/app/views/workspace/member.py @@ -0,0 +1,243 @@ +# Django imports +from django.db.models import Count, Q, OuterRef, Subquery, IntegerField +from django.utils import timezone +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 WorkspaceEntityPermission, allow_permission, ROLE + +# Module imports +from plane.app.serializers import ( + ProjectMemberRoleSerializer, + WorkspaceMemberAdminSerializer, + WorkspaceMemberMeSerializer, + WorkSpaceMemberSerializer, +) +from plane.app.views.base import BaseAPIView +from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue +from plane.utils.cache import invalidate_cache + +from .. import BaseViewSet + + +class WorkSpaceMemberViewSet(BaseViewSet): + serializer_class = WorkspaceMemberAdminSerializer + model = WorkspaceMember + + search_fields = ["member__display_name", "member__first_name"] + use_read_replica = True + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("member", "member__avatar_asset") + ) + + @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) + + # 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) + else: + serializer = WorkSpaceMemberSerializer(workspace_members, fields=("id", "member", "role"), many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @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 + ) + if request.user.id == workspace_member.member_id: + return Response( + {"error": "You cannot update your own role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # If a user is moved to a guest role he can't have any other role in projects + if "role" in request.data and int(request.data.get("role")) == 5: + ProjectMember.objects.filter(workspace__slug=slug, member_id=workspace_member.member_id).update(role=5) + + serializer = WorkSpaceMemberSerializer(workspace_member, data=request.data, partial=True) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @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 + ) + + # check requesting user role + requesting_workspace_member = WorkspaceMember.objects.get( + workspace__slug=slug, member=request.user, is_active=True + ) + + if str(workspace_member.id) == str(requesting_workspace_member.id): + return Response( + {"error": "You cannot remove yourself from the workspace. Please use leave workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if requesting_workspace_member.role < workspace_member.role: + return Response( + {"error": "You cannot remove a user having role higher than you"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if ( + Project.objects.annotate( + total_members=Count("project_projectmember"), + member_with_role=Count( + "project_projectmember", + filter=Q( + project_projectmember__member_id=workspace_member.id, + project_projectmember__role=20, + ), + ), + ) + .filter(total_members=1, member_with_role=1, workspace__slug=slug) + .exists() + ): + return Response( + { + "error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin." # noqa: E501 + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Deactivate the users from the projects where the user is part of + _ = ProjectMember.objects.filter( + workspace__slug=slug, member_id=workspace_member.member_id, is_active=True + ).update(is_active=False, updated_at=timezone.now()) + + workspace_member.is_active = False + workspace_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + @invalidate_cache( + path="/api/workspaces/:slug/members/", + url_params=True, + user=False, + multiple=True, + ) + @invalidate_cache(path="/api/users/me/settings/") + @invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def leave(self, request, slug): + workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user, is_active=True) + + # Check if the leaving user is the only admin of the workspace + if ( + workspace_member.role == 20 + and not WorkspaceMember.objects.filter(workspace__slug=slug, role=20, is_active=True).count() > 1 + ): + return Response( + { + "error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin." # noqa: E501 + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if ( + Project.objects.annotate( + total_members=Count("project_projectmember"), + member_with_role=Count( + "project_projectmember", + filter=Q( + project_projectmember__member_id=request.user.id, + project_projectmember__role=20, + ), + ), + ) + .filter(total_members=1, member_with_role=1, workspace__slug=slug) + .exists() + ): + return Response( + { + "error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin." # noqa: E501 + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # # Deactivate the users from the projects where the user is part of + _ = ProjectMember.objects.filter( + workspace__slug=slug, member_id=workspace_member.member_id, is_active=True + ).update(is_active=False, updated_at=timezone.now()) + + # # Deactivate the user + workspace_member.is_active = False + workspace_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WorkspaceMemberUserViewsEndpoint(BaseAPIView): + def post(self, request, slug): + workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user, is_active=True) + workspace_member.view_props = request.data.get("view_props", {}) + workspace_member.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WorkspaceMemberUserEndpoint(BaseAPIView): + use_read_replica = True + + def get(self, request, slug): + 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) + + +class WorkspaceProjectMemberEndpoint(BaseAPIView): + serializer_class = ProjectMemberRoleSerializer + model = ProjectMember + + 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) + .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 + ).select_related("project", "member", "workspace") + project_members = ProjectMemberRoleSerializer(project_members, many=True).data + + project_members_dict = dict() + + # Construct a dictionary with project_id as key and project_members as value + for project_member in project_members: + project_id = project_member.pop("project") + if str(project_id) not in project_members_dict: + project_members_dict[str(project_id)] = [] + project_members_dict[str(project_id)].append(project_member) + + return Response(project_members_dict, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/module.py b/apps/api/plane/app/views/workspace/module.py new file mode 100644 index 00000000..e61fc70e --- /dev/null +++ b/apps/api/plane/app/views/workspace/module.py @@ -0,0 +1,107 @@ +# Django imports +from django.db.models import Prefetch, Q, Count + +# Third party modules +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.views.base import BaseAPIView +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] + + def get(self, request, slug): + modules = ( + Module.objects.filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .filter(archived_at__isnull=True) + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related("module", "created_by"), + ) + ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + completed_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + cancelled_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + started_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + unstarted_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + backlog_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + serializer = ModuleSerializer(modules, many=True).data + return Response(serializer, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/quick_link.py b/apps/api/plane/app/views/workspace/quick_link.py new file mode 100644 index 00000000..82c10457 --- /dev/null +++ b/apps/api/plane/app/views/workspace/quick_link.py @@ -0,0 +1,61 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.db.models import WorkspaceUserLink, Workspace +from plane.app.serializers import WorkspaceUserLinkSerializer +from ..base import BaseViewSet +from plane.app.permissions import allow_permission, ROLE + + +class QuickLinkViewSet(BaseViewSet): + model = WorkspaceUserLink + use_read_replica = True + + def get_serializer_class(self): + return WorkspaceUserLinkSerializer + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + serializer = WorkspaceUserLinkSerializer(data=request.data) + + if serializer.is_valid(): + serializer.save(workspace_id=workspace.id, owner_id=request.user.id) + 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], level="WORKSPACE") + def partial_update(self, request, slug, pk): + quick_link = WorkspaceUserLink.objects.filter(pk=pk, workspace__slug=slug, owner=request.user).first() + + if quick_link: + serializer = WorkspaceUserLinkSerializer(quick_link, 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) + return Response({"detail": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def retrieve(self, request, slug, pk): + try: + quick_link = WorkspaceUserLink.objects.get(pk=pk, workspace__slug=slug, owner=request.user) + serializer = WorkspaceUserLinkSerializer(quick_link) + return Response(serializer.data, status=status.HTTP_200_OK) + except WorkspaceUserLink.DoesNotExist: + return Response({"error": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def destroy(self, request, slug, pk): + quick_link = WorkspaceUserLink.objects.get(pk=pk, workspace__slug=slug, owner=request.user) + quick_link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def list(self, request, slug): + quick_links = WorkspaceUserLink.objects.filter(workspace__slug=slug, owner=request.user) + + serializer = WorkspaceUserLinkSerializer(quick_links, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/recent_visit.py b/apps/api/plane/app/views/workspace/recent_visit.py new file mode 100644 index 00000000..0d9c1ef9 --- /dev/null +++ b/apps/api/plane/app/views/workspace/recent_visit.py @@ -0,0 +1,32 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +from plane.db.models import UserRecentVisit +from plane.app.serializers import WorkspaceRecentVisitSerializer + +# Modules imports +from ..base import BaseViewSet +from plane.app.permissions import allow_permission, ROLE + + +class UserRecentVisitViewSet(BaseViewSet): + model = UserRecentVisit + use_read_replica = True + + def get_serializer_class(self): + return WorkspaceRecentVisitSerializer + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def list(self, request, slug): + user_recent_visits = UserRecentVisit.objects.filter(workspace__slug=slug, user=request.user) + + entity_name = request.query_params.get("entity_name") + + if entity_name: + user_recent_visits = user_recent_visits.filter(entity_name=entity_name) + + user_recent_visits = user_recent_visits.filter(entity_name__in=["issue", "page", "project"]) + + serializer = WorkspaceRecentVisitSerializer(user_recent_visits[:20], many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/state.py b/apps/api/plane/app/views/workspace/state.py new file mode 100644 index 00000000..3bfc8d22 --- /dev/null +++ b/apps/api/plane/app/views/workspace/state.py @@ -0,0 +1,37 @@ +# Third party modules +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.serializers import StateSerializer +from plane.app.views.base import BaseAPIView +from plane.db.models import State +from plane.app.permissions import WorkspaceEntityPermission +from collections import defaultdict + + +class WorkspaceStatesEndpoint(BaseAPIView): + permission_classes = [WorkspaceEntityPermission] + use_read_replica = True + + def get(self, request, slug): + states = State.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + is_triage=False, + ) + + grouped_states = defaultdict(list) + for state in states: + grouped_states[state.group].append(state) + + for group, group_states in grouped_states.items(): + count = len(group_states) + + for index, state in enumerate(group_states, start=1): + state.order = index / count + + serializer = StateSerializer(states, many=True).data + return Response(serializer, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/sticky.py b/apps/api/plane/app/views/workspace/sticky.py new file mode 100644 index 00000000..8ab6c5c9 --- /dev/null +++ b/apps/api/plane/app/views/workspace/sticky.py @@ -0,0 +1,56 @@ +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from plane.app.views.base import BaseViewSet +from plane.app.permissions import ROLE, allow_permission +from plane.db.models import Sticky, Workspace +from plane.app.serializers import StickySerializer + + +class WorkspaceStickyViewSet(BaseViewSet): + serializer_class = StickySerializer + model = Sticky + use_read_replica = True + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(owner_id=self.request.user.id) + .select_related("workspace", "owner") + .distinct() + ) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + serializer = StickySerializer(data=request.data) + if serializer.is_valid(): + serializer.save(workspace_id=workspace.id, owner_id=request.user.id) + 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, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def list(self, request, slug): + query = request.query_params.get("query", False) + stickies = self.get_queryset().order_by("-sort_order") + if query: + stickies = stickies.filter(description_stripped__icontains=query) + + return self.paginate( + request=request, + queryset=(stickies), + on_results=lambda stickies: StickySerializer(stickies, many=True).data, + default_per_page=20, + ) + + @allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE") + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE") + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) diff --git a/apps/api/plane/app/views/workspace/user.py b/apps/api/plane/app/views/workspace/user.py new file mode 100644 index 00000000..0d4f152e --- /dev/null +++ b/apps/api/plane/app/views/workspace/user.py @@ -0,0 +1,551 @@ +# Python imports +import copy +from datetime import date + +from dateutil.relativedelta import relativedelta + +# Django imports +from django.db.models import ( + Case, + Count, + F, + Func, + IntegerField, + OuterRef, + Q, + Value, + When, + Subquery, +) +from django.db.models.fields import DateField +from django.db.models.functions import Cast, ExtractWeek +from django.utils import timezone + +# Third party modules +from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import WorkspaceEntityPermission, WorkspaceViewerPermission + +# Module imports +from plane.app.serializers import ( + IssueActivitySerializer, + ProjectMemberSerializer, + WorkSpaceSerializer, + WorkspaceUserPropertiesSerializer, +) +from plane.app.views.base import BaseAPIView +from plane.db.models import ( + CycleIssue, + Issue, + IssueActivity, + FileAsset, + IssueLink, + IssueSubscriber, + Project, + ProjectMember, + User, + Workspace, + WorkspaceMember, + WorkspaceUserProperties, +) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) +from plane.utils.issue_filters import issue_filters +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator +from plane.utils.filters import ComplexFilterBackend +from plane.utils.filters import IssueFilterSet + + +class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): + def get(self, request): + user = User.objects.get(pk=request.user.id) + + last_workspace_id = user.last_workspace_id + + if last_workspace_id is None: + return Response( + {"project_details": [], "workspace_details": {}}, + status=status.HTTP_200_OK, + ) + + workspace = Workspace.objects.get(pk=last_workspace_id) + workspace_serializer = WorkSpaceSerializer(workspace) + + project_member = ProjectMember.objects.filter( + workspace_id=last_workspace_id, member=request.user + ).select_related("workspace", "project", "member", "workspace__owner") + + project_member_serializer = ProjectMemberSerializer(project_member, many=True) + + return Response( + { + "workspace_details": workspace_serializer.data, + "project_details": project_member_serializer.data, + }, + status=status.HTTP_200_OK, + ) + + +class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): + permission_classes = [WorkspaceViewerPermission] + + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet + + def apply_annotations(self, issues): + return ( + issues.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() + .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") + ) + .prefetch_related("assignees", "labels", "issue_module__module") + ) + + def get(self, request, slug, user_id): + filters = issue_filters(request.query_params, "GET") + + order_by_param = request.GET.get("order_by", "-created_at") + issue_queryset = Issue.issue_objects.filter( + id__in=Issue.issue_objects.filter( + Q(assignees__in=[user_id]) | Q(created_by_id=user_id) | Q(issue_subscribers__subscriber_id=user_id), + workspace__slug=slug, + ).values_list("id", flat=True), + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + ) + + # Apply filtering from filterset + issue_queryset = self.filter_queryset(issue_queryset) + + # Apply legacy filters + issue_queryset = issue_queryset.filter(**filters) + + # Total count queryset + total_issue_queryset = copy.deepcopy(issue_queryset) + + # Apply annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) + + # 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: + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" # noqa: E501 + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + total_count_queryset=total_issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + filters=filters, + queryset=total_issue_queryset, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + filters=filters, + queryset=total_issue_queryset, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + 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, + ), + ) + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + total_count_queryset=total_issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + filters=filters, + queryset=total_issue_queryset, + ), + group_by_field_name=group_by, + count_filter=Q( + 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, + ), + ) + else: + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + total_count_queryset=total_issue_queryset, + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), + ) + + +class WorkspaceUserPropertiesEndpoint(BaseAPIView): + permission_classes = [WorkspaceViewerPermission] + + def patch(self, request, slug): + workspace_properties = WorkspaceUserProperties.objects.get(user=request.user, workspace__slug=slug) + + workspace_properties.filters = request.data.get("filters", workspace_properties.filters) + workspace_properties.rich_filters = request.data.get("rich_filters", workspace_properties.rich_filters) + workspace_properties.display_filters = request.data.get("display_filters", workspace_properties.display_filters) + workspace_properties.display_properties = request.data.get( + "display_properties", workspace_properties.display_properties + ) + workspace_properties.save() + + serializer = WorkspaceUserPropertiesSerializer(workspace_properties) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request, slug): + (workspace_properties, _) = WorkspaceUserProperties.objects.get_or_create( + user=request.user, workspace__slug=slug + ) + serializer = WorkspaceUserPropertiesSerializer(workspace_properties) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class WorkspaceUserProfileEndpoint(BaseAPIView): + def get(self, request, slug, user_id): + user_data = User.objects.get(pk=user_id) + + requesting_workspace_member = WorkspaceMember.objects.get( + workspace__slug=slug, member=request.user, is_active=True + ) + projects = [] + if requesting_workspace_member.role >= 15: + projects = ( + Project.objects.filter( + workspace__slug=slug, + project_projectmember__member=request.user, + project_projectmember__is_active=True, + archived_at__isnull=True, + ) + .annotate( + created_issues=Count( + "project_issue", + filter=Q( + project_issue__created_by_id=user_id, + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, + ), + ) + ) + .annotate( + assigned_issues=Count( + "project_issue", + filter=Q( + project_issue__assignees__in=[user_id], + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "project_issue", + filter=Q( + project_issue__completed_at__isnull=False, + project_issue__assignees__in=[user_id], + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "project_issue", + filter=Q( + project_issue__state__group__in=[ + "backlog", + "unstarted", + "started", + ], + project_issue__assignees__in=[user_id], + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, + ), + ) + ) + .values( + "id", + "logo_props", + "created_issues", + "assigned_issues", + "completed_issues", + "pending_issues", + ) + ) + + return Response( + { + "project_data": projects, + "user_data": { + "email": user_data.email, + "first_name": user_data.first_name, + "last_name": user_data.last_name, + "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, + }, + }, + status=status.HTTP_200_OK, + ) + + +class WorkspaceUserActivityEndpoint(BaseAPIView): + permission_classes = [WorkspaceEntityPermission] + + def get(self, request, slug, user_id): + projects = request.query_params.getlist("project", []) + + queryset = IssueActivity.objects.filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + actor=user_id, + ).select_related("actor", "workspace", "issue", "project") + + if projects: + queryset = queryset.filter(project__in=projects) + + return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), + request=request, + queryset=queryset, + on_results=lambda issue_activities: IssueActivitySerializer(issue_activities, many=True).data, + ) + + +class WorkspaceUserProfileStatsEndpoint(BaseAPIView): + def get(self, request, slug, user_id): + filters = issue_filters(request.query_params, "GET") + + state_distribution = ( + Issue.issue_objects.filter( + (Q(assignees__in=[user_id]) & Q(issue_assignee__deleted_at__isnull=True)), + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + ) + .filter(**filters) + .annotate(state_group=F("state__group")) + .values("state_group") + .annotate(state_count=Count("state_group")) + .order_by("state_group") + ) + + priority_order = ["urgent", "high", "medium", "low", "none"] + + priority_distribution = ( + Issue.issue_objects.filter( + (Q(assignees__in=[user_id]) & Q(issue_assignee__deleted_at__isnull=True)), + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + ) + .filter(**filters) + .values("priority") + .annotate(priority_count=Count("priority")) + .filter(priority_count__gte=1) + .annotate( + priority_order=Case( + *[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)], + default=Value(len(priority_order)), + output_field=IntegerField(), + ) + ) + .order_by("priority_order") + ) + + created_issues = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + created_by_id=user_id, + ) + .filter(**filters) + .count() + ) + + assigned_issues_count = ( + Issue.issue_objects.filter( + (Q(assignees__in=[user_id]) & Q(issue_assignee__deleted_at__isnull=True)), + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + ) + .filter(**filters) + .count() + ) + + pending_issues_count = ( + Issue.issue_objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + (Q(assignees__in=[user_id]) & Q(issue_assignee__deleted_at__isnull=True)), + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + ) + .filter(**filters) + .count() + ) + + completed_issues_count = ( + Issue.issue_objects.filter( + (Q(assignees__in=[user_id]) & Q(issue_assignee__deleted_at__isnull=True)), + workspace__slug=slug, + state__group="completed", + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + ) + .filter(**filters) + .count() + ) + + subscribed_issues_count = ( + IssueSubscriber.objects.filter( + workspace__slug=slug, + subscriber_id=user_id, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .filter(**filters) + .count() + ) + + upcoming_cycles = CycleIssue.objects.filter( + workspace__slug=slug, + 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(), + cycle__end_date__gt=timezone.now(), + issue__assignees__in=[user_id], + ).values("cycle__name", "cycle__id", "cycle__project_id") + + return Response( + { + "state_distribution": state_distribution, + "priority_distribution": priority_distribution, + "created_issues": created_issues, + "assigned_issues": assigned_issues_count, + "completed_issues": completed_issues_count, + "pending_issues": pending_issues_count, + "subscribed_issues": subscribed_issues_count, + "present_cycles": present_cycle, + "upcoming_cycles": upcoming_cycles, + } + ) + + +class UserActivityGraphEndpoint(BaseAPIView): + def get(self, request, slug): + issue_activities = ( + IssueActivity.objects.filter( + actor=request.user, + workspace__slug=slug, + created_at__date__gte=date.today() + relativedelta(months=-6), + ) + .annotate(created_date=Cast("created_at", DateField())) + .values("created_date") + .annotate(activity_count=Count("created_date")) + .order_by("created_date") + ) + + return Response(issue_activities, status=status.HTTP_200_OK) + + +class UserIssueCompletedGraphEndpoint(BaseAPIView): + def get(self, request, slug): + month = request.GET.get("month", 1) + + issues = ( + Issue.issue_objects.filter( + assignees__in=[request.user], + workspace__slug=slug, + completed_at__month=month, + completed_at__isnull=False, + ) + .annotate(completed_week=ExtractWeek("completed_at")) + .annotate(week=F("completed_week") % 4) + .values("week") + .annotate(completed_count=Count("completed_week")) + .order_by("week") + ) + + return Response(issues, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/user_preference.py b/apps/api/plane/app/views/workspace/user_preference.py new file mode 100644 index 00000000..30c6ab97 --- /dev/null +++ b/apps/api/plane/app/views/workspace/user_preference.py @@ -0,0 +1,79 @@ +# Module imports +from ..base import BaseAPIView +from plane.db.models.workspace import WorkspaceUserPreference +from plane.app.serializers.workspace import WorkspaceUserPreferenceSerializer +from plane.app.permissions import allow_permission, ROLE +from plane.db.models import Workspace + + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + + +class WorkspaceUserPreferenceViewSet(BaseAPIView): + model = WorkspaceUserPreference + use_read_replica = True + + def get_serializer_class(self): + return WorkspaceUserPreferenceSerializer + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + get_preference = WorkspaceUserPreference.objects.filter(user=request.user, workspace_id=workspace.id) + + create_preference_keys = [] + + keys = [key for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices] + + for preference in keys: + if preference not in get_preference.values_list("key", flat=True): + create_preference_keys.append(preference) + + preference = WorkspaceUserPreference.objects.bulk_create( + [ + WorkspaceUserPreference( + key=key, + user=request.user, + workspace=workspace, + sort_order=(65535 + (i * 10000)), + ) + for i, key in enumerate(create_preference_keys) + ], + batch_size=10, + ignore_conflicts=True, + ) + + preferences = ( + WorkspaceUserPreference.objects.filter(user=request.user, workspace_id=workspace.id) + .order_by("sort_order") + .values("key", "is_pinned", "sort_order") + ) + + user_preferences = {} + + for preference in preferences: + user_preferences[(str(preference["key"]))] = { + "is_pinned": preference["is_pinned"], + "sort_order": preference["sort_order"], + } + return Response( + user_preferences, + status=status.HTTP_200_OK, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def patch(self, request, slug, key): + preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first() + + if preference: + serializer = WorkspaceUserPreferenceSerializer(preference, 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) + + return Response({"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND) diff --git a/apps/api/plane/asgi.py b/apps/api/plane/asgi.py new file mode 100644 index 00000000..2dd703ff --- /dev/null +++ b/apps/api/plane/asgi.py @@ -0,0 +1,14 @@ +import os + +from channels.routing import ProtocolTypeRouter +from django.core.asgi import get_asgi_application + +django_asgi_app = get_asgi_application() + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") +# Initialize Django ASGI application early to ensure the AppRegistry +# is populated before importing code that may import ORM models. + + +application = ProtocolTypeRouter({"http": get_asgi_application()}) diff --git a/apps/api/plane/authentication/__init__.py b/apps/api/plane/authentication/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/authentication/adapter/__init__.py b/apps/api/plane/authentication/adapter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/authentication/adapter/base.py b/apps/api/plane/authentication/adapter/base.py new file mode 100644 index 00000000..bbb50eb7 --- /dev/null +++ b/apps/api/plane/authentication/adapter/base.py @@ -0,0 +1,177 @@ +# Python imports +import os +import uuid + +# Django imports +from django.utils import timezone +from django.core.validators import validate_email +from django.core.exceptions import ValidationError + +# Third party imports +from zxcvbn import zxcvbn + +# Module imports +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 +from plane.utils.host import base_host +from plane.utils.ip_address import get_client_ip + + +class Adapter: + """Common interface for all auth providers""" + + def __init__(self, request, provider, callback=None): + self.request = request + self.provider = provider + self.callback = callback + self.token_data = None + self.user_data = None + + def get_user_token(self, data, headers=None): + raise NotImplementedError + + def get_user_response(self): + raise NotImplementedError + + def set_token_data(self, data): + self.token_data = data + + def set_user_data(self, data): + self.user_data = data + + def create_update_account(self, user): + raise NotImplementedError + + def authenticate(self): + raise NotImplementedError + + def sanitize_email(self, email): + # Check if email is present + if not email: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + payload={"email": email}, + ) + + # Sanitize email + email = str(email).lower().strip() + + # validate email + try: + validate_email(email) + except ValidationError: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + payload={"email": email}, + ) + # Return email + return email + + def validate_password(self, email): + """Validate password strength""" + results = zxcvbn(self.code) + if results["score"] < 3: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + payload={"email": email}, + ) + return + + def __check_signup(self, email): + """Check if sign up is enabled or not and raise exception if not enabled""" + + # Get configuration value + (ENABLE_SIGNUP,) = get_configuration_value( + [{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}] + ) + + # Check if sign up is disabled and invite is present or not + if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists(): + # Raise exception + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"], + error_message="SIGNUP_DISABLED", + payload={"email": email}, + ) + + return True + + def save_user_data(self, user): + # Update user details + user.last_login_medium = self.provider + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = get_client_ip(request=self.request) + user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + # If user is not active, send the activation email and set the user as active + if not user.is_active: + user_activation_email.delay(base_host(request=self.request), user.id) + # Set user as active + user.is_active = True + user.save() + return user + + def complete_login_or_signup(self): + # Get email + email = self.user_data.get("email") + + # Sanitize email + email = self.sanitize_email(email) + + # Check if the user is present + user = User.objects.filter(email=email).first() + # Check if sign up case or login + is_signup = bool(user) + # If user is not present, create a new user + if not user: + # New user + self.__check_signup(email) + + # Initialize user + user = User(email=email, username=uuid.uuid4().hex) + + # Check if password is autoset + if self.user_data.get("user").get("is_password_autoset"): + user.set_password(uuid.uuid4().hex) + user.is_password_autoset = True + user.is_email_verified = True + + # Validate password + else: + # Validate password + self.validate_password(email) + # Set password + user.set_password(self.code) + user.is_password_autoset = False + + # Set user details + avatar = self.user_data.get("user", {}).get("avatar", "") + first_name = self.user_data.get("user", {}).get("first_name", "") + last_name = self.user_data.get("user", {}).get("last_name", "") + user.avatar = avatar if avatar else "" + user.first_name = first_name if first_name else "" + user.last_name = last_name if last_name else "" + user.save() + + # Create profile + Profile.objects.create(user=user) + + # Save user data + user = self.save_user_data(user=user) + + # Call callback if present + if self.callback: + self.callback(user, is_signup, self.request) + + # Create or update account if token data is present + if self.token_data: + self.create_update_account(user=user) + + # Return user + return user diff --git a/apps/api/plane/authentication/adapter/credential.py b/apps/api/plane/authentication/adapter/credential.py new file mode 100644 index 00000000..0327289c --- /dev/null +++ b/apps/api/plane/authentication/adapter/credential.py @@ -0,0 +1,14 @@ +from plane.authentication.adapter.base import Adapter + + +class CredentialAdapter(Adapter): + """Common interface for all credential providers""" + + def __init__(self, request, provider, callback=None): + super().__init__(request=request, provider=provider, callback=callback) + self.request = request + self.provider = provider + + def authenticate(self): + self.set_user_data() + return self.complete_login_or_signup() diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py new file mode 100644 index 00000000..c8622277 --- /dev/null +++ b/apps/api/plane/authentication/adapter/error.py @@ -0,0 +1,85 @@ +AUTHENTICATION_ERROR_CODES = { + # Global + "INSTANCE_NOT_CONFIGURED": 5000, + "INVALID_EMAIL": 5005, + "EMAIL_REQUIRED": 5010, + "SIGNUP_DISABLED": 5015, + "MAGIC_LINK_LOGIN_DISABLED": 5016, + "PASSWORD_LOGIN_DISABLED": 5018, + "USER_ACCOUNT_DEACTIVATED": 5019, + # Password strength + "INVALID_PASSWORD": 5020, + "SMTP_NOT_CONFIGURED": 5025, + # Sign Up + "USER_ALREADY_EXIST": 5030, + "AUTHENTICATION_FAILED_SIGN_UP": 5035, + "REQUIRED_EMAIL_PASSWORD_SIGN_UP": 5040, + "INVALID_EMAIL_SIGN_UP": 5045, + "INVALID_EMAIL_MAGIC_SIGN_UP": 5050, + "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5055, + "EMAIL_PASSWORD_AUTHENTICATION_DISABLED": 5056, + # Sign In + "USER_DOES_NOT_EXIST": 5060, + "AUTHENTICATION_FAILED_SIGN_IN": 5065, + "REQUIRED_EMAIL_PASSWORD_SIGN_IN": 5070, + "INVALID_EMAIL_SIGN_IN": 5075, + "INVALID_EMAIL_MAGIC_SIGN_IN": 5080, + "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED": 5085, + # Both Sign in and Sign up for magic + "INVALID_MAGIC_CODE_SIGN_IN": 5090, + "INVALID_MAGIC_CODE_SIGN_UP": 5092, + "EXPIRED_MAGIC_CODE_SIGN_IN": 5095, + "EXPIRED_MAGIC_CODE_SIGN_UP": 5097, + "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN": 5100, + "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP": 5102, + # Oauth + "OAUTH_NOT_CONFIGURED": 5104, + "GOOGLE_NOT_CONFIGURED": 5105, + "GITHUB_NOT_CONFIGURED": 5110, + "GITHUB_USER_NOT_IN_ORG": 5122, + "GITLAB_NOT_CONFIGURED": 5111, + "GOOGLE_OAUTH_PROVIDER_ERROR": 5115, + "GITHUB_OAUTH_PROVIDER_ERROR": 5120, + "GITLAB_OAUTH_PROVIDER_ERROR": 5121, + # Reset Password + "INVALID_PASSWORD_TOKEN": 5125, + "EXPIRED_PASSWORD_TOKEN": 5130, + # Change password + "INCORRECT_OLD_PASSWORD": 5135, + "MISSING_PASSWORD": 5138, + "INVALID_NEW_PASSWORD": 5140, + # set password + "PASSWORD_ALREADY_SET": 5145, + # Admin + "ADMIN_ALREADY_EXIST": 5150, + "REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME": 5155, + "INVALID_ADMIN_EMAIL": 5160, + "INVALID_ADMIN_PASSWORD": 5165, + "REQUIRED_ADMIN_EMAIL_PASSWORD": 5170, + "ADMIN_AUTHENTICATION_FAILED": 5175, + "ADMIN_USER_ALREADY_EXIST": 5180, + "ADMIN_USER_DOES_NOT_EXIST": 5185, + "ADMIN_USER_DEACTIVATED": 5190, + # Rate limit + "RATE_LIMIT_EXCEEDED": 5900, + # Unknown + "AUTHENTICATION_FAILED": 5999, +} + + +class AuthenticationException(Exception): + error_code = None + error_message = None + payload = {} + + def __init__(self, error_code, error_message, payload={}): + self.error_code = error_code + self.error_message = error_message + self.payload = payload + + def get_error_dict(self): + error = {"error_code": self.error_code, "error_message": self.error_message} + for key in self.payload: + error[key] = self.payload[key] + + return error diff --git a/apps/api/plane/authentication/adapter/exception.py b/apps/api/plane/authentication/adapter/exception.py new file mode 100644 index 00000000..e906c5a5 --- /dev/null +++ b/apps/api/plane/authentication/adapter/exception.py @@ -0,0 +1,30 @@ +# Third party imports +from rest_framework.views import exception_handler +from rest_framework.exceptions import NotAuthenticated +from rest_framework.exceptions import Throttled + +# Module imports +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +def auth_exception_handler(exc, context): + # Call the default exception handler first, to get the standard error response. + response = exception_handler(exc, context) + # Check if an AuthenticationFailed exception is raised. + if isinstance(exc, NotAuthenticated): + response.status_code = 401 + + # Check if an Throttled exception is raised. + if isinstance(exc, Throttled): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], + error_message="RATE_LIMIT_EXCEEDED", + ) + 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/apps/api/plane/authentication/adapter/oauth.py b/apps/api/plane/authentication/adapter/oauth.py new file mode 100644 index 00000000..ed120109 --- /dev/null +++ b/apps/api/plane/authentication/adapter/oauth.py @@ -0,0 +1,122 @@ +# Python imports +import requests + +# Django imports +from django.utils import timezone +from django.db import DatabaseError, IntegrityError + +# Module imports +from plane.db.models import Account + +from .base import Adapter +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.exception_logger import log_exception + + +class OauthAdapter(Adapter): + def __init__( + self, + request, + provider, + client_id, + scope, + redirect_uri, + auth_url, + token_url, + userinfo_url, + client_secret=None, + code=None, + callback=None, + ): + super().__init__(request=request, provider=provider, callback=callback) + self.client_id = client_id + self.scope = scope + self.redirect_uri = redirect_uri + self.auth_url = auth_url + self.token_url = token_url + self.userinfo_url = userinfo_url + self.client_secret = client_secret + self.code = code + + def authentication_error_code(self): + if self.provider == "google": + return "GOOGLE_OAUTH_PROVIDER_ERROR" + elif self.provider == "github": + return "GITHUB_OAUTH_PROVIDER_ERROR" + elif self.provider == "gitlab": + return "GITLAB_OAUTH_PROVIDER_ERROR" + else: + return "OAUTH_NOT_CONFIGURED" + + def get_auth_url(self): + return self.auth_url + + def get_token_url(self): + return self.token_url + + def get_user_info_url(self): + return self.userinfo_url + + def authenticate(self): + self.set_token_data() + self.set_user_data() + return self.complete_login_or_signup() + + def get_user_token(self, data, headers=None): + try: + headers = headers or {} + 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)) + + def get_user_response(self): + try: + 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)) + + def set_user_data(self, data): + self.user_data = data + + def create_update_account(self, user): + 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") + account.refresh_token_expired_at = self.token_data.get("refresh_token_expired_at") + 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/apps/api/plane/authentication/apps.py b/apps/api/plane/authentication/apps.py new file mode 100644 index 00000000..cf5cdca1 --- /dev/null +++ b/apps/api/plane/authentication/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + name = "plane.authentication" diff --git a/apps/api/plane/authentication/middleware/__init__.py b/apps/api/plane/authentication/middleware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/authentication/middleware/session.py b/apps/api/plane/authentication/middleware/session.py new file mode 100644 index 00000000..c367a15d --- /dev/null +++ b/apps/api/plane/authentication/middleware/session.py @@ -0,0 +1,88 @@ +import time +from importlib import import_module + +from django.conf import settings +from django.contrib.sessions.backends.base import UpdateError +from django.contrib.sessions.exceptions import SessionInterrupted +from django.utils.cache import patch_vary_headers +from django.utils.deprecation import MiddlewareMixin +from django.utils.http import http_date + + +class SessionMiddleware(MiddlewareMixin): + def __init__(self, get_response): + super().__init__(get_response) + engine = import_module(settings.SESSION_ENGINE) + self.SessionStore = engine.SessionStore + + def process_request(self, request): + if "instances" in request.path: + 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) + + def process_response(self, request, response): + """ + If request.session was modified, or if the configuration is to save the + session every time, save the changes and set a session cookie or delete + the session cookie if the session has been emptied. + """ + try: + accessed = request.session.accessed + modified = request.session.modified + empty = request.session.is_empty() + except AttributeError: + return response + # First check if we need to delete this cookie. + # The session should be deleted only if the session is entirely empty. + is_admin_path = "instances" in request.path + cookie_name = settings.ADMIN_SESSION_COOKIE_NAME if is_admin_path else settings.SESSION_COOKIE_NAME + + if cookie_name in request.COOKIES and empty: + response.delete_cookie( + cookie_name, + path=settings.SESSION_COOKIE_PATH, + domain=settings.SESSION_COOKIE_DOMAIN, + samesite=settings.SESSION_COOKIE_SAMESITE, + ) + patch_vary_headers(response, ("Cookie",)) + else: + if accessed: + patch_vary_headers(response, ("Cookie",)) + if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: + if request.session.get_expire_at_browser_close(): + max_age = None + expires = None + else: + # Use different max_age based on whether it's an admin cookie + if is_admin_path: + max_age = settings.ADMIN_SESSION_COOKIE_AGE + else: + max_age = request.session.get_expiry_age() + + expires_time = time.time() + max_age + expires = http_date(expires_time) + + # Save the session data and refresh the client cookie. + if response.status_code < 500: + try: + request.session.save() + except UpdateError: + raise SessionInterrupted( + "The request's session was deleted before the " + "request completed. The user may have logged " + "out in a concurrent request, for example." + ) + response.set_cookie( + cookie_name, + request.session.session_key, + max_age=max_age, + expires=expires, + domain=settings.SESSION_COOKIE_DOMAIN, + path=settings.SESSION_COOKIE_PATH, + secure=settings.SESSION_COOKIE_SECURE or None, + httponly=settings.SESSION_COOKIE_HTTPONLY or None, + samesite=settings.SESSION_COOKIE_SAMESITE, + ) + return response diff --git a/apps/api/plane/authentication/provider/__init__.py b/apps/api/plane/authentication/provider/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/authentication/provider/credentials/__init__.py b/apps/api/plane/authentication/provider/credentials/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/authentication/provider/credentials/email.py b/apps/api/plane/authentication/provider/credentials/email.py new file mode 100644 index 00000000..c3d19a80 --- /dev/null +++ b/apps/api/plane/authentication/provider/credentials/email.py @@ -0,0 +1,95 @@ +# Python imports +import os + +# Module imports +from plane.authentication.adapter.credential import CredentialAdapter +from plane.db.models import User +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +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) + self.key = key + self.code = code + self.is_signup = is_signup + + (ENABLE_EMAIL_PASSWORD,) = get_configuration_value( + [ + { + "key": "ENABLE_EMAIL_PASSWORD", + "default": os.environ.get("ENABLE_EMAIL_PASSWORD"), + } + ] + ) + + if ENABLE_EMAIL_PASSWORD == "0": + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_PASSWORD_AUTHENTICATION_DISABLED"], + error_message="EMAIL_PASSWORD_AUTHENTICATION_DISABLED", + ) + + def set_user_data(self): + if self.is_signup: + # Check if the user already exists + if User.objects.filter(email=self.key).exists(): + raise AuthenticationException( + error_message="USER_ALREADY_EXIST", + error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], + ) + + super().set_user_data( + { + "email": self.key, + "user": { + "avatar": "", + "first_name": "", + "last_name": "", + "provider_id": "", + "is_password_autoset": False, + }, + } + ) + return + else: + 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}, + ) + + # Check user password + if not user.check_password(self.code): + raise AuthenticationException( + error_message=( + "AUTHENTICATION_FAILED_SIGN_UP" if self.is_signup else "AUTHENTICATION_FAILED_SIGN_IN" + ), + error_code=AUTHENTICATION_ERROR_CODES[ + ("AUTHENTICATION_FAILED_SIGN_UP" if self.is_signup else "AUTHENTICATION_FAILED_SIGN_IN") + ], + payload={"email": self.key}, + ) + + super().set_user_data( + { + "email": self.key, + "user": { + "avatar": "", + "first_name": "", + "last_name": "", + "provider_id": "", + "is_password_autoset": False, + }, + } + ) + return diff --git a/apps/api/plane/authentication/provider/credentials/magic_code.py b/apps/api/plane/authentication/provider/credentials/magic_code.py new file mode 100644 index 00000000..3f03572a --- /dev/null +++ b/apps/api/plane/authentication/provider/credentials/magic_code.py @@ -0,0 +1,150 @@ +# Python imports +import json +import os +import random +import string + + +# Module imports +from plane.authentication.adapter.credential import CredentialAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.settings.redis import redis_instance +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +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( + [ + {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST")}, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), + }, + ] + ) + + if not (EMAIL_HOST): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"], + error_message="SMTP_NOT_CONFIGURED", + payload={"email": str(key)}, + ) + + if ENABLE_MAGIC_LINK_LOGIN == "0": + raise AuthenticationException( + 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) + self.key = key + self.code = code + + def initiate(self): + ## Generate a random token + token = ( + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + ) + + ri = redis_instance() + + key = "magic_" + str(self.key) + + # Check if the key already exists in python + if ri.exists(key): + data = json.loads(ri.get(key)) + + current_attempt = data["current_attempt"] + 1 + + if data["current_attempt"] > 2: + email = str(self.key).replace("magic_", "", 1) + if User.objects.filter(email=email).exists(): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN"], + error_message="EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN", + payload={"email": str(email)}, + ) + else: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP"], + error_message="EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP", + payload={"email": self.key}, + ) + + value = { + "current_attempt": current_attempt, + "email": str(self.key), + "token": token, + } + expiry = 600 + ri.set(key, json.dumps(value), ex=expiry) + else: + value = {"current_attempt": 0, "email": self.key, "token": token} + expiry = 600 + + ri.set(key, json.dumps(value), ex=expiry) + return key, token + + def set_user_data(self): + ri = redis_instance() + if ri.exists(self.key): + data = json.loads(ri.get(self.key)) + token = data["token"] + email = data["email"] + + if str(token) == str(self.code): + super().set_user_data( + { + "email": email, + "user": { + "avatar": "", + "first_name": "", + "last_name": "", + "provider_id": "", + "is_password_autoset": True, + }, + } + ) + # Delete the token from redis if the code match is successful + ri.delete(self.key) + return + else: + email = str(self.key).replace("magic_", "", 1) + if User.objects.filter(email=email).exists(): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_MAGIC_CODE_SIGN_IN"], + error_message="INVALID_MAGIC_CODE_SIGN_IN", + payload={"email": str(email)}, + ) + else: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_MAGIC_CODE_SIGN_UP"], + error_message="INVALID_MAGIC_CODE_SIGN_UP", + payload={"email": str(email)}, + ) + else: + 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_message="EXPIRED_MAGIC_CODE_SIGN_IN", + payload={"email": str(email)}, + ) + else: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_MAGIC_CODE_SIGN_UP"], + error_message="EXPIRED_MAGIC_CODE_SIGN_UP", + payload={"email": str(email)}, + ) diff --git a/apps/api/plane/authentication/provider/oauth/__init__.py b/apps/api/plane/authentication/provider/oauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/authentication/provider/oauth/github.py b/apps/api/plane/authentication/provider/oauth/github.py new file mode 100644 index 00000000..54c48018 --- /dev/null +++ b/apps/api/plane/authentication/provider/oauth/github.py @@ -0,0 +1,155 @@ +# Python imports +import os +from datetime import datetime +from urllib.parse import urlencode + +import pytz +import requests + +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class GitHubOAuthProvider(OauthAdapter): + token_url = "https://github.com/login/oauth/access_token" + userinfo_url = "https://api.github.com/user" + org_membership_url = "https://api.github.com/orgs" + + provider = "github" + scope = "read:user user:email" + + organization_scope = "read:org" + + def __init__(self, request, code=None, state=None, callback=None): + GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = 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"), + }, + { + "key": "GITHUB_ORGANIZATION_ID", + "default": os.environ.get("GITHUB_ORGANIZATION_ID"), + }, + ] + ) + + if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_NOT_CONFIGURED"], + error_message="GITHUB_NOT_CONFIGURED", + ) + + client_id = GITHUB_CLIENT_ID + client_secret = GITHUB_CLIENT_SECRET + self.organization_id = GITHUB_ORGANIZATION_ID + + if self.organization_id: + self.scope += f" {self.organization_scope}" + + redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/""" + url_params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": self.scope, + "state": state, + } + auth_url = f"https://github.com/login/oauth/authorize?{urlencode(url_params)}" + super().__init__( + request, + self.provider, + client_id, + self.scope, + redirect_uri, + auth_url, + self.token_url, + self.userinfo_url, + client_secret, + code, + callback=callback, + ) + + def set_token_data(self): + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": self.code, + "redirect_uri": self.redirect_uri, + } + token_response = self.get_user_token(data=data, headers={"Accept": "application/json"}) + super().set_token_data( + { + "access_token": token_response.get("access_token"), + "refresh_token": token_response.get("refresh_token", None), + "access_token_expired_at": ( + datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc) + if token_response.get("expires_in") + else None + ), + "refresh_token_expired_at": ( + datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc) + if token_response.get("refresh_token_expired_at") + else None + ), + "id_token": token_response.get("id_token", ""), + } + ) + + def __get_email(self, headers): + try: + # Github does not provide email in user response + 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) + return email + except requests.RequestException: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + + def is_user_in_organization(self, github_username): + headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"} + response = requests.get( + f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", + headers=headers, + ) + return response.status_code == 200 # 200 means the user is a member + + def set_user_data(self): + user_info_response = self.get_user_response() + headers = { + "Authorization": f"Bearer {self.token_data.get('access_token')}", + "Accept": "application/json", + } + + if self.organization_id: + if not self.is_user_in_organization(user_info_response.get("login")): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"], + error_message="GITHUB_USER_NOT_IN_ORG", + ) + + email = self.__get_email(headers=headers) + super().set_user_data( + { + "email": email, + "user": { + "provider_id": user_info_response.get("id"), + "email": email, + "avatar": user_info_response.get("avatar_url"), + "first_name": user_info_response.get("name"), + "last_name": user_info_response.get("family_name"), + "is_password_autoset": True, + }, + } + ) diff --git a/apps/api/plane/authentication/provider/oauth/gitlab.py b/apps/api/plane/authentication/provider/oauth/gitlab.py new file mode 100644 index 00000000..de4a3515 --- /dev/null +++ b/apps/api/plane/authentication/provider/oauth/gitlab.py @@ -0,0 +1,120 @@ +# Python imports +import os +from datetime import datetime +from urllib.parse import urlencode + +import pytz + +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class GitLabOAuthProvider(OauthAdapter): + provider = "gitlab" + scope = "read_user" + + def __init__(self, request, code=None, state=None, callback=None): + GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, GITLAB_HOST = get_configuration_value( + [ + { + "key": "GITLAB_CLIENT_ID", + "default": os.environ.get("GITLAB_CLIENT_ID"), + }, + { + "key": "GITLAB_CLIENT_SECRET", + "default": os.environ.get("GITLAB_CLIENT_SECRET"), + }, + { + "key": "GITLAB_HOST", + "default": os.environ.get("GITLAB_HOST", "https://gitlab.com"), + }, + ] + ) + + self.host = GITLAB_HOST + self.token_url = f"{self.host}/oauth/token" + self.userinfo_url = f"{self.host}/api/v4/user" + + if not (GITLAB_CLIENT_ID and GITLAB_CLIENT_SECRET and GITLAB_HOST): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITLAB_NOT_CONFIGURED"], + error_message="GITLAB_NOT_CONFIGURED", + ) + + client_id = GITLAB_CLIENT_ID + client_secret = GITLAB_CLIENT_SECRET + + redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/gitlab/callback/""" + url_params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": self.scope, + "state": state, + } + auth_url = f"{self.host}/oauth/authorize?{urlencode(url_params)}" + super().__init__( + request, + self.provider, + client_id, + self.scope, + redirect_uri, + auth_url, + self.token_url, + self.userinfo_url, + client_secret, + code, + callback=callback, + ) + + def set_token_data(self): + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": self.code, + "redirect_uri": self.redirect_uri, + "grant_type": "authorization_code", + } + token_response = self.get_user_token(data=data, headers={"Accept": "application/json"}) + super().set_token_data( + { + "access_token": token_response.get("access_token"), + "refresh_token": token_response.get("refresh_token", None), + "access_token_expired_at": ( + datetime.fromtimestamp( + token_response.get("created_at") + token_response.get("expires_in"), + tz=pytz.utc, + ) + if token_response.get("expires_in") + else None + ), + "refresh_token_expired_at": ( + datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc) + if token_response.get("refresh_token_expired_at") + else None + ), + "id_token": token_response.get("id_token", ""), + } + ) + + def set_user_data(self): + user_info_response = self.get_user_response() + email = user_info_response.get("email") + super().set_user_data( + { + "email": email, + "user": { + "provider_id": user_info_response.get("id"), + "email": email, + "avatar": user_info_response.get("avatar_url"), + "first_name": user_info_response.get("name"), + "last_name": user_info_response.get("family_name"), + "is_password_autoset": True, + }, + } + ) diff --git a/apps/api/plane/authentication/provider/oauth/google.py b/apps/api/plane/authentication/provider/oauth/google.py new file mode 100644 index 00000000..41293782 --- /dev/null +++ b/apps/api/plane/authentication/provider/oauth/google.py @@ -0,0 +1,111 @@ +# Python imports +import os +from datetime import datetime +from urllib.parse import urlencode + +import pytz + +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +class GoogleOAuthProvider(OauthAdapter): + token_url = "https://oauth2.googleapis.com/token" + userinfo_url = "https://www.googleapis.com/oauth2/v2/userinfo" + scope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" + provider = "google" + + def __init__(self, request, code=None, state=None, callback=None): + (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"), + }, + ] + ) + + if not (GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_NOT_CONFIGURED"], + error_message="GOOGLE_NOT_CONFIGURED", + ) + + client_id = GOOGLE_CLIENT_ID + client_secret = GOOGLE_CLIENT_SECRET + + redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/google/callback/""" + url_params = { + "client_id": client_id, + "scope": self.scope, + "redirect_uri": redirect_uri, + "response_type": "code", + "access_type": "offline", + "prompt": "consent", + "state": state, + } + auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(url_params)}" + + super().__init__( + request, + self.provider, + client_id, + self.scope, + redirect_uri, + auth_url, + self.token_url, + self.userinfo_url, + client_secret, + code, + callback=callback, + ) + + def set_token_data(self): + data = { + "code": self.code, + "client_id": self.client_id, + "client_secret": self.client_secret, + "redirect_uri": self.redirect_uri, + "grant_type": "authorization_code", + } + token_response = self.get_user_token(data=data) + super().set_token_data( + { + "access_token": token_response.get("access_token"), + "refresh_token": token_response.get("refresh_token", None), + "access_token_expired_at": ( + datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc) + if token_response.get("expires_in") + else None + ), + "refresh_token_expired_at": ( + datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc) + if token_response.get("refresh_token_expired_at") + else None + ), + "id_token": token_response.get("id_token", ""), + } + ) + + def set_user_data(self): + user_info_response = self.get_user_response() + user_data = { + "email": user_info_response.get("email"), + "user": { + "avatar": user_info_response.get("picture"), + "first_name": user_info_response.get("given_name"), + "last_name": user_info_response.get("family_name"), + "provider_id": user_info_response.get("id"), + "is_password_autoset": True, + }, + } + super().set_user_data(user_data) diff --git a/apps/api/plane/authentication/rate_limit.py b/apps/api/plane/authentication/rate_limit.py new file mode 100644 index 00000000..09c3381c --- /dev/null +++ b/apps/api/plane/authentication/rate_limit.py @@ -0,0 +1,24 @@ +# Third party imports +from rest_framework.throttling import AnonRateThrottle +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class AuthenticationThrottle(AnonRateThrottle): + rate = "30/minute" + scope = "authentication" + + def throttle_failure_view(self, request, *args, **kwargs): + try: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], + error_message="RATE_LIMIT_EXCEEDED", + ) + except AuthenticationException as e: + return Response(e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS) diff --git a/apps/api/plane/authentication/session.py b/apps/api/plane/authentication/session.py new file mode 100644 index 00000000..862a63c1 --- /dev/null +++ b/apps/api/plane/authentication/session.py @@ -0,0 +1,7 @@ +from rest_framework.authentication import SessionAuthentication + + +class BaseSessionAuthentication(SessionAuthentication): + # Disable csrf for the rest apis + def enforce_csrf(self, request): + return diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py new file mode 100644 index 00000000..d8b5799d --- /dev/null +++ b/apps/api/plane/authentication/urls.py @@ -0,0 +1,132 @@ +from django.urls import path + +from .views import ( + CSRFTokenEndpoint, + ForgotPasswordEndpoint, + SetUserPasswordEndpoint, + ResetPasswordEndpoint, + ChangePasswordEndpoint, + # App + EmailCheckEndpoint, + GitLabCallbackEndpoint, + GitLabOauthInitiateEndpoint, + GitHubCallbackEndpoint, + GitHubOauthInitiateEndpoint, + GoogleCallbackEndpoint, + GoogleOauthInitiateEndpoint, + MagicGenerateEndpoint, + MagicSignInEndpoint, + MagicSignUpEndpoint, + SignInAuthEndpoint, + SignOutAuthEndpoint, + SignUpAuthEndpoint, + ForgotPasswordSpaceEndpoint, + ResetPasswordSpaceEndpoint, + # Space + EmailCheckSpaceEndpoint, + GitLabCallbackSpaceEndpoint, + GitLabOauthInitiateSpaceEndpoint, + GitHubCallbackSpaceEndpoint, + GitHubOauthInitiateSpaceEndpoint, + GoogleCallbackSpaceEndpoint, + GoogleOauthInitiateSpaceEndpoint, + MagicGenerateSpaceEndpoint, + MagicSignInSpaceEndpoint, + MagicSignUpSpaceEndpoint, + SignInAuthSpaceEndpoint, + SignUpAuthSpaceEndpoint, + SignOutAuthSpaceEndpoint, +) + +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="space-sign-in"), + path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"), + # signout + path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"), + path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"), + # 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( + "spaces/magic-generate/", + MagicGenerateSpaceEndpoint.as_view(), + name="space-magic-generate", + ), + path( + "spaces/magic-sign-in/", + MagicSignInSpaceEndpoint.as_view(), + name="space-magic-sign-in", + ), + path( + "spaces/magic-sign-up/", + MagicSignUpSpaceEndpoint.as_view(), + name="space-magic-sign-up", + ), + ## Google Oauth + path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"), + path("google/callback/", GoogleCallbackEndpoint.as_view(), name="google-callback"), + path( + "spaces/google/", + GoogleOauthInitiateSpaceEndpoint.as_view(), + name="space-google-initiate", + ), + path( + "spaces/google/callback/", + GoogleCallbackSpaceEndpoint.as_view(), + name="space-google-callback", + ), + ## Github Oauth + path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"), + path("github/callback/", GitHubCallbackEndpoint.as_view(), name="github-callback"), + path( + "spaces/github/", + GitHubOauthInitiateSpaceEndpoint.as_view(), + name="space-github-initiate", + ), + path( + "spaces/github/callback/", + GitHubCallbackSpaceEndpoint.as_view(), + name="space-github-callback", + ), + ## Gitlab Oauth + path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"), + path("gitlab/callback/", GitLabCallbackEndpoint.as_view(), name="gitlab-callback"), + path( + "spaces/gitlab/", + GitLabOauthInitiateSpaceEndpoint.as_view(), + name="space-gitlab-initiate", + ), + path( + "spaces/gitlab/callback/", + GitLabCallbackSpaceEndpoint.as_view(), + name="space-gitlab-callback", + ), + # 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( + "reset-password///", + ResetPasswordEndpoint.as_view(), + name="forgot-password", + ), + path( + "spaces/forgot-password/", + ForgotPasswordSpaceEndpoint.as_view(), + name="space-forgot-password", + ), + path( + "spaces/reset-password///", + ResetPasswordSpaceEndpoint.as_view(), + name="space-forgot-password", + ), + path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"), + path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"), +] diff --git a/apps/api/plane/authentication/utils/host.py b/apps/api/plane/authentication/utils/host.py new file mode 100644 index 00000000..415791a8 --- /dev/null +++ b/apps/api/plane/authentication/utils/host.py @@ -0,0 +1,63 @@ +# Django imports +from django.conf import settings +from django.http import HttpRequest + +# Third party imports +from rest_framework.request import Request + +# Module imports +from plane.utils.ip_address import get_client_ip + + +def base_host( + request: Request | HttpRequest, + is_admin: bool = False, + is_space: bool = False, + is_app: bool = False, +) -> str: + """Utility function to return host / origin from the request""" + # Calculate the base origin from request + base_origin = settings.WEB_URL or settings.APP_BASE_URL + + # Admin redirection + if is_admin: + admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None) + if not isinstance(admin_base_path, str): + admin_base_path = "/god-mode/" + if not admin_base_path.startswith("/"): + admin_base_path = "/" + admin_base_path + if not admin_base_path.endswith("/"): + admin_base_path += "/" + + if settings.ADMIN_BASE_URL: + return settings.ADMIN_BASE_URL + admin_base_path + else: + return base_origin + admin_base_path + + # Space redirection + if is_space: + space_base_path = getattr(settings, "SPACE_BASE_PATH", None) + if not isinstance(space_base_path, str): + space_base_path = "/spaces/" + if not space_base_path.startswith("/"): + space_base_path = "/" + space_base_path + if not space_base_path.endswith("/"): + space_base_path += "/" + + if settings.SPACE_BASE_URL: + return settings.SPACE_BASE_URL + space_base_path + else: + return base_origin + space_base_path + + # App Redirection + if is_app: + if settings.APP_BASE_URL: + return settings.APP_BASE_URL + else: + return base_origin + + return base_origin + + +def user_ip(request: Request | HttpRequest) -> str: + return get_client_ip(request=request) diff --git a/apps/api/plane/authentication/utils/login.py b/apps/api/plane/authentication/utils/login.py new file mode 100644 index 00000000..fe6fdad9 --- /dev/null +++ b/apps/api/plane/authentication/utils/login.py @@ -0,0 +1,24 @@ +# Django imports +from django.contrib.auth import login +from django.conf import settings + +# Module imports +from plane.utils.host import base_host +from plane.utils.ip_address import get_client_ip + + +def user_login(request, user, is_app=False, is_admin=False, is_space=False): + login(request=request, user=user) + + # If is admin cookie set the custom age + if is_admin: + request.session.set_expiry(settings.ADMIN_SESSION_COOKIE_AGE) + + device_info = { + "user_agent": request.META.get("HTTP_USER_AGENT", ""), + "ip_address": get_client_ip(request=request), + "domain": base_host(request=request, is_app=is_app, is_admin=is_admin, is_space=is_space), + } + request.session["device_info"] = device_info + request.session.save() + return diff --git a/apps/api/plane/authentication/utils/redirection_path.py b/apps/api/plane/authentication/utils/redirection_path.py new file mode 100644 index 00000000..82139b82 --- /dev/null +++ b/apps/api/plane/authentication/utils/redirection_path.py @@ -0,0 +1,42 @@ +from plane.db.models import Profile, Workspace, WorkspaceMemberInvite + + +def get_redirection_path(user): + # Handle redirections + profile, _ = Profile.objects.get_or_create(user=user) + + # Redirect to onboarding if the user is not onboarded yet + if not profile.is_onboarded: + return "onboarding" + + # Redirect to the last workspace if the user has last workspace + if ( + profile.last_workspace_id + and Workspace.objects.filter( + pk=profile.last_workspace_id, + workspace_member__member_id=user.id, + workspace_member__is_active=True, + ).exists() + ): + workspace = Workspace.objects.filter( + pk=profile.last_workspace_id, + workspace_member__member_id=user.id, + workspace_member__is_active=True, + ).first() + return f"{workspace.slug}" + + fallback_workspace = ( + Workspace.objects.filter(workspace_member__member_id=user.id, workspace_member__is_active=True) + .order_by("created_at") + .first() + ) + # Redirect to fallback workspace + if fallback_workspace: + return f"{fallback_workspace.slug}" + + # Redirect to invitations if the user has unaccepted invitations + if WorkspaceMemberInvite.objects.filter(email=user.email).count(): + return "invitations" + + # Redirect the user to create workspace + return "create-workspace" diff --git a/apps/api/plane/authentication/utils/user_auth_workflow.py b/apps/api/plane/authentication/utils/user_auth_workflow.py new file mode 100644 index 00000000..13de4c28 --- /dev/null +++ b/apps/api/plane/authentication/utils/user_auth_workflow.py @@ -0,0 +1,5 @@ +from .workspace_project_join import process_workspace_project_invitations + + +def post_user_auth_workflow(user, is_signup, request): + process_workspace_project_invitations(user=user) diff --git a/apps/api/plane/authentication/utils/workspace_project_join.py b/apps/api/plane/authentication/utils/workspace_project_join.py new file mode 100644 index 00000000..bd5ad850 --- /dev/null +++ b/apps/api/plane/authentication/utils/workspace_project_join.py @@ -0,0 +1,71 @@ +from plane.db.models import ( + ProjectMember, + ProjectMemberInvite, + WorkspaceMember, + WorkspaceMemberInvite, +) +from plane.utils.cache import invalidate_cache_directly + + +def process_workspace_project_invitations(user): + """This function takes in User and adds him to all workspace and projects that the user has accepted invited of""" + + # Check if user has any accepted invites for workspace and add them to workspace + workspace_member_invites = WorkspaceMemberInvite.objects.filter(email=user.email, accepted=True) + + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=workspace_member_invite.workspace_id, + member=user, + role=workspace_member_invite.role, + ) + for workspace_member_invite in workspace_member_invites + ], + ignore_conflicts=True, + ) + + [ + invalidate_cache_directly( + path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/", + url_params=False, + user=False, + multiple=True, + ) + for workspace_member_invite in workspace_member_invites + ] + + # Check if user has any project invites + project_member_invites = ProjectMemberInvite.objects.filter(email=user.email, accepted=True) + + # Add user to workspace + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=project_member_invite.workspace_id, + role=(project_member_invite.role if project_member_invite.role in [5, 15] else 15), + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=(project_member_invite.role if project_member_invite.role in [5, 15] else 15), + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py new file mode 100644 index 00000000..24ae1f67 --- /dev/null +++ b/apps/api/plane/authentication/views/__init__.py @@ -0,0 +1,36 @@ +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.signout import SignOutAuthEndpoint + + +from .space.email import SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint + +from .space.github import GitHubCallbackSpaceEndpoint, GitHubOauthInitiateSpaceEndpoint + +from .space.gitlab import GitLabCallbackSpaceEndpoint, GitLabOauthInitiateSpaceEndpoint + +from .space.google import GoogleCallbackSpaceEndpoint, GoogleOauthInitiateSpaceEndpoint + +from .space.magic import ( + MagicGenerateSpaceEndpoint, + MagicSignInSpaceEndpoint, + MagicSignUpSpaceEndpoint, +) + +from .space.signout import SignOutAuthSpaceEndpoint + +from .space.check import EmailCheckSpaceEndpoint + +from .space.password_management import ( + ForgotPasswordSpaceEndpoint, + ResetPasswordSpaceEndpoint, +) +from .app.password_management import ForgotPasswordEndpoint, ResetPasswordEndpoint diff --git a/apps/api/plane/authentication/views/app/check.py b/apps/api/plane/authentication/views/app/check.py new file mode 100644 index 00000000..10457b45 --- /dev/null +++ b/apps/api/plane/authentication/views/app/check.py @@ -0,0 +1,99 @@ +# Python imports +import os + +# Django imports +from django.core.validators import validate_email +from django.core.exceptions import ValidationError + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +## Module imports +from plane.db.models import User +from plane.license.models import Instance +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.authentication.rate_limit import AuthenticationThrottle +from plane.license.utils.instance_value import get_configuration_value + + +class EmailCheckEndpoint(APIView): + permission_classes = [AllowAny] + + 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_message="INSTANCE_NOT_CONFIGURED", + ) + 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": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), + }, + ] + ) + + smtp_configured = bool(EMAIL_HOST) + is_magic_login_enabled = ENABLE_MAGIC_LINK_LOGIN == "1" + + email = request.data.get("email", False) + + # Return error if email is not present + if not email: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], + error_message="EMAIL_REQUIRED", + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + # Lower the email + email = str(email).lower().strip() + + # Validate email + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + ) + 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() + + # If existing user + if existing_user: + # Return response + return Response( + { + "existing": True, + "status": ( + "MAGIC_CODE" + if existing_user.is_password_autoset and smtp_configured and is_magic_login_enabled + else "CREDENTIAL" + ), + }, + status=status.HTTP_200_OK, + ) + # Else return response + return Response( + { + "existing": False, + "status": ("MAGIC_CODE" if smtp_configured and is_magic_login_enabled else "CREDENTIAL"), + }, + status=status.HTTP_200_OK, + ) diff --git a/apps/api/plane/authentication/views/app/email.py b/apps/api/plane/authentication/views/app/email.py new file mode 100644 index 00000000..864ff102 --- /dev/null +++ b/apps/api/plane/authentication/views/app/email.py @@ -0,0 +1,234 @@ +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.credentials.email import EmailProvider +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.db.models import User +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.path_validator import get_safe_redirect_url + + +class SignInAuthEndpoint(View): + def post(self, request): + next_path = request.POST.get("next_path") + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + # Base URL join + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + # set the referer as session to redirect after login + email = request.POST.get("email", False) + password = request.POST.get("password", False) + + ## Raise exception if any of the above are missing + if not email or not password: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_EMAIL_PASSWORD_SIGN_IN"], + error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + # Next path + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + # Validate email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_IN"], + error_message="INVALID_EMAIL_SIGN_IN", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + existing_user = User.objects.filter(email=email).first() + + if not existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + try: + provider = EmailProvider( + request=request, + key=email, + code=password, + is_signup=False, + callback=post_user_auth_workflow, + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_app=True) + # Get the redirection path + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + + # Get the safe redirect URL + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + +class SignUpAuthEndpoint(View): + def post(self, request): + next_path = request.POST.get("next_path") + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + email = request.POST.get("email", False) + password = request.POST.get("password", False) + ## Raise exception if any of the above are missing + if not email or not password: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_EMAIL_PASSWORD_SIGN_UP"], + error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + # Validate the email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_UP"], + error_message="INVALID_EMAIL_SIGN_UP", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + # Existing user + existing_user = User.objects.filter(email=email).first() + + if existing_user: + # Existing User + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], + error_message="USER_ALREADY_EXIST", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + try: + provider = EmailProvider( + request=request, + key=email, + code=password, + is_signup=True, + callback=post_user_auth_workflow, + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_app=True) + # Get the redirection path + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/github.py b/apps/api/plane/authentication/views/app/github.py new file mode 100644 index 00000000..4720fc7d --- /dev/null +++ b/apps/api/plane/authentication/views/app/github.py @@ -0,0 +1,102 @@ +# Python imports +import uuid + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +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.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.path_validator import get_safe_redirect_url + + +class GitHubOauthInitiateEndpoint(View): + def get(self, request): + # Get host and next path + request.session["host"] = base_host(request=request, is_app=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(next_path) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + try: + state = uuid.uuid4().hex + provider = GitHubOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + +class GitHubCallbackEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + try: + provider = GitHubOAuthProvider(request=request, code=code, callback=post_user_auth_workflow) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_app=True) + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + + # Get the safe redirect URL + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=path, params={}) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/gitlab.py b/apps/api/plane/authentication/views/app/gitlab.py new file mode 100644 index 00000000..665af00c --- /dev/null +++ b/apps/api/plane/authentication/views/app/gitlab.py @@ -0,0 +1,103 @@ +# Python imports +import uuid + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider +from plane.authentication.utils.login import user_login +from plane.authentication.utils.redirection_path import get_redirection_path +from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.path_validator import get_safe_redirect_url + + +class GitLabOauthInitiateEndpoint(View): + def get(self, request): + # Get host and next path + request.session["host"] = base_host(request=request, is_app=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(next_path) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + try: + state = uuid.uuid4().hex + provider = GitLabOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + +class GitLabCallbackEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"], + error_message="GITLAB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"], + error_message="GITLAB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + try: + provider = GitLabOAuthProvider(request=request, code=code, callback=post_user_auth_workflow) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_app=True) + # Get the redirection path + + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + # redirect to referer path + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=path, params={}) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/google.py b/apps/api/plane/authentication/views/app/google.py new file mode 100644 index 00000000..0ee81c76 --- /dev/null +++ b/apps/api/plane/authentication/views/app/google.py @@ -0,0 +1,100 @@ +# Python imports +import uuid + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + + +# Module imports +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.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.path_validator import get_safe_redirect_url + + +class GoogleOauthInitiateEndpoint(View): + def get(self, request): + request.session["host"] = base_host(request=request, is_app=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(next_path) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = GoogleOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + +class GoogleCallbackEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"], + error_message="GOOGLE_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"], + error_message="GOOGLE_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + try: + provider = GoogleOAuthProvider(request=request, code=code, callback=post_user_auth_workflow) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_app=True) + # Get the redirection path + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=path, params={}) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/magic.py b/apps/api/plane/authentication/views/app/magic.py new file mode 100644 index 00000000..518a5cde --- /dev/null +++ b/apps/api/plane/authentication/views/app/magic.py @@ -0,0 +1,192 @@ +# Django imports +from django.core.validators import validate_email +from django.http import HttpResponseRedirect +from django.views import View + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +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.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.bgtasks.magic_link_code_task import magic_link +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.db.models import User, Profile +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.authentication.rate_limit import AuthenticationThrottle +from plane.utils.path_validator import get_safe_redirect_url + + +class MagicGenerateEndpoint(APIView): + permission_classes = [AllowAny] + + 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_message="INSTANCE_NOT_CONFIGURED", + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + email = request.data.get("email", "").strip().lower() + try: + validate_email(email) + adapter = MagicCodeProvider(request=request, key=email) + key, token = adapter.initiate() + # If the smtp is configured send through here + magic_link.delay(email, key, token) + 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) + + +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() + next_path = request.POST.get("next_path") + + if code == "" or email == "": + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED"], + error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + # Existing User + existing_user = User.objects.filter(email=email).first() + + if not existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + try: + provider = MagicCodeProvider( + request=request, + key=f"magic_{email}", + code=code, + callback=post_user_auth_workflow, + ) + user = provider.authenticate() + profile, _ = Profile.objects.get_or_create(user=user) + # Login the user and record his device info + user_login(request=request, user=user, is_app=True) + if user.is_password_autoset and profile.is_onboarded: + # Redirect to the home page + path = "/" + else: + # Get the redirection path + path = str(next_path) if next_path else str(get_redirection_path(user=user)) + # redirect to referer path + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=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() + next_path = request.POST.get("next_path") + + if code == "" or email == "": + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"], + error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + # Existing user + existing_user = User.objects.filter(email=email).first() + if existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], + error_message="USER_ALREADY_EXIST", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + try: + provider = MagicCodeProvider( + request=request, + key=f"magic_{email}", + code=code, + callback=post_user_auth_workflow, + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_app=True) + # Get the redirection path + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + # redirect to referer path + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/password_management.py b/apps/api/plane/authentication/views/app/password_management.py new file mode 100644 index 00000000..de0baa71 --- /dev/null +++ b/apps/api/plane/authentication/views/app/password_management.py @@ -0,0 +1,172 @@ +# Python imports +import os +from urllib.parse import urlencode, urljoin + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView +from zxcvbn import zxcvbn + +# Django imports +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.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.views import View + +# Module imports +from plane.bgtasks.forgot_password_task import forgot_password +from plane.license.models import Instance +from plane.db.models import User +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +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) + + return uidb64, token + + +class ForgotPasswordEndpoint(APIView): + permission_classes = [AllowAny] + + throttle_classes = [AuthenticationThrottle] + + def post(self, request): + email = request.data.get("email") + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + 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")}]) + + if not (EMAIL_HOST): + exc = AuthenticationException( + 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) + + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + # Get the user + user = User.objects.filter(email=email).first() + if user: + # Get the reset token for user + uidb64, token = generate_password_token(user=user) + current_site = base_host(request=request, is_app=True) + # send the forgot password email + forgot_password.delay(user.first_name, user.email, uidb64, token, current_site) + return Response( + {"message": "Check your email to reset your password"}, + status=status.HTTP_200_OK, + ) + exc = AuthenticationException( + 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) + + +class ResetPasswordEndpoint(View): + def post(self, request, uidb64, token): + try: + # Decode the id from the uidb64 + try: + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(id=id) + except (ValueError, User.DoesNotExist): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], + error_message="INVALID_PASSWORD_TOKEN", + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "accounts/reset-password?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # 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_message="INVALID_PASSWORD_TOKEN", + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "accounts/reset-password?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + password = request.POST.get("password", False) + + if not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + url = urljoin( + base_host(request=request, is_app=True), + "accounts/reset-password?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Check the password complexity + results = zxcvbn(password) + if results["score"] < 3: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + url = urljoin( + base_host(request=request, is_app=True), + "accounts/reset-password?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # set_password also hashes the password that the user will get + user.set_password(password) + user.is_password_autoset = False + user.save() + + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode({"success": True}), + ) + return HttpResponseRedirect(url) + except DjangoUnicodeDecodeError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_PASSWORD_TOKEN"], + error_message="EXPIRED_PASSWORD_TOKEN", + ) + url = urljoin( + base_host(request=request, is_app=True), + "accounts/reset-password?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/signout.py b/apps/api/plane/authentication/views/app/signout.py new file mode 100644 index 00000000..b8019dac --- /dev/null +++ b/apps/api/plane/authentication/views/app/signout.py @@ -0,0 +1,24 @@ +# Django imports +from django.views import View +from django.contrib.auth import logout +from django.http import HttpResponseRedirect +from django.utils import timezone + +# Module imports +from plane.authentication.utils.host import user_ip, base_host +from plane.db.models import User + + +class SignOutAuthEndpoint(View): + def post(self, request): + # Get user + try: + user = User.objects.get(pk=request.user.id) + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save() + # Log the user out + logout(request) + return HttpResponseRedirect(base_host(request=request, is_app=True)) + except Exception: + return HttpResponseRedirect(base_host(request=request, is_app=True)) diff --git a/apps/api/plane/authentication/views/common.py b/apps/api/plane/authentication/views/common.py new file mode 100644 index 00000000..c5dd1714 --- /dev/null +++ b/apps/api/plane/authentication/views/common.py @@ -0,0 +1,134 @@ +# Django imports +from django.shortcuts import render + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView +from zxcvbn import zxcvbn + +## Module imports +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 ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +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] + + 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) + + +def csrf_failure(request, reason=""): + """Custom CSRF failure view""" + return render( + request, + "csrf_failure.html", + {"reason": reason, "root_url": base_host(request=request)}, + ) + + +class ChangePasswordEndpoint(APIView): + def post(self, request): + user = User.objects.get(pk=request.user.id) + + # If the user password is not autoset then we need to check the old passwords + if not user.is_password_autoset: + old_password = request.data.get("old_password", False) + if not old_password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"], + error_message="MISSING_PASSWORD", + payload={"error": "Old password is missing"}, + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + # Get the new password + new_password = request.data.get("new_password", False) + + if not new_password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"], + error_message="MISSING_PASSWORD", + payload={"error": "Old or new password is missing"}, + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + # If the user password is not autoset then we need to check the old passwords + if not user.is_password_autoset and not user.check_password(old_password): + exc = AuthenticationException( + 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) + + # check the password score + results = zxcvbn(new_password) + if results["score"] < 3: + exc = AuthenticationException( + 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) + + # set_password also hashes the password that the user will get + user.set_password(new_password) + user.is_password_autoset = False + user.save() + user_login(user=user, request=request, is_app=True) + return Response({"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) + password = request.data.get("password", False) + + # If the user password is not autoset then return error + if not user.is_password_autoset: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_ALREADY_SET"], + error_message="PASSWORD_ALREADY_SET", + payload={"error": "Your password is already set please change your password from profile"}, + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + # Check password validation + if not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + results = zxcvbn(password) + if results["score"] < 3: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + # Set the user password + user.set_password(password) + user.is_password_autoset = False + user.save() + # Login the user as the session is invalidated + user_login(user=user, request=request, is_app=True) + # Return the user + serializer = UserSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/authentication/views/space/check.py b/apps/api/plane/authentication/views/space/check.py new file mode 100644 index 00000000..95a5e68d --- /dev/null +++ b/apps/api/plane/authentication/views/space/check.py @@ -0,0 +1,97 @@ +# Python imports +import os + +# Django imports +from django.core.validators import validate_email +from django.core.exceptions import ValidationError + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +## Module imports +from plane.db.models import User +from plane.license.models import Instance +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +from plane.authentication.rate_limit import AuthenticationThrottle +from plane.license.utils.instance_value import get_configuration_value + + +class EmailCheckSpaceEndpoint(APIView): + permission_classes = [AllowAny] + + 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_message="INSTANCE_NOT_CONFIGURED", + ) + 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": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), + }, + ] + ) + + smtp_configured = bool(EMAIL_HOST) + is_magic_login_enabled = ENABLE_MAGIC_LINK_LOGIN == "1" + + email = request.data.get("email", False) + + # Return error if email is not present + if not email: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], + error_message="EMAIL_REQUIRED", + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + email = str(email).lower().strip() + # Validate email + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + ) + 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() + + # If existing user + if existing_user: + # Return response + return Response( + { + "existing": True, + "status": ( + "MAGIC_CODE" + if existing_user.is_password_autoset and smtp_configured and is_magic_login_enabled + else "CREDENTIAL" + ), + }, + status=status.HTTP_200_OK, + ) + # Else return response + return Response( + { + "existing": False, + "status": ("MAGIC_CODE" if smtp_configured and is_magic_login_enabled else "CREDENTIAL"), + }, + status=status.HTTP_200_OK, + ) diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py new file mode 100644 index 00000000..3d092591 --- /dev/null +++ b/apps/api/plane/authentication/views/space/email.py @@ -0,0 +1,187 @@ +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.http import HttpResponseRedirect +from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme + +# Module imports +from plane.authentication.provider.credentials.email import EmailProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.db.models import User +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts + + +class SignInAuthSpaceEndpoint(View): + def post(self, request): + next_path = request.POST.get("next_path") + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + # set the referer as session to redirect after login + email = request.POST.get("email", False) + password = request.POST.get("password", False) + + ## Raise exception if any of the above are missing + if not email or not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_EMAIL_PASSWORD_SIGN_IN"], + error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + # Validate email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_IN"], + error_message="INVALID_EMAIL_SIGN_IN", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + # Existing User + existing_user = User.objects.filter(email=email).first() + + if not existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + try: + provider = EmailProvider(request=request, key=email, code=password, is_signup=False) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # redirect to referer path + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + +class SignUpAuthSpaceEndpoint(View): + def post(self, request): + next_path = request.POST.get("next_path") + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + email = request.POST.get("email", False) + password = request.POST.get("password", False) + ## Raise exception if any of the above are missing + if not email or not password: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_EMAIL_PASSWORD_SIGN_UP"], + error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + # Validate the email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_UP"], + error_message="INVALID_EMAIL_SIGN_UP", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + # Existing User + existing_user = User.objects.filter(email=email).first() + + if existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], + error_message="USER_ALREADY_EXIST", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + try: + provider = EmailProvider(request=request, key=email, code=password, is_signup=True) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # redirect to referer path + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/github.py b/apps/api/plane/authentication/views/space/github.py new file mode 100644 index 00000000..f12498d3 --- /dev/null +++ b/apps/api/plane/authentication/views/space/github.py @@ -0,0 +1,101 @@ +# Python imports +import uuid + +# Django import +from django.http import HttpResponseRedirect +from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme + +# Module imports +from plane.authentication.provider.oauth.github import GitHubOAuthProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts + + +class GitHubOauthInitiateSpaceEndpoint(View): + def get(self, request): + # Get host and next path + request.session["host"] = base_host(request=request, is_space=True) + next_path = request.GET.get("next_path") + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = GitHubOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + +class GitHubCallbackSpaceEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + base_host = request.session.get("host") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + try: + 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) + # Process workspace and project invitations + # redirect to referer path + next_path = validate_next_path(next_path=next_path) + + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/gitlab.py b/apps/api/plane/authentication/views/space/gitlab.py new file mode 100644 index 00000000..498916b3 --- /dev/null +++ b/apps/api/plane/authentication/views/space/gitlab.py @@ -0,0 +1,102 @@ +# Python imports +import uuid + +# Django import +from django.http import HttpResponseRedirect +from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme + +# Module imports +from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts + + +class GitLabOauthInitiateSpaceEndpoint(View): + def get(self, request): + # Get host and next path + request.session["host"] = base_host(request=request, is_space=True) + next_path = request.GET.get("next_path") + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = GitLabOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + +class GitLabCallbackSpaceEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + base_host = request.session.get("host") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"], + error_message="GITLAB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"], + error_message="GITLAB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + try: + provider = GitLabOAuthProvider(request=request, code=code) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # Process workspace and project invitations + # redirect to referer path + next_path = validate_next_path(next_path=next_path) + + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/google.py b/apps/api/plane/authentication/views/space/google.py new file mode 100644 index 00000000..0f02c1f9 --- /dev/null +++ b/apps/api/plane/authentication/views/space/google.py @@ -0,0 +1,98 @@ +# Python imports +import uuid + +# Django import +from django.http import HttpResponseRedirect +from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme + +# Module imports +from plane.authentication.provider.oauth.google import GoogleOAuthProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts + + +class GoogleOauthInitiateSpaceEndpoint(View): + def get(self, request): + request.session["host"] = base_host(request=request, is_space=True) + next_path = request.GET.get("next_path") + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = GoogleOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + +class GoogleCallbackSpaceEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + base_host = request.session.get("host") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"], + error_message="GOOGLE_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"], + error_message="GOOGLE_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + try: + 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) + # redirect to referer path + next_path = validate_next_path(next_path=next_path) + + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py new file mode 100644 index 00000000..df940b32 --- /dev/null +++ b/apps/api/plane/authentication/views/space/magic.py @@ -0,0 +1,166 @@ +# Django imports +from django.core.validators import validate_email +from django.http import HttpResponseRedirect +from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +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.utils.login import user_login +from plane.bgtasks.magic_link_code_task import magic_link +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.db.models import User +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts + + +class MagicGenerateSpaceEndpoint(APIView): + 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_message="INSTANCE_NOT_CONFIGURED", + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + email = request.data.get("email", "").strip().lower() + try: + validate_email(email) + adapter = MagicCodeProvider(request=request, key=email) + key, token = adapter.initiate() + # If the smtp is configured send through here + magic_link.delay(email, key, token) + 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) + + +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() + next_path = request.POST.get("next_path") + + if code == "" or email == "": + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED"], + error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + existing_user = User.objects.filter(email=email).first() + + if not existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + # Active User + try: + provider = MagicCodeProvider(request=request, key=f"magic_{email}", code=code) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # redirect to referer path + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) + + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + +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() + next_path = request.POST.get("next_path") + + if code == "" or email == "": + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"], + error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + # Existing User + existing_user = User.objects.filter(email=email).first() + # Already existing + if existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], + error_message="USER_ALREADY_EXIST", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) + + try: + provider = MagicCodeProvider(request=request, key=f"magic_{email}", code=code) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # redirect to referer path + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) + + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/password_management.py b/apps/api/plane/authentication/views/space/password_management.py new file mode 100644 index 00000000..12cc88f6 --- /dev/null +++ b/apps/api/plane/authentication/views/space/password_management.py @@ -0,0 +1,156 @@ +# Python imports +import os +from urllib.parse import urlencode + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView +from zxcvbn import zxcvbn + +# Django imports +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.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.views import View + +# Module imports +from plane.bgtasks.forgot_password_task import forgot_password +from plane.license.models import Instance +from plane.db.models import User +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +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) + + return uidb64, token + + +class ForgotPasswordSpaceEndpoint(APIView): + permission_classes = [AllowAny] + + throttle_classes = [AuthenticationThrottle] + + def post(self, request): + email = request.data.get("email") + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + 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"), + }, + ] + ) + + if not (EMAIL_HOST): + exc = AuthenticationException( + 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) + + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + # Get the user + user = User.objects.filter(email=email).first() + if user: + # Get the reset token for user + uidb64, token = generate_password_token(user=user) + current_site = base_host(request=request, is_space=True) + # send the forgot password email + forgot_password.delay(user.first_name, user.email, uidb64, token, current_site) + return Response( + {"message": "Check your email to reset your password"}, + status=status.HTTP_200_OK, + ) + exc = AuthenticationException( + 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) + + +class ResetPasswordSpaceEndpoint(View): + def post(self, request, uidb64, token): + try: + # Decode the id from the uidb64 + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(id=id) + + # check if the token is valid for the user + if not PasswordResetTokenGenerator().check_token(user, token): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], + error_message="INVALID_PASSWORD_TOKEN", + ) + params = exc.get_error_dict() + url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(params)}" + return HttpResponseRedirect(url) + + password = request.POST.get("password", False) + + if not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 + return HttpResponseRedirect(url) + + # Check the password complexity + results = zxcvbn(password) + if results["score"] < 3: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 + return HttpResponseRedirect(url) + + # set_password also hashes the password that the user will get + user.set_password(password) + user.is_password_autoset = False + user.save() + + return HttpResponseRedirect(base_host(request=request, is_space=True)) + except DjangoUnicodeDecodeError: + exc = AuthenticationException( + 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())}" # noqa: E501 + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/signout.py b/apps/api/plane/authentication/views/space/signout.py new file mode 100644 index 00000000..aa890f97 --- /dev/null +++ b/apps/api/plane/authentication/views/space/signout.py @@ -0,0 +1,29 @@ +# Django imports +from django.views import View +from django.contrib.auth import logout +from django.http import HttpResponseRedirect +from django.utils import timezone + +# Module imports +from plane.authentication.utils.host import base_host, user_ip +from plane.db.models import User +from plane.utils.path_validator import get_safe_redirect_url + + +class SignOutAuthSpaceEndpoint(View): + def post(self, request): + next_path = request.POST.get("next_path") + + # Get user + try: + user = User.objects.get(pk=request.user.id) + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save() + # Log the user out + logout(request) + url = get_safe_redirect_url(base_url=base_host(request=request, is_space=True), next_path=next_path) + return HttpResponseRedirect(url) + except Exception: + url = get_safe_redirect_url(base_url=base_host(request=request, is_space=True), next_path=next_path) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/bgtasks/__init__.py b/apps/api/plane/bgtasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/bgtasks/analytic_plot_export.py b/apps/api/plane/bgtasks/analytic_plot_export.py new file mode 100644 index 00000000..845fb50d --- /dev/null +++ b/apps/api/plane/bgtasks/analytic_plot_export.py @@ -0,0 +1,430 @@ +# Python imports +import csv +import io +import logging + +# Third party imports +from celery import shared_task + +# Django imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags +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 +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.analytics_plot import build_graph_plot +from plane.utils.exception_logger import log_exception +from plane.utils.issue_filters import issue_filters + +row_mapping = { + "state__name": "State", + "state__group": "State Group", + "labels__id": "Label", + "assignees__id": "Assignee Name", + "start_date": "Start Date", + "target_date": "Due Date", + "completed_at": "Completed At", + "created_at": "Created At", + "issue_count": "Issue Count", + "priority": "Priority", + "estimate": "Estimate", + "issue_cycle__cycle_id": "Cycle", + "issue_module__module_id": "Module", +} + +ASSIGNEE_ID = "assignees__id" +LABEL_ID = "labels__id" +STATE_ID = "state_id" +CYCLE_ID = "issue_cycle__cycle_id" +MODULE_ID = "issue_module__module_id" + + +def send_export_email(email, slug, csv_buffer, rows): + """Helper function to send export email.""" + subject = "Your Export is ready" + html_content = render_to_string("emails/exports/analytics.html", {}) + text_content = strip_tags(html_content) + + csv_buffer.seek(0) + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[email], + connection=connection, + ) + msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue()) + msg.send(fail_silently=False) + return + + +def get_assignee_details(slug, filters): + """Fetch assignee details if required.""" + return ( + Issue.issue_objects.filter( + 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_url", + "assignees__display_name", + "assignees__first_name", + "assignees__last_name", + "assignees__id", + ) + ) + + +def get_label_details(slug, filters): + """Fetch label details if required""" + return ( + Issue.objects.filter( + workspace__slug=slug, + **filters, + labels__id__isnull=False, + label_issue__deleted_at__isnull=True, + ) + .distinct("labels__id") + .order_by("labels__id") + .values("labels__id", "labels__color", "labels__name") + ) + + +def get_state_details(slug, filters): + return ( + Issue.issue_objects.filter(workspace__slug=slug, **filters) + .distinct("state_id") + .order_by("state_id") + .values("state_id", "state__name", "state__color") + ) + + +def get_module_details(slug, filters): + return ( + Issue.issue_objects.filter( + 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") + ) + + +def get_cycle_details(slug, filters): + return ( + 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") + ) + + +def generate_csv_from_rows(rows): + """Generate CSV buffer from rows.""" + csv_buffer = io.StringIO() + writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL) + [writer.writerow(row) for row in rows] + return csv_buffer + + +def generate_segmented_rows( + distribution, + x_axis, + y_axis, + segment, + key, + assignee_details, + label_details, + state_details, + cycle_details, + module_details, +): + segment_zero = list(set(item.get("segment") for sublist in distribution.values() for item in sublist)) + + segmented = segment + + row_zero = [ + row_mapping.get(x_axis, "X-Axis"), + row_mapping.get(y_axis, "Y-Axis"), + ] + segment_zero + + rows = [] + for item, data in distribution.items(): + generated_row = [ + item, + sum(obj.get(key) for obj in data if obj.get(key) is not None), + ] + + for segment in segment_zero: + value = next((x.get(key) for x in data if x.get("segment") == segment), "0") + generated_row.append(value) + + if x_axis == ASSIGNEE_ID: + assignee = next( + (user for user in assignee_details if str(user[ASSIGNEE_ID]) == str(item)), + None, + ) + if assignee: + generated_row[0] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + + if x_axis == LABEL_ID: + label = next((lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), None) + + if label: + generated_row[0] = f"{label['labels__name']}" + + if x_axis == STATE_ID: + state = next((sta for sta in state_details if str(sta[STATE_ID]) == str(item)), None) + + if state: + generated_row[0] = f"{state['state__name']}" + + if x_axis == CYCLE_ID: + cycle = next((cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), None) + + if cycle: + generated_row[0] = f"{cycle['issue_cycle__cycle__name']}" + + if x_axis == MODULE_ID: + module = next( + (mod for mod in module_details if str(mod[MODULE_ID]) == str(item)), + None, + ) + + if module: + generated_row[0] = f"{module['issue_module__module__name']}" + + rows.append(tuple(generated_row)) + + if segmented == ASSIGNEE_ID: + for index, segm in enumerate(row_zero[2:]): + assignee = next( + (user for user in assignee_details if str(user[ASSIGNEE_ID]) == str(segm)), + None, + ) + if assignee: + row_zero[index + 2] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + + 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) + if label: + row_zero[index + 2] = label["labels__name"] + + 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) + if state: + row_zero[index + 2] = state["state__name"] + + 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) + if module: + row_zero[index + 2] = module["issue_module__module__name"] + + 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) + if cycle: + row_zero[index + 2] = cycle["issue_cycle__cycle__name"] + + return [tuple(row_zero)] + rows + + +def generate_non_segmented_rows( + distribution, + x_axis, + y_axis, + key, + assignee_details, + label_details, + state_details, + cycle_details, + module_details, +): + rows = [] + for item, data in distribution.items(): + row = [item, data[0].get("count" if y_axis == "issue_count" else "estimate")] + + if x_axis == ASSIGNEE_ID: + assignee = next( + (user for user in assignee_details if str(user[ASSIGNEE_ID]) == str(item)), + None, + ) + if assignee: + row[0] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + + if x_axis == LABEL_ID: + label = next((lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), None) + + if label: + row[0] = f"{label['labels__name']}" + + if x_axis == STATE_ID: + state = next((sta for sta in state_details if str(sta[STATE_ID]) == str(item)), None) + + if state: + row[0] = f"{state['state__name']}" + + if x_axis == CYCLE_ID: + cycle = next((cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), None) + + if cycle: + row[0] = f"{cycle['issue_cycle__cycle__name']}" + + if x_axis == MODULE_ID: + module = next( + (mod for mod in module_details if str(mod[MODULE_ID]) == str(item)), + None, + ) + + if module: + row[0] = f"{module['issue_module__module__name']}" + + rows.append(tuple(row)) + + row_zero = [row_mapping.get(x_axis, "X-Axis"), row_mapping.get(y_axis, "Y-Axis")] + return [tuple(row_zero)] + rows + + +@shared_task +def analytic_export_task(email, data, slug): + try: + filters = issue_filters(data, "POST") + queryset = Issue.issue_objects.filter(**filters, workspace__slug=slug) + + x_axis = data.get("x_axis", False) + y_axis = data.get("y_axis", False) + segment = data.get("segment", False) + + distribution = build_graph_plot(queryset, x_axis=x_axis, y_axis=y_axis, segment=segment) + key = "count" if y_axis == "issue_count" else "estimate" + + assignee_details = ( + get_assignee_details(slug, filters) if x_axis == ASSIGNEE_ID or segment == ASSIGNEE_ID else {} + ) + + label_details = get_label_details(slug, filters) if x_axis == LABEL_ID or segment == LABEL_ID else {} + + state_details = get_state_details(slug, filters) if x_axis == STATE_ID or segment == STATE_ID else {} + + cycle_details = get_cycle_details(slug, filters) if x_axis == CYCLE_ID or segment == CYCLE_ID else {} + + module_details = get_module_details(slug, filters) if x_axis == MODULE_ID or segment == MODULE_ID else {} + + if segment: + rows = generate_segmented_rows( + distribution, + x_axis, + y_axis, + segment, + key, + assignee_details, + label_details, + state_details, + cycle_details, + module_details, + ) + else: + rows = generate_non_segmented_rows( + distribution, + x_axis, + y_axis, + key, + assignee_details, + label_details, + state_details, + cycle_details, + module_details, + ) + + csv_buffer = generate_csv_from_rows(rows) + send_export_email(email, slug, csv_buffer, rows) + logging.getLogger("plane.worker").info("Email sent successfully.") + return + except Exception as e: + log_exception(e) + return + + +@shared_task +def export_analytics_to_csv_email(data, headers, keys, email, slug): + try: + """ + Prepares a CSV from data and sends it as an email attachment. + + Parameters: + - data: List of dictionaries (e.g. from .values()) + - headers: List of CSV column headers + - keys: Keys to extract from each data item (dict) + - email: Email address to send to + - slug: Used for the filename + """ + # Prepare rows: header + data rows + rows = [headers] + for item in data: + row = [item.get(key, "") for key in keys] + rows.append(row) + + # Generate CSV buffer + csv_buffer = generate_csv_from_rows(rows) + + # Send email with CSV attachment + send_export_email(email=email, slug=slug, csv_buffer=csv_buffer, rows=rows) + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/apps.py b/apps/api/plane/bgtasks/apps.py new file mode 100644 index 00000000..7f6ca38f --- /dev/null +++ b/apps/api/plane/bgtasks/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BgtasksConfig(AppConfig): + name = "plane.bgtasks" diff --git a/apps/api/plane/bgtasks/cleanup_task.py b/apps/api/plane/bgtasks/cleanup_task.py new file mode 100644 index 00000000..6b23f257 --- /dev/null +++ b/apps/api/plane/bgtasks/cleanup_task.py @@ -0,0 +1,475 @@ +# Python imports +from datetime import timedelta +import logging +from typing import List, Dict, Any, Callable, Optional +import os + +# Django imports +from django.utils import timezone +from django.db.models import F, Window, Subquery +from django.db.models.functions import RowNumber + +# Third party imports +from celery import shared_task +from pymongo.errors import BulkWriteError +from pymongo.collection import Collection +from pymongo.operations import InsertOne + +# Module imports +from plane.db.models import ( + EmailNotificationLog, + PageVersion, + APIActivityLog, + IssueDescriptionVersion, + WebhookLog, +) +from plane.settings.mongo import MongoConnection +from plane.utils.exception_logger import log_exception + + +logger = logging.getLogger("plane.worker") +BATCH_SIZE = 500 + + +def get_mongo_collection(collection_name: str) -> Optional[Collection]: + """Get MongoDB collection if available, otherwise return None.""" + if not MongoConnection.is_configured(): + logger.info("MongoDB not configured") + return None + + try: + mongo_collection = MongoConnection.get_collection(collection_name) + logger.info(f"MongoDB collection '{collection_name}' connected successfully") + return mongo_collection + except Exception as e: + logger.error(f"Failed to get MongoDB collection: {str(e)}") + log_exception(e) + return None + + +def flush_to_mongo_and_delete( + mongo_collection: Optional[Collection], + buffer: List[Dict[str, Any]], + ids_to_delete: List[int], + model, + mongo_available: bool, +) -> None: + """ + Inserts a batch of records into MongoDB and deletes the corresponding rows from PostgreSQL. + """ + if not buffer: + logger.debug("No records to flush - buffer is empty") + return + + logger.info(f"Starting batch flush: {len(buffer)} records, {len(ids_to_delete)} IDs to delete") + + mongo_archival_failed = False + + # Try to insert into MongoDB if available + if mongo_collection is not None and mongo_available: + try: + mongo_collection.bulk_write([InsertOne(doc) for doc in buffer]) + except BulkWriteError as bwe: + logger.error(f"MongoDB bulk write error: {str(bwe)}") + log_exception(bwe) + mongo_archival_failed = True + + # If MongoDB is available and archival failed, log the error and return + if mongo_available and mongo_archival_failed: + logger.error(f"MongoDB archival failed for {len(buffer)} records") + return + + # Delete from PostgreSQL - delete() returns (count, {model: count}) + delete_result = model.all_objects.filter(id__in=ids_to_delete).delete() + deleted_count = delete_result[0] if delete_result and isinstance(delete_result, tuple) else 0 + logger.info(f"Batch flush completed: {deleted_count} records deleted") + + +def process_cleanup_task( + queryset_func: Callable, + transform_func: Callable[[Dict], Dict], + model, + task_name: str, + collection_name: str, +): + """ + Generic function to process cleanup tasks. + + Args: + queryset_func: Function that returns the queryset to process + transform_func: Function to transform each record for MongoDB + model: Django model class + task_name: Name of the task for logging + collection_name: MongoDB collection name + """ + logger.info(f"Starting {task_name} cleanup task") + + # Get MongoDB collection + mongo_collection = get_mongo_collection(collection_name) + mongo_available = mongo_collection is not None + + # Get queryset + queryset = queryset_func() + + # Process records in batches + buffer: List[Dict[str, Any]] = [] + ids_to_delete: List[int] = [] + total_processed = 0 + total_batches = 0 + + for record in queryset: + # Transform record for MongoDB + buffer.append(transform_func(record)) + ids_to_delete.append(record["id"]) + + # Flush batch when it reaches BATCH_SIZE + if len(buffer) >= BATCH_SIZE: + total_batches += 1 + flush_to_mongo_and_delete( + mongo_collection=mongo_collection, + buffer=buffer, + ids_to_delete=ids_to_delete, + model=model, + mongo_available=mongo_available, + ) + total_processed += len(buffer) + buffer.clear() + ids_to_delete.clear() + + # Process final batch if any records remain + if buffer: + total_batches += 1 + flush_to_mongo_and_delete( + mongo_collection=mongo_collection, + buffer=buffer, + ids_to_delete=ids_to_delete, + model=model, + mongo_available=mongo_available, + ) + total_processed += len(buffer) + + logger.info( + f"{task_name} cleanup task completed", + extra={ + "total_records_processed": total_processed, + "total_batches": total_batches, + "mongo_available": mongo_available, + "collection_name": collection_name, + }, + ) + + +# Transform functions for each model +def transform_api_log(record: Dict) -> Dict: + """Transform API activity log record.""" + return { + "id": str(record["id"]), + "created_at": str(record["created_at"]) if record.get("created_at") else None, + "token_identifier": str(record["token_identifier"]), + "path": record["path"], + "method": record["method"], + "query_params": record.get("query_params"), + "headers": record.get("headers"), + "body": record.get("body"), + "response_code": record["response_code"], + "response_body": record["response_body"], + "ip_address": record["ip_address"], + "user_agent": record["user_agent"], + "created_by_id": str(record["created_by_id"]), + } + + +def transform_email_log(record: Dict) -> Dict: + """Transform email notification log record.""" + return { + "id": str(record["id"]), + "created_at": str(record["created_at"]) if record.get("created_at") else None, + "receiver_id": str(record["receiver_id"]), + "triggered_by_id": str(record["triggered_by_id"]), + "entity_identifier": str(record["entity_identifier"]), + "entity_name": record["entity_name"], + "data": record["data"], + "processed_at": (str(record["processed_at"]) if record.get("processed_at") else None), + "sent_at": str(record["sent_at"]) if record.get("sent_at") else None, + "entity": record["entity"], + "old_value": str(record["old_value"]), + "new_value": str(record["new_value"]), + "created_by_id": str(record["created_by_id"]), + } + + +def transform_page_version(record: Dict) -> Dict: + """Transform page version record.""" + return { + "id": str(record["id"]), + "created_at": str(record["created_at"]) if record.get("created_at") else None, + "page_id": str(record["page_id"]), + "workspace_id": str(record["workspace_id"]), + "owned_by_id": str(record["owned_by_id"]), + "description_html": record["description_html"], + "description_binary": record["description_binary"], + "description_stripped": record["description_stripped"], + "description_json": record["description_json"], + "sub_pages_data": record["sub_pages_data"], + "created_by_id": str(record["created_by_id"]), + "updated_by_id": str(record["updated_by_id"]), + "deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None, + "last_saved_at": (str(record["last_saved_at"]) if record.get("last_saved_at") else None), + } + + +def transform_issue_description_version(record: Dict) -> Dict: + """Transform issue description version record.""" + return { + "id": str(record["id"]), + "created_at": str(record["created_at"]) if record.get("created_at") else None, + "issue_id": str(record["issue_id"]), + "workspace_id": str(record["workspace_id"]), + "project_id": str(record["project_id"]), + "created_by_id": str(record["created_by_id"]), + "updated_by_id": str(record["updated_by_id"]), + "owned_by_id": str(record["owned_by_id"]), + "last_saved_at": (str(record["last_saved_at"]) if record.get("last_saved_at") else None), + "description_binary": record["description_binary"], + "description_html": record["description_html"], + "description_stripped": record["description_stripped"], + "description_json": record["description_json"], + "deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None, + } + + +def transform_webhook_log(record: Dict): + """Transfer webhook logs to a new destination.""" + return { + "id": str(record["id"]), + "created_at": str(record["created_at"]) if record.get("created_at") else None, + "workspace_id": str(record["workspace_id"]), + "webhook": str(record["webhook"]), + # Request + "event_type": str(record["event_type"]), + "request_method": str(record["request_method"]), + "request_headers": str(record["request_headers"]), + "request_body": str(record["request_body"]), + # Response + "response_status": str(record["response_status"]), + "response_body": str(record["response_body"]), + "response_headers": str(record["response_headers"]), + # retry count + "retry_count": str(record["retry_count"]), + } + + +# Queryset functions for each cleanup task +def get_api_logs_queryset(): + """Get API logs older than cutoff days.""" + cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30)) + cutoff_time = timezone.now() - timedelta(days=cutoff_days) + logger.info(f"API logs cutoff time: {cutoff_time}") + + return ( + APIActivityLog.all_objects.filter(created_at__lte=cutoff_time) + .values( + "id", + "created_at", + "token_identifier", + "path", + "method", + "query_params", + "headers", + "body", + "response_code", + "response_body", + "ip_address", + "user_agent", + "created_by_id", + ) + .iterator(chunk_size=BATCH_SIZE) + ) + + +def get_email_logs_queryset(): + """Get email logs older than cutoff days.""" + cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30)) + cutoff_time = timezone.now() - timedelta(days=cutoff_days) + logger.info(f"Email logs cutoff time: {cutoff_time}") + + return ( + EmailNotificationLog.all_objects.filter(sent_at__lte=cutoff_time) + .values( + "id", + "created_at", + "receiver_id", + "triggered_by_id", + "entity_identifier", + "entity_name", + "data", + "processed_at", + "sent_at", + "entity", + "old_value", + "new_value", + "created_by_id", + ) + .iterator(chunk_size=BATCH_SIZE) + ) + + +def get_page_versions_queryset(): + """Get page versions beyond the maximum allowed (20 per page).""" + subq = ( + PageVersion.all_objects.annotate( + row_num=Window( + expression=RowNumber(), + partition_by=[F("page_id")], + order_by=F("created_at").desc(), + ) + ) + .filter(row_num__gt=20) + .values("id") + ) + + return ( + PageVersion.all_objects.filter(id__in=Subquery(subq)) + .values( + "id", + "created_at", + "page_id", + "workspace_id", + "owned_by_id", + "description_html", + "description_binary", + "description_stripped", + "description_json", + "sub_pages_data", + "created_by_id", + "updated_by_id", + "deleted_at", + "last_saved_at", + ) + .iterator(chunk_size=BATCH_SIZE) + ) + + +def get_issue_description_versions_queryset(): + """Get issue description versions beyond the maximum allowed (20 per issue).""" + subq = ( + IssueDescriptionVersion.all_objects.annotate( + row_num=Window( + expression=RowNumber(), + partition_by=[F("issue_id")], + order_by=F("created_at").desc(), + ) + ) + .filter(row_num__gt=20) + .values("id") + ) + + return ( + IssueDescriptionVersion.all_objects.filter(id__in=Subquery(subq)) + .values( + "id", + "created_at", + "issue_id", + "workspace_id", + "project_id", + "created_by_id", + "updated_by_id", + "owned_by_id", + "last_saved_at", + "description_binary", + "description_html", + "description_stripped", + "description_json", + "deleted_at", + ) + .iterator(chunk_size=BATCH_SIZE) + ) + + +def get_webhook_logs_queryset(): + """Get email logs older than cutoff days.""" + cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30)) + cutoff_time = timezone.now() - timedelta(days=cutoff_days) + logger.info(f"Webhook logs cutoff time: {cutoff_time}") + + return ( + WebhookLog.all_objects.filter(created_at__lte=cutoff_time) + .values( + "id", + "created_at", + "workspace_id", + "webhook", + "event_type", + # Request + "request_method", + "request_headers", + "request_body", + # Response + "response_status", + "response_body", + "response_headers", + "retry_count", + ) + .order_by("created_at") + .iterator(chunk_size=100) + ) + + +@shared_task +def delete_api_logs(): + """Delete old API activity logs.""" + process_cleanup_task( + queryset_func=get_api_logs_queryset, + transform_func=transform_api_log, + model=APIActivityLog, + task_name="API Activity Log", + collection_name="api_activity_logs", + ) + + +@shared_task +def delete_email_notification_logs(): + """Delete old email notification logs.""" + process_cleanup_task( + queryset_func=get_email_logs_queryset, + transform_func=transform_email_log, + model=EmailNotificationLog, + task_name="Email Notification Log", + collection_name="email_notification_logs", + ) + + +@shared_task +def delete_page_versions(): + """Delete excess page versions.""" + process_cleanup_task( + queryset_func=get_page_versions_queryset, + transform_func=transform_page_version, + model=PageVersion, + task_name="Page Version", + collection_name="page_versions", + ) + + +@shared_task +def delete_issue_description_versions(): + """Delete excess issue description versions.""" + process_cleanup_task( + queryset_func=get_issue_description_versions_queryset, + transform_func=transform_issue_description_version, + model=IssueDescriptionVersion, + task_name="Issue Description Version", + collection_name="issue_description_versions", + ) + + +@shared_task +def delete_webhook_logs(): + """Delete old webhook logs""" + process_cleanup_task( + queryset_func=get_webhook_logs_queryset, + transform_func=transform_webhook_log, + model=WebhookLog, + task_name="Webhook Log", + collection_name="webhook_logs", + ) diff --git a/apps/api/plane/bgtasks/copy_s3_object.py b/apps/api/plane/bgtasks/copy_s3_object.py new file mode 100644 index 00000000..e7ef09e3 --- /dev/null +++ b/apps/api/plane/bgtasks/copy_s3_object.py @@ -0,0 +1,151 @@ +# Python imports +import uuid +import base64 +import requests +from bs4 import BeautifulSoup + +# Django imports +from django.conf import settings + +# Module imports +from plane.db.models import FileAsset, Page, Issue +from plane.utils.exception_logger import log_exception +from plane.settings.storage import S3Storage +from celery import shared_task +from plane.utils.url import normalize_url_path + + +def get_entity_id_field(entity_type, entity_id): + entity_mapping = { + FileAsset.EntityTypeContext.WORKSPACE_LOGO: {"workspace_id": entity_id}, + FileAsset.EntityTypeContext.PROJECT_COVER: {"project_id": entity_id}, + FileAsset.EntityTypeContext.USER_AVATAR: {"user_id": entity_id}, + FileAsset.EntityTypeContext.USER_COVER: {"user_id": entity_id}, + FileAsset.EntityTypeContext.ISSUE_ATTACHMENT: {"issue_id": entity_id}, + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: {"issue_id": entity_id}, + FileAsset.EntityTypeContext.PAGE_DESCRIPTION: {"page_id": entity_id}, + FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: {"comment_id": entity_id}, + FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: {"draft_issue_id": entity_id}, + } + return entity_mapping.get(entity_type, {}) + + +def extract_asset_ids(html, tag): + try: + soup = BeautifulSoup(html, "html.parser") + return [tag.get("src") for tag in soup.find_all(tag) if tag.get("src")] + except Exception as e: + log_exception(e) + return [] + + +def replace_asset_ids(html, tag, duplicated_assets): + try: + soup = BeautifulSoup(html, "html.parser") + for mention_tag in soup.find_all(tag): + for asset in duplicated_assets: + if mention_tag.get("src") == asset["old_asset_id"]: + mention_tag["src"] = asset["new_asset_id"] + return str(soup) + except Exception as e: + log_exception(e) + return html + + +def update_description(entity, duplicated_assets, tag): + updated_html = replace_asset_ids(entity.description_html, tag, duplicated_assets) + entity.description_html = updated_html + entity.save() + return updated_html + + +# Get the description binary and description from the live server +def sync_with_external_service(entity_name, description_html): + try: + data = { + "description_html": description_html, + "variant": "rich" if entity_name == "PAGE" else "document", + } + + live_url = settings.LIVE_URL + if not live_url: + return {} + + url = normalize_url_path(f"{live_url}/convert-document/") + + response = requests.post(url, json=data, headers=None) + if response.status_code == 200: + return response.json() + except requests.RequestException as e: + log_exception(e) + return {} + + +def copy_assets(entity, entity_identifier, project_id, asset_ids, user_id): + duplicated_assets = [] + workspace = entity.workspace + storage = S3Storage() + original_assets = FileAsset.objects.filter(workspace=workspace, project_id=project_id, id__in=asset_ids) + + for original_asset in original_assets: + destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" + duplicated_asset = FileAsset.objects.create( + attributes={ + "name": original_asset.attributes.get("name"), + "type": original_asset.attributes.get("type"), + "size": original_asset.attributes.get("size"), + }, + asset=destination_key, + size=original_asset.size, + workspace=workspace, + created_by_id=user_id, + entity_type=original_asset.entity_type, + project_id=project_id, + storage_metadata=original_asset.storage_metadata, + **get_entity_id_field(original_asset.entity_type, entity_identifier), + ) + storage.copy_object(original_asset.asset, destination_key) + duplicated_assets.append( + { + "new_asset_id": str(duplicated_asset.id), + "old_asset_id": str(original_asset.id), + } + ) + if duplicated_assets: + FileAsset.objects.filter(pk__in=[item["new_asset_id"] for item in duplicated_assets]).update(is_uploaded=True) + + return duplicated_assets + + +@shared_task +def copy_s3_objects_of_description_and_assets(entity_name, entity_identifier, project_id, slug, user_id): + """ + Step 1: Extract asset ids from the description_html of the entity + Step 2: Duplicate the assets + Step 3: Update the description_html of the entity with the new asset ids (change the src of img tag) + Step 4: Request the live server to generate the description_binary and description for the entity + + """ + try: + model_class = {"PAGE": Page, "ISSUE": Issue}.get(entity_name) + if not model_class: + raise ValueError(f"Unsupported entity_name: {entity_name}") + + entity = model_class.objects.get(id=entity_identifier) + asset_ids = extract_asset_ids(entity.description_html, "image-component") + + duplicated_assets = copy_assets(entity, entity_identifier, project_id, asset_ids, user_id) + + updated_html = update_description(entity, duplicated_assets, "image-component") + + external_data = sync_with_external_service(entity_name, updated_html) + + if external_data: + entity.description = external_data.get("description") + entity.description_binary = base64.b64decode(external_data.get("description_binary")) + entity.save() + + return + except Exception as e: + log_exception(e) + return [] diff --git a/apps/api/plane/bgtasks/deletion_task.py b/apps/api/plane/bgtasks/deletion_task.py new file mode 100644 index 00000000..932a1fce --- /dev/null +++ b/apps/api/plane/bgtasks/deletion_task.py @@ -0,0 +1,189 @@ +# Django imports +from django.utils import timezone +from django.apps import apps +from django.conf import settings +from django.db import models +from django.db.models.fields.related import OneToOneRel + + +# Third party imports +from celery import shared_task + + +@shared_task +def soft_delete_related_objects(app_label, model_name, instance_pk, using=None): + """ + Soft delete related objects for a given model instance + """ + # Get the model class using app registry + model_class = apps.get_model(app_label, model_name) + + # Get the instance using all_objects to ensure we can get even if it's already soft deleted + try: + instance = model_class.all_objects.get(pk=instance_pk) + except model_class.DoesNotExist: + return + + # Get all related fields that are reverse relationships + all_related = [ + f for f in instance._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete + ] + + # Handle each related field + for relation in all_related: + related_name = relation.get_accessor_name() + + # Skip if the relation doesn't exist + if not hasattr(instance, related_name): + continue + + # Get the on_delete behavior name + on_delete_name = relation.on_delete.__name__ if hasattr(relation.on_delete, "__name__") else "" + + if on_delete_name == "DO_NOTHING": + continue + + elif on_delete_name == "SET_NULL": + # Handle SET_NULL relationships + if isinstance(relation, OneToOneRel): + # For OneToOne relationships + related_obj = getattr(instance, related_name, None) + if related_obj and isinstance(related_obj, models.Model): + setattr(related_obj, relation.remote_field.name, None) + related_obj.save(update_fields=[relation.remote_field.name]) + else: + # For other relationships + related_queryset = getattr(instance, related_name).all() + related_queryset.update(**{relation.remote_field.name: None}) + + else: + # Handle CASCADE and other delete behaviors + try: + if relation.one_to_one: + # Handle OneToOne relationships + related_obj = getattr(instance, related_name, None) + if related_obj: + if hasattr(related_obj, "deleted_at"): + if not related_obj.deleted_at: + related_obj.deleted_at = timezone.now() + related_obj.save() + # Recursively handle related objects + soft_delete_related_objects( + related_obj._meta.app_label, + related_obj._meta.model_name, + related_obj.pk, + using, + ) + else: + # Handle other relationships + related_queryset = getattr(instance, related_name)(manager="objects").all() + + for related_obj in related_queryset: + if hasattr(related_obj, "deleted_at"): + if not related_obj.deleted_at: + related_obj.deleted_at = timezone.now() + related_obj.save() + # Recursively handle related objects + soft_delete_related_objects( + related_obj._meta.app_label, + related_obj._meta.model_name, + related_obj.pk, + using, + ) + except Exception as e: + # Log the error or handle as needed + print(f"Error handling relation {related_name}: {str(e)}") + continue + + # Finally, soft delete the instance itself if it hasn't been deleted yet + if hasattr(instance, "deleted_at") and not instance.deleted_at: + instance.deleted_at = timezone.now() + instance.save() + + +# @shared_task +def restore_related_objects(app_label, model_name, instance_pk, using=None): + pass + + +@shared_task +def hard_delete(): + from plane.db.models import ( + Workspace, + Project, + Cycle, + Module, + Issue, + Page, + IssueView, + Label, + State, + IssueActivity, + IssueComment, + IssueLink, + IssueReaction, + UserFavorite, + ModuleIssue, + CycleIssue, + Estimate, + EstimatePoint, + ) + + days = settings.HARD_DELETE_AFTER_DAYS + # check delete workspace + _ = Workspace.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + # check delete project + _ = Project.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + # check delete cycle + _ = Cycle.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + # check delete module + _ = Module.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + # check delete issue + _ = Issue.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + # check delete page + _ = Page.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + # check delete view + _ = IssueView.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + # check delete label + _ = Label.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + # check delete state + _ = State.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + _ = IssueActivity.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + _ = IssueComment.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + _ = IssueLink.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + _ = IssueReaction.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + _ = UserFavorite.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + _ = ModuleIssue.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + _ = CycleIssue.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + _ = Estimate.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + _ = EstimatePoint.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + # at last, check for every thing which ever is left and delete it + # Get all Django models + all_models = apps.get_models() + + # Iterate through all models + for model in all_models: + # Check if the model has a 'deleted_at' field + if hasattr(model, "deleted_at"): + # Get all instances where 'deleted_at' is greater than 30 days ago + _ = model.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() + + return diff --git a/apps/api/plane/bgtasks/dummy_data_task.py b/apps/api/plane/bgtasks/dummy_data_task.py new file mode 100644 index 00000000..3220ef0c --- /dev/null +++ b/apps/api/plane/bgtasks/dummy_data_task.py @@ -0,0 +1,546 @@ +# Python imports +import uuid +import random +from datetime import datetime, timedelta + +# Django imports +from django.db.models import Max + +# Third party imports +from celery import shared_task +from faker import Faker + +# Module imports +from plane.db.models import ( + Workspace, + User, + Project, + ProjectMember, + State, + Label, + Cycle, + Module, + Issue, + IssueSequence, + IssueAssignee, + IssueLabel, + IssueActivity, + CycleIssue, + ModuleIssue, + Page, + ProjectPage, + PageLabel, + Intake, + IntakeIssue, +) +from plane.db.models.intake import SourceType + + +def create_project(workspace, user_id): + fake = Faker() + name = fake.name() + unique_id = str(uuid.uuid4())[:5] + + project = Project.objects.create( + workspace=workspace, + name=f"{name}_{unique_id}", + identifier=name[: random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1)].upper(), + created_by_id=user_id, + intake_view=True, + ) + + # Add current member as project member + _ = ProjectMember.objects.create(project=project, member_id=user_id, role=20) + + return project + + +def create_project_members(workspace, project, members): + members = User.objects.filter(email__in=members) + + _ = ProjectMember.objects.bulk_create( + [ + ProjectMember( + project=project, + workspace=workspace, + member=member, + role=20, + sort_order=random.randint(0, 65535), + ) + for member in members + ], + ignore_conflicts=True, + ) + return + + +def create_states(workspace, project, user_id): + states = [ + { + "name": "Backlog", + "color": "#A3A3A3", + "sequence": 15000, + "group": "backlog", + "default": True, + }, + {"name": "Todo", "color": "#3A3A3A", "sequence": 25000, "group": "unstarted"}, + { + "name": "In Progress", + "color": "#F59E0B", + "sequence": 35000, + "group": "started", + }, + {"name": "Done", "color": "#16A34A", "sequence": 45000, "group": "completed"}, + { + "name": "Cancelled", + "color": "#EF4444", + "sequence": 55000, + "group": "cancelled", + }, + ] + + states = State.objects.bulk_create( + [ + State( + name=state["name"], + color=state["color"], + project=project, + sequence=state["sequence"], + workspace=workspace, + group=state["group"], + default=state.get("default", False), + created_by_id=user_id, + ) + for state in states + ] + ) + + return states + + +def create_labels(workspace, project, user_id): + fake = Faker() + Faker.seed(0) + + return Label.objects.bulk_create( + [ + Label( + name=fake.color_name(), + color=fake.hex_color(), + project=project, + workspace=workspace, + created_by_id=user_id, + sort_order=random.randint(0, 65535), + ) + for _ in range(0, 50) + ], + ignore_conflicts=True, + ) + + +def create_cycles(workspace, project, user_id, cycle_count): + fake = Faker() + Faker.seed(0) + + cycles = [] + used_date_ranges = set() # Track used date ranges + + while len(cycles) <= cycle_count: + # Generate a start date, allowing for None + start_date_option = [None, fake.date_this_year()] + start_date = start_date_option[random.randint(0, 1)] + + # Initialize end_date based on start_date + end_date = ( + None + if start_date is None + else fake.date_between_dates( + date_start=start_date, + date_end=datetime.now().date().replace(month=12, day=31), + ) + ) + + # 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 = fake.date_this_year() + + # Add the unique date range to the set + (used_date_ranges.add((start_date, end_date)) if (end_date is not None and start_date is not None) else None) + + # Append the cycle with unique date range + cycles.append( + Cycle( + name=fake.name(), + owned_by_id=user_id, + sort_order=random.randint(0, 65535), + start_date=start_date, + end_date=end_date, + project=project, + workspace=workspace, + ) + ) + + return Cycle.objects.bulk_create(cycles, ignore_conflicts=True) + + +def create_modules(workspace, project, user_id, module_count): + fake = Faker() + Faker.seed(0) + + modules = [] + for _ in range(0, module_count): + start_date = [None, fake.date_this_year()][random.randint(0, 1)] + end_date = ( + None + if start_date is None + else fake.date_between_dates( + date_start=start_date, + date_end=datetime.now().date().replace(month=12, day=31), + ) + ) + + modules.append( + Module( + name=fake.name(), + sort_order=random.randint(0, 65535), + start_date=start_date, + target_date=end_date, + project=project, + workspace=workspace, + ) + ) + + return Module.objects.bulk_create(modules, ignore_conflicts=True) + + +def create_pages(workspace, project, user_id, pages_count): + fake = Faker() + Faker.seed(0) + + pages = [] + for _ in range(0, pages_count): + text = fake.text(max_nb_chars=60000) + pages.append( + Page( + name=fake.name(), + workspace=workspace, + owned_by_id=user_id, + access=random.randint(0, 1), + color=fake.hex_color(), + description_html=f"

    {text}

    ", + archived_at=None, + is_locked=False, + ) + ) + # Bulk create pages + pages = Page.objects.bulk_create(pages, ignore_conflicts=True) + # Add Page to project + ProjectPage.objects.bulk_create( + [ProjectPage(page=page, project=project, workspace=workspace) for page in pages], + batch_size=1000, + ) + + +def create_page_labels(workspace, project, user_id, pages_count): + # 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)), + 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)): + bulk_page_labels.append(PageLabel(page_id=page, label_id=label, workspace=workspace)) + + # Page labels + PageLabel.objects.bulk_create(bulk_page_labels, batch_size=1000, ignore_conflicts=True) + + +def create_issues(workspace, project, user_id, issue_count): + fake = Faker() + Faker.seed(0) + + states = ( + State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True) + ) + creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True) + + issues = [] + + # Get the maximum sequence_id + last_id = IssueSequence.objects.filter(project=project).aggregate(largest=Max("sequence"))["largest"] + + last_id = 1 if last_id is None else last_id + 1 + + # Get the maximum sort order + largest_sort_order = Issue.objects.filter( + project=project, state_id=states[random.randint(0, len(states) - 1)] + ).aggregate(largest=Max("sort_order"))["largest"] + + largest_sort_order = 65535 if largest_sort_order is None else largest_sort_order + 10000 + + for _ in range(0, issue_count): + start_date = [None, fake.date_this_year()][random.randint(0, 1)] + end_date = ( + None + if start_date is None + else fake.date_between_dates( + date_start=start_date, + date_end=datetime.now().date().replace(month=12, day=31), + ) + ) + + text = fake.text(max_nb_chars=3000) + issues.append( + Issue( + state_id=states[random.randint(0, len(states) - 1)], + project=project, + workspace=workspace, + name=text[:254], + description_html=f"

    {text}

    ", + description_stripped=text, + sequence_id=last_id, + sort_order=largest_sort_order, + start_date=start_date, + target_date=end_date, + priority=["urgent", "high", "medium", "low", "none"][random.randint(0, 4)], + created_by_id=creators[random.randint(0, len(creators) - 1)], + ) + ) + + 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) + # Sequences + _ = IssueSequence.objects.bulk_create( + [ + IssueSequence( + issue=issue, + sequence=issue.sequence_id, + project=project, + workspace=workspace, + ) + for issue in issues + ], + batch_size=100, + ) + + # Track the issue activities + IssueActivity.objects.bulk_create( + [ + IssueActivity( + issue=issue, + actor_id=user_id, + project=project, + workspace=workspace, + comment="created the issue", + verb="created", + created_by_id=user_id, + ) + for issue in issues + ], + batch_size=100, + ) + return issues + + +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) + IntakeIssue.objects.bulk_create( + [ + IntakeIssue( + issue=issue, + intake=intake, + status=(status := [-2, -1, 0, 1, 2][random.randint(0, 4)]), + snoozed_till=(datetime.now() + timedelta(days=random.randint(1, 30)) if status == 0 else None), + source=SourceType.IN_APP, + workspace=workspace, + project=project, + ) + for issue in issues + ], + batch_size=100, + ) + + +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)] + + bulk_sub_issues = [] + for sub_issue in sub_issues: + sub_issue.parent_id = parent_issues[random.randint(0, int(parent_count - 1))] + + Issue.objects.bulk_update(bulk_sub_issues, ["parent"], batch_size=1000) + + +def create_issue_assignees(workspace, project, user_id, issue_count): + # assignees + assignees = ProjectMember.objects.filter(project=project).values_list("member_id", flat=True) + issues = random.sample( + list(Issue.objects.filter(project=project).values_list("id", flat=True)), + int(issue_count / 2), + ) + + # Bulk issue + bulk_issue_assignees = [] + for issue in issues: + for assignee in random.sample(list(assignees), random.randint(0, len(assignees) - 1)): + bulk_issue_assignees.append( + IssueAssignee( + issue_id=issue, + assignee_id=assignee, + project=project, + workspace=workspace, + ) + ) + + # Issue assignees + IssueAssignee.objects.bulk_create(bulk_issue_assignees, batch_size=1000, ignore_conflicts=True) + + +def create_issue_labels(workspace, project, user_id, issue_count): + # labels + labels = Label.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)) + 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)): + bulk_issue_labels.append(IssueLabel(issue_id=issue, label_id=label, project=project, workspace=workspace)) + + # Issue labels + IssueLabel.objects.bulk_create(bulk_issue_labels, batch_size=1000, ignore_conflicts=True) + + +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)), + int(issue_count / 2), + ) + + # Bulk issue + bulk_cycle_issues = [] + for issue in issues: + cycle = cycles[random.randint(0, len(cycles) - 1)] + bulk_cycle_issues.append(CycleIssue(cycle_id=cycle, issue_id=issue, project=project, workspace=workspace)) + + # Issue assignees + CycleIssue.objects.bulk_create(bulk_cycle_issues, batch_size=1000, ignore_conflicts=True) + + +def create_module_issues(workspace, project, user_id, issue_count): + # assignees + 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)) + + shuffled_modules = list(modules) + + # Bulk issue + bulk_module_issues = [] + for issue in issues: + random.shuffle(shuffled_modules) + for module in random.sample(shuffled_modules, random.randint(0, 5)): + bulk_module_issues.append( + ModuleIssue( + module_id=module, + issue_id=issue, + project=project, + workspace=workspace, + ) + ) + # Issue assignees + ModuleIssue.objects.bulk_create(bulk_module_issues, batch_size=1000, ignore_conflicts=True) + + +@shared_task +def create_dummy_data( + slug, + email, + members, + issue_count, + cycle_count, + module_count, + pages_count, + intake_issue_count, +): + workspace = Workspace.objects.get(slug=slug) + + user = User.objects.get(email=email) + user_id = user.id + + # Create a project + project = create_project(workspace=workspace, user_id=user_id) + + # create project members + create_project_members(workspace=workspace, project=project, members=members) + + # Create states + create_states(workspace=workspace, project=project, user_id=user_id) + + # Create labels + create_labels(workspace=workspace, project=project, user_id=user_id) + + # create cycles + create_cycles(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) + + # create pages + create_pages(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) + + # create issues + create_issues(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count) + + # create intake issues + create_intake_issues( + workspace=workspace, + project=project, + user_id=user_id, + intake_issue_count=intake_issue_count, + ) + + # create issue parent + create_issue_parent(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) + + # create issue labels + create_issue_labels(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) + + # create module issues + create_module_issues(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count) + + return diff --git a/apps/api/plane/bgtasks/email_notification_task.py b/apps/api/plane/bgtasks/email_notification_task.py new file mode 100644 index 00000000..1402adc4 --- /dev/null +++ b/apps/api/plane/bgtasks/email_notification_task.py @@ -0,0 +1,302 @@ +import logging +import re +from datetime import datetime + +from bs4 import BeautifulSoup + +# Third party imports +from celery import shared_task +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string + +# Django imports +from django.utils import timezone +from django.utils.html import strip_tags + +# Module imports +from plane.db.models import EmailNotificationLog, Issue, User +from plane.license.utils.instance_value import get_email_configuration +from plane.settings.redis import redis_instance +from plane.utils.exception_logger import log_exception + + +def remove_unwanted_characters(input_text): + # Remove only control characters and potentially problematic characters for email subjects + processed_text = re.sub(r"[\x00-\x1F\x7F-\x9F]", "", input_text) + return processed_text + + +# acquire and delete redis lock +def acquire_lock(lock_id, expire_time=300): + redis_client = redis_instance() + """Attempt to acquire a lock with a specified expiration time.""" + return redis_client.set(lock_id, "true", nx=True, ex=expire_time) + + +def release_lock(lock_id): + """Release a lock.""" + redis_client = redis_instance() + redis_client.delete(lock_id) + + +@shared_task +def stack_email_notification(): + # get all email notifications + email_notifications = EmailNotificationLog.objects.filter(processed_at__isnull=True).order_by("receiver").values() + + # Create the below format for each of the issues + # {"issue_id" : { "actor_id1": [ { data }, { data } ], "actor_id2": [ { data }, { data } ] }} + + # Convert to unique receivers list + receivers = list(set([str(notification.get("receiver_id")) for notification in email_notifications])) + processed_notifications = [] + # Loop through all the issues to create the emails + for receiver_id in receivers: + # Notification triggered for the receiver + receiver_notifications = [ + notification for notification in email_notifications if str(notification.get("receiver_id")) == receiver_id + ] + # create payload for all issues + payload = {} + email_notification_ids = [] + for receiver_notification in receiver_notifications: + payload.setdefault(receiver_notification.get("entity_identifier"), {}).setdefault( + str(receiver_notification.get("triggered_by_id")), [] + ).append(receiver_notification.get("data")) + # append processed notifications + processed_notifications.append(receiver_notification.get("id")) + email_notification_ids.append(receiver_notification.get("id")) + + # Create emails for all the issues + for issue_id, notification_data in payload.items(): + send_email_notification.delay( + issue_id=issue_id, + notification_data=notification_data, + receiver_id=receiver_id, + email_notification_ids=email_notification_ids, + ) + + # Update the email notification log + EmailNotificationLog.objects.filter(pk__in=processed_notifications).update(processed_at=timezone.now()) + + +def create_payload(notification_data): + # return format {"actor_id": { "key": { "old_value": [], "new_value": [] } }} + data = {} + for actor_id, changes in notification_data.items(): + for change in changes: + issue_activity = change.get("issue_activity") + if issue_activity: # Ensure issue_activity is not None + field = issue_activity.get("field") + old_value = str(issue_activity.get("old_value")) + new_value = str(issue_activity.get("new_value")) + + # Append old_value if it's not empty and not already in the list + if old_value: + ( + data.setdefault(actor_id, {}) + .setdefault(field, {}) + .setdefault("old_value", []) + .append(old_value) + if old_value not in data.setdefault(actor_id, {}).setdefault(field, {}).get("old_value", []) + else None + ) + + # Append new_value if it's not empty and not already in the list + if new_value: + ( + data.setdefault(actor_id, {}) + .setdefault(field, {}) + .setdefault("new_value", []) + .append(new_value) + if new_value not in data.setdefault(actor_id, {}).setdefault(field, {}).get("new_value", []) + else None + ) + + if not data.get("actor_id", {}).get("activity_time", False): + data[actor_id]["activity_time"] = str( + datetime.fromisoformat(issue_activity.get("activity_time").rstrip("Z")).strftime( + "%Y-%m-%d %H:%M:%S" + ) + ) + + return data + + +def process_mention(mention_component): + soup = BeautifulSoup(mention_component, "html.parser") + mentions = soup.find_all("mention-component") + for mention in mentions: + user_id = mention["entity_identifier"] + user = User.objects.get(pk=user_id) + user_name = user.display_name + highlighted_name = f"@{user_name}" + mention.replace_with(highlighted_name) + return str(soup) + + +def process_html_content(content): + if content is None: + return None + processed_content_list = [] + for html_content in content: + processed_content = process_mention(html_content) + processed_content_list.append(processed_content) + return processed_content_list + + +@shared_task +def send_email_notification(issue_id, notification_data, receiver_id, email_notification_ids): + # Convert UUIDs to a sorted, concatenated string + sorted_ids = sorted(email_notification_ids) + ids_str = "_".join(str(id) for id in sorted_ids) + lock_id = f"send_email_notif_{issue_id}_{receiver_id}_{ids_str}" + + # acquire the lock for sending emails + try: + 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 + + # Skip if base api is not present + if not base_api: + return + + data = create_payload(notification_data=notification_data) + + # Get email configurations + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + receiver = User.objects.get(pk=receiver_id) + issue = Issue.objects.get(pk=issue_id) + template_data = [] + total_changes = 0 + comments = [] + actors_involved = [] + for actor_id, changes in data.items(): + actor = User.objects.get(pk=actor_id) + total_changes = total_changes + len(changes) + comment = changes.pop("comment", False) + mention = changes.pop("mention", False) + actors_involved.append(actor_id) + if comment: + comments.append( + { + "actor_comments": comment, + "actor_detail": { + "avatar_url": f"{base_api}{actor.avatar_url}", + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + if mention: + mention["new_value"] = process_html_content(mention.get("new_value")) + mention["old_value"] = process_html_content(mention.get("old_value")) + comments.append( + { + "actor_comments": mention, + "actor_detail": { + "avatar_url": f"{base_api}{actor.avatar_url}", + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + activity_time = changes.pop("activity_time") + # Parse the input string into a datetime object + formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") + + if changes: + template_data.append( + { + "actor_detail": { + "avatar_url": f"{base_api}{actor.avatar_url}", + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + "changes": changes, + "issue_details": { + "name": issue.name, + "identifier": f"{issue.project.identifier}-{issue.sequence_id}", + }, + "activity_time": str(formatted_time), + } + ) + + summary = "Updates were made to the issue by" + + # Send the mail + subject = f"{issue.project.identifier}-{issue.sequence_id} {remove_unwanted_characters(issue.name)}" + context = { + "data": template_data, + "summary": summary, + "actors_involved": len(set(actors_involved)), + "issue": { + "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", + "name": issue.name, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", # noqa: E501 + }, + "receiver": {"email": receiver.email}, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", # noqa: E501 + "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", # noqa: E501 + "workspace": str(issue.project.workspace.slug), + "project": str(issue.project.name), + "user_preference": f"{base_api}/{str(issue.project.workspace.slug)}/settings/account/notifications/", + "comments": comments, + "entity_type": "issue", + } + html_content = render_to_string("emails/notifications/issue-updates.html", context) + text_content = strip_tags(html_content) + + try: + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver.email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + logging.getLogger("plane.worker").info("Email Sent Successfully") + + # Update the logs + EmailNotificationLog.objects.filter(pk__in=email_notification_ids).update(sent_at=timezone.now()) + + # release the lock + release_lock(lock_id=lock_id) + return + except Exception as e: + log_exception(e) + # release the lock + release_lock(lock_id=lock_id) + return + else: + logging.getLogger("plane.worker").info("Duplicate email received skipping") + return + except (Issue.DoesNotExist, User.DoesNotExist): + release_lock(lock_id=lock_id) + return + except Exception as e: + log_exception(e) + release_lock(lock_id=lock_id) + return diff --git a/apps/api/plane/bgtasks/event_tracking_task.py b/apps/api/plane/bgtasks/event_tracking_task.py new file mode 100644 index 00000000..0629db93 --- /dev/null +++ b/apps/api/plane/bgtasks/event_tracking_task.py @@ -0,0 +1,71 @@ +import os +import uuid + +# third party imports +from celery import shared_task +from posthog import Posthog + +# module imports +from plane.license.utils.instance_value import get_configuration_value +from plane.utils.exception_logger import log_exception + + +def posthogConfiguration(): + POSTHOG_API_KEY, POSTHOG_HOST = get_configuration_value( + [ + { + "key": "POSTHOG_API_KEY", + "default": os.environ.get("POSTHOG_API_KEY", None), + }, + {"key": "POSTHOG_HOST", "default": os.environ.get("POSTHOG_HOST", None)}, + ] + ) + if POSTHOG_API_KEY and POSTHOG_HOST: + return POSTHOG_API_KEY, POSTHOG_HOST + else: + return None, None + + +@shared_task +def auth_events(user, email, user_agent, ip, event_name, medium, first_time): + try: + POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() + + if POSTHOG_API_KEY and POSTHOG_HOST: + posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) + posthog.capture( + email, + event=event_name, + properties={ + "event_id": uuid.uuid4().hex, + "user": {"email": email, "id": str(user)}, + "device_ctx": {"ip": ip, "user_agent": user_agent}, + "medium": medium, + "first_time": first_time, + }, + ) + except Exception as e: + log_exception(e) + return + + +@shared_task +def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from): + try: + POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() + + if POSTHOG_API_KEY and POSTHOG_HOST: + posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) + posthog.capture( + email, + event=event_name, + properties={ + "event_id": uuid.uuid4().hex, + "user": {"email": email, "id": str(user)}, + "device_ctx": {"ip": ip, "user_agent": user_agent}, + "accepted_from": accepted_from, + }, + ) + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/export_task.py b/apps/api/plane/bgtasks/export_task.py new file mode 100644 index 00000000..d8aad5f6 --- /dev/null +++ b/apps/api/plane/bgtasks/export_task.py @@ -0,0 +1,220 @@ +# Python imports +import io +import zipfile +from typing import List +from collections import defaultdict +import boto3 +from botocore.client import Config +from uuid import UUID + +# Third party imports +from celery import shared_task + +# Django imports +from django.conf import settings +from django.utils import timezone +from django.db.models import Prefetch + +# Module imports +from plane.db.models import ExporterHistory, Issue, IssueRelation +from plane.utils.exception_logger import log_exception +from plane.utils.exporters import Exporter, IssueExportSchema + + +def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO: + """ + Create a ZIP file from the provided files. + """ + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: + for filename, file_content in files: + zipf.writestr(filename, file_content) + + zip_buffer.seek(0) + return zip_buffer + + +# TODO: Change the upload_to_s3 function to use the new storage method with entry in file asset table +def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: str) -> None: + """ + Upload a ZIP file to S3 and generate a presigned URL. + """ + 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: + upload_s3 = boto3.client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + upload_s3.upload_fileobj( + zip_file, + settings.AWS_STORAGE_BUCKET_NAME, + file_name, + ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"}, + ) + + # Generate presigned url for the uploaded file with different base + presign_s3 = boto3.client( + "s3", + endpoint_url=( + f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/" + ), + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + + presigned_url = presign_s3.generate_presigned_url( + "get_object", + Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name}, + ExpiresIn=expires_in, + ) + else: + # If endpoint url is present, use it + if settings.AWS_S3_ENDPOINT_URL: + s3 = boto3.client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + else: + s3 = boto3.client( + "s3", + region_name=settings.AWS_REGION, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + + # Upload the file to S3 + s3.upload_fileobj( + zip_file, + settings.AWS_STORAGE_BUCKET_NAME, + file_name, + 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}, + ExpiresIn=expires_in, + ) + + exporter_instance = ExporterHistory.objects.get(token=token_id) + + # Update the exporter instance with the presigned url + if presigned_url: + exporter_instance.url = presigned_url + exporter_instance.status = "completed" + exporter_instance.key = file_name + else: + exporter_instance.status = "failed" + + exporter_instance.save(update_fields=["status", "url", "key"]) + + +@shared_task +def issue_export_task( + provider: str, + workspace_id: UUID, + project_ids: List[str], + token_id: str, + multiple: bool, + slug: str, +): + """ + Export issues from the workspace. + provider (str): The provider to export the issues to csv | json | xlsx. + token_id (str): The export object token id. + multiple (bool): Whether to export the issues to multiple files per project. + """ + try: + exporter_instance = ExporterHistory.objects.get(token=token_id) + exporter_instance.status = "processing" + exporter_instance.save(update_fields=["status"]) + + # Build base queryset for issues + workspace_issues = ( + Issue.objects.filter( + workspace__id=workspace_id, + project_id__in=project_ids, + project__project_projectmember__member=exporter_instance.initiated_by_id, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .select_related( + "project", + "workspace", + "state", + "created_by", + "estimate_point", + ) + .prefetch_related( + "labels", + "issue_cycle__cycle", + "issue_module__module", + "issue_comments", + "assignees", + "issue_subscribers", + "issue_link", + Prefetch( + "issue_relation", + queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"), + ), + Prefetch( + "issue_related", + queryset=IssueRelation.objects.select_related("issue", "issue__project"), + ), + Prefetch( + "parent", + queryset=Issue.objects.select_related("type", "project"), + ), + ) + ) + + # Create exporter for the specified format + try: + exporter = Exporter( + format_type=provider, + schema_class=IssueExportSchema, + options={"list_joiner": ", "}, + ) + except ValueError as e: + # Invalid format type + exporter_instance = ExporterHistory.objects.get(token=token_id) + exporter_instance.status = "failed" + exporter_instance.reason = str(e) + exporter_instance.save(update_fields=["status", "reason"]) + return + + files = [] + if multiple: + # Export each project separately with its own queryset + for project_id in project_ids: + project_issues = workspace_issues.filter(project_id=project_id) + export_filename = f"{slug}-{project_id}" + filename, content = exporter.export(export_filename, project_issues) + files.append((filename, content)) + else: + # Export all issues in a single file + export_filename = f"{slug}-{workspace_id}" + filename, content = exporter.export(export_filename, workspace_issues) + files.append((filename, content)) + + zip_buffer = create_zip_file(files) + upload_to_s3(zip_buffer, workspace_id, token_id, slug) + + except Exception as e: + exporter_instance = ExporterHistory.objects.get(token=token_id) + exporter_instance.status = "failed" + exporter_instance.reason = str(e) + exporter_instance.save(update_fields=["status", "reason"]) + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/exporter_expired_task.py b/apps/api/plane/bgtasks/exporter_expired_task.py new file mode 100644 index 00000000..30b638c8 --- /dev/null +++ b/apps/api/plane/bgtasks/exporter_expired_task.py @@ -0,0 +1,49 @@ +# Python imports +import boto3 +from datetime import timedelta + +# Django imports +from django.conf import settings +from django.utils import timezone +from django.db.models import Q + +# Third party imports +from celery import shared_task +from botocore.client import Config + +# Module imports +from plane.db.models import ExporterHistory + + +@shared_task +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)) + ).values_list("key", "id") + if settings.USE_MINIO: + s3 = boto3.client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + else: + s3 = boto3.client( + "s3", + region_name=settings.AWS_REGION, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + + for file_name, exporter_id in expired_exporter_history: + # Delete object from S3 + if file_name: + if settings.USE_MINIO: + 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) + + ExporterHistory.objects.filter(id=exporter_id).update(url=None) diff --git a/apps/api/plane/bgtasks/file_asset_task.py b/apps/api/plane/bgtasks/file_asset_task.py new file mode 100644 index 00000000..d6eccf73 --- /dev/null +++ b/apps/api/plane/bgtasks/file_asset_task.py @@ -0,0 +1,22 @@ +# Python imports +import os +from datetime import timedelta + +# Django imports +from django.utils import timezone +from django.db.models import Q + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import FileAsset + + +@shared_task +def delete_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/apps/api/plane/bgtasks/forgot_password_task.py b/apps/api/plane/bgtasks/forgot_password_task.py new file mode 100644 index 00000000..ffaba993 --- /dev/null +++ b/apps/api/plane/bgtasks/forgot_password_task.py @@ -0,0 +1,68 @@ +# Python imports +import logging + +# Third party imports +from celery import shared_task + +# Django imports +# Third party imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Module imports +from plane.license.utils.instance_value import get_email_configuration +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}" + abs_url = str(current_site) + relative_link + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + subject = "A new password to your Plane account has been requested" + + context = { + "first_name": first_name, + "forgot_password_url": abs_url, + "email": email, + } + + html_content = render_to_string("emails/auth/forgot_password.html", context) + + text_content = strip_tags(html_content) + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + logging.getLogger("plane.worker").info("Email sent successfully") + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/issue_activities_task.py b/apps/api/plane/bgtasks/issue_activities_task.py new file mode 100644 index 00000000..a886305f --- /dev/null +++ b/apps/api/plane/bgtasks/issue_activities_task.py @@ -0,0 +1,1600 @@ +# Python imports +import json + + +# Third Party imports +from celery import shared_task + +# Django imports +from django.core.serializers.json import DjangoJSONEncoder +from django.utils import timezone + + +# Module imports +from plane.app.serializers import IssueActivitySerializer +from plane.bgtasks.notification_task import notifications +from plane.db.models import ( + CommentReaction, + Cycle, + Issue, + IssueActivity, + IssueComment, + IssueReaction, + IssueSubscriber, + Label, + Module, + Project, + State, + User, + EstimatePoint, +) +from plane.settings.redis import redis_instance +from plane.utils.exception_logger import log_exception +from plane.utils.issue_relation_mapper import get_inverse_relation +from plane.utils.uuid import is_valid_uuid + + +def extract_ids(data: dict | None, primary_key: str, fallback_key: str) -> set[str]: + if not data: + return set() + if primary_key in data: + return {str(x) for x in data.get(primary_key, [])} + return {str(x) for x in data.get(fallback_key, [])} + + +# Track Changes in name +def track_name( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("name") != requested_data.get("name"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=current_instance.get("name"), + new_value=requested_data.get("name"), + field="name", + project_id=project_id, + workspace_id=workspace_id, + comment="updated the name to", + epoch=epoch, + ) + ) + + +# Track issue description +def track_description( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("description_html") != requested_data.get("description_html"): + last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first() + if ( + last_activity is not None + and last_activity.field == "description" + and actor_id == str(last_activity.actor_id) + ): + last_activity.created_at = timezone.now() + last_activity.save(update_fields=["created_at"]) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=current_instance.get("description_html"), + new_value=requested_data.get("description_html"), + field="description", + project_id=project_id, + workspace_id=workspace_id, + comment="updated the description to", + epoch=epoch, + ) + ) + + +# Track changes in parent issue +def track_parent( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + current_parent_id = current_instance.get("parent_id") or current_instance.get("parent") + requested_parent_id = requested_data.get("parent_id") or requested_data.get("parent") + + # Validate UUIDs before database queries + if current_parent_id is not None and not is_valid_uuid(current_parent_id): + return + if requested_parent_id is not None and not is_valid_uuid(requested_parent_id): + return + + if current_parent_id != requested_parent_id: + old_parent = Issue.objects.filter(pk=current_parent_id).first() if current_parent_id is not None else None + new_parent = Issue.objects.filter(pk=requested_parent_id).first() if requested_parent_id is not None else None + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=( + f"{old_parent.project.identifier}-{old_parent.sequence_id}" if old_parent is not None else "" + ), + new_value=( + f"{new_parent.project.identifier}-{new_parent.sequence_id}" if new_parent is not None else "" + ), + field="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), + epoch=epoch, + ) + ) + + +# Track changes in priority +def track_priority( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("priority") != requested_data.get("priority"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=current_instance.get("priority"), + new_value=requested_data.get("priority"), + field="priority", + project_id=project_id, + workspace_id=workspace_id, + comment="updated the priority to", + epoch=epoch, + ) + ) + + +# Track changes in state of the issue +def track_state( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + current_state_id = current_instance.get("state_id") or current_instance.get("state") + requested_state_id = requested_data.get("state_id") or requested_data.get("state") + + if current_state_id is not None and not is_valid_uuid(current_state_id): + current_state_id = None + if requested_state_id is not None and not is_valid_uuid(requested_state_id): + requested_state_id = None + + if current_state_id != requested_state_id: + new_state = State.objects.filter(pk=requested_state_id, project_id=project_id).first() + old_state = State.objects.filter(pk=current_state_id, project_id=project_id).first() + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=old_state.name if old_state else None, + new_value=new_state.name if new_state else None, + field="state", + project_id=project_id, + workspace_id=workspace_id, + comment="updated the state to", + old_identifier=old_state.id if old_state else None, + new_identifier=new_state.id if new_state else None, + epoch=epoch, + ) + ) + + +# Track changes in issue target date +def track_target_date( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("target_date") != requested_data.get("target_date"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=( + current_instance.get("target_date") if current_instance.get("target_date") is not None else "" + ), + new_value=(requested_data.get("target_date") if requested_data.get("target_date") is not None else ""), + field="target_date", + project_id=project_id, + workspace_id=workspace_id, + comment="updated the target date to", + epoch=epoch, + ) + ) + + +# Track changes in issue start date +def track_start_date( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("start_date") != requested_data.get("start_date"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=( + current_instance.get("start_date") if current_instance.get("start_date") is not None else "" + ), + new_value=(requested_data.get("start_date") if requested_data.get("start_date") is not None else ""), + field="start_date", + project_id=project_id, + workspace_id=workspace_id, + comment="updated the start date to ", + epoch=epoch, + ) + ) + + +# Track changes in issue labels +def track_labels( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + # Labels + requested_labels = extract_ids(requested_data, "label_ids", "labels") + current_labels = extract_ids(current_instance, "label_ids", "labels") + + added_labels = requested_labels - current_labels + dropped_labels = current_labels - requested_labels + + # Set of newly added labels + for added_label in added_labels: + # validate uuids + if not is_valid_uuid(added_label): + continue + + label = Label.objects.get(pk=added_label) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + project_id=project_id, + workspace_id=workspace_id, + verb="updated", + field="labels", + comment="added label ", + old_value="", + new_value=label.name, + new_identifier=label.id, + old_identifier=None, + epoch=epoch, + ) + ) + + # Set of dropped labels + for dropped_label in dropped_labels: + # validate uuids + if not is_valid_uuid(dropped_label): + continue + + label = Label.objects.get(pk=dropped_label) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=label.name, + new_value="", + field="labels", + project_id=project_id, + workspace_id=workspace_id, + comment="removed label ", + old_identifier=label.id, + new_identifier=None, + epoch=epoch, + ) + ) + + +# Track changes in issue assignees +def track_assignees( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + # Assignees + requested_assignees = extract_ids(requested_data, "assignee_ids", "assignees") + current_assignees = extract_ids(current_instance, "assignee_ids", "assignees") + + added_assignees = requested_assignees - current_assignees + dropped_assginees = current_assignees - requested_assignees + + bulk_subscribers = [] + for added_asignee in added_assignees: + # validate uuids + if not is_valid_uuid(added_asignee): + continue + + assignee = User.objects.get(pk=added_asignee) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value="", + new_value=assignee.display_name, + field="assignees", + project_id=project_id, + workspace_id=workspace_id, + comment="added assignee ", + new_identifier=assignee.id, + epoch=epoch, + ) + ) + bulk_subscribers.append( + IssueSubscriber( + subscriber_id=assignee.id, + issue_id=issue_id, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=assignee.id, + updated_by_id=assignee.id, + ) + ) + + # Create assignees subscribers to the issue and ignore if already + IssueSubscriber.objects.bulk_create(bulk_subscribers, batch_size=10, ignore_conflicts=True) + + for dropped_assignee in dropped_assginees: + # validate uuids + if not is_valid_uuid(dropped_assignee): + continue + + assignee = User.objects.get(pk=dropped_assignee) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=assignee.display_name, + new_value="", + field="assignees", + project_id=project_id, + workspace_id=workspace_id, + comment="removed assignee ", + old_identifier=assignee.id, + epoch=epoch, + ) + ) + + +def track_estimate_points( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("estimate_point") != requested_data.get("estimate_point"): + old_estimate = ( + EstimatePoint.objects.filter(pk=current_instance.get("estimate_point")).first() + if current_instance.get("estimate_point") is not None + else None + ) + new_estimate = ( + EstimatePoint.objects.filter(pk=requested_data.get("estimate_point")).first() + if requested_data.get("estimate_point") is not None + else None + ) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="removed" if new_estimate is None else "updated", + old_identifier=( + current_instance.get("estimate_point") + if current_instance.get("estimate_point") is not None + else None + ), + new_identifier=( + requested_data.get("estimate_point") if requested_data.get("estimate_point") is not None else None + ), + old_value=old_estimate.value if old_estimate else None, + new_value=new_estimate.value if new_estimate else None, + field="estimate_" + new_estimate.estimate.type, + project_id=project_id, + workspace_id=workspace_id, + comment="updated the estimate point to ", + epoch=epoch, + ) + ) + + +def track_archive_at( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("archived_at") != requested_data.get("archived_at"): + if requested_data.get("archived_at") is None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="has restored the issue", + verb="updated", + actor_id=actor_id, + field="archived_at", + old_value="archive", + new_value="restore", + epoch=epoch, + ) + ) + else: + if requested_data.get("automation"): + comment = "Plane has archived the issue" + new_value = "archive" + else: + comment = "Actor has archived the issue" + new_value = "manual_archive" + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment=comment, + verb="updated", + actor_id=actor_id, + field="archived_at", + old_value=None, + new_value=new_value, + epoch=epoch, + ) + ) + + +def track_closed_to( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if requested_data.get("closed_to") is not None: + updated_state = State.objects.get(pk=requested_data.get("closed_to"), project_id=project_id) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=None, + new_value=updated_state.name, + field="state", + project_id=project_id, + workspace_id=workspace_id, + comment="Plane updated the state to ", + old_identifier=None, + new_identifier=updated_state.id, + epoch=epoch, + ) + ) + + +def create_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + issue = Issue.objects.get(pk=issue_id) + issue_activity = IssueActivity.objects.create( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="created the issue", + verb="created", + actor_id=actor_id, + epoch=epoch, + ) + 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 + if requested_data.get("assignee_ids") is not None: + track_assignees( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, + ) + + +def update_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + ISSUE_ACTIVITY_MAPPER = { + "name": track_name, + "parent_id": track_parent, + "priority": track_priority, + "state_id": track_state, + "description_html": track_description, + "target_date": track_target_date, + "start_date": track_start_date, + "label_ids": track_labels, + "assignee_ids": track_assignees, + "estimate_point": track_estimate_points, + "archived_at": track_archive_at, + "closed_to": track_closed_to, + # External endpoint keys + "parent": track_parent, + "state": track_state, + "assignees": track_assignees, + "labels": track_labels, + } + + 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 + + for key in requested_data: + func = ISSUE_ACTIVITY_MAPPER.get(key) + if func is not None: + func( + requested_data=requested_data, + current_instance=current_instance, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + actor_id=actor_id, + issue_activities=issue_activities, + epoch=epoch, + ) + + +def delete_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + issue_activities.append( + IssueActivity( + project_id=project_id, + workspace_id=workspace_id, + issue_id=issue_id, + comment="deleted the issue", + verb="deleted", + actor_id=actor_id, + field="issue", + epoch=epoch, + ) + ) + + +def create_comment_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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 + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="created a comment", + verb="created", + actor_id=actor_id, + field="comment", + new_value=requested_data.get("comment_html", ""), + new_identifier=requested_data.get("id", None), + issue_comment_id=requested_data.get("id", None), + epoch=epoch, + ) + ) + + +def update_comment_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="updated a comment", + verb="updated", + actor_id=actor_id, + field="comment", + old_value=current_instance.get("comment_html", ""), + old_identifier=current_instance.get("id"), + new_value=requested_data.get("comment_html", ""), + new_identifier=current_instance.get("id", None), + issue_comment_id=current_instance.get("id", None), + epoch=epoch, + ) + ) + + +def delete_comment_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + requested_data = json.loads(requested_data) if requested_data is not None else None + issue_activities.append( + IssueActivity( + issue_comment_id=requested_data.get("comment_id", None), + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="deleted the comment", + verb="deleted", + actor_id=actor_id, + field="comment", + epoch=epoch, + ) + ) + + +def create_cycle_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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", [])) + + for updated_record in updated_records: + old_cycle = Cycle.objects.filter(pk=updated_record.get("old_cycle_id", None)).first() + new_cycle = Cycle.objects.filter(pk=updated_record.get("new_cycle_id", None)).first() + issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first() + if issue: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + + issue_activities.append( + IssueActivity( + issue_id=updated_record.get("issue_id"), + actor_id=actor_id, + verb="updated", + old_value=old_cycle.name if old_cycle else "", + new_value=new_cycle.name if new_cycle else "", + field="cycles", + project_id=project_id, + workspace_id=workspace_id, + comment=f"""updated cycle from {old_cycle.name if old_cycle else ""} + to {new_cycle.name if new_cycle else ""}""", + old_identifier=old_cycle.id if old_cycle else None, + new_identifier=new_cycle.id if new_cycle else None, + epoch=epoch, + ) + ) + + for created_record in created_records: + cycle = Cycle.objects.filter(pk=created_record.get("fields").get("cycle")).first() + issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).first() + if issue: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + + issue_activities.append( + IssueActivity( + issue_id=created_record.get("fields").get("issue"), + actor_id=actor_id, + verb="created", + old_value="", + new_value=cycle.name, + field="cycles", + project_id=project_id, + workspace_id=workspace_id, + comment=f"added cycle {cycle.name}", + new_identifier=cycle.id, + epoch=epoch, + ) + ) + + +def delete_cycle_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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 + + cycle_id = requested_data.get("cycle_id", "") + cycle_name = requested_data.get("cycle_name", "") + cycle = Cycle.objects.filter(pk=cycle_id).first() + issues = requested_data.get("issues") + for issue in issues: + current_issue = Issue.objects.filter(pk=issue).first() + if current_issue: + current_issue.updated_at = timezone.now() + current_issue.save(update_fields=["updated_at"]) + issue_activities.append( + IssueActivity( + issue_id=issue, + actor_id=actor_id, + verb="deleted", + old_value=cycle.name if cycle is not None else cycle_name, + new_value="", + field="cycles", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed this issue from {cycle.name if cycle is not None else cycle_name}", + old_identifier=cycle_id if cycle_id is not None else None, + epoch=epoch, + ) + ) + + +def create_module_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="created", + old_value="", + new_value=module.name if module else "", + field="modules", + project_id=project_id, + workspace_id=workspace_id, + comment=f"added module {module.name if module else ''}", + new_identifier=requested_data.get("module_id"), + epoch=epoch, + ) + ) + + +def delete_module_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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 + module_name = current_instance.get("module_name") + current_issue = Issue.objects.filter(pk=issue_id).first() + if current_issue: + current_issue.updated_at = timezone.now() + current_issue.save(update_fields=["updated_at"]) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="deleted", + old_value=module_name, + new_value="", + field="modules", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed this issue from {module_name}", + old_identifier=(requested_data.get("module_id") if requested_data.get("module_id") is not None else None), + epoch=epoch, + ) + ) + + +def create_link_activity( + requested_data, + current_instance, + issue_id, + project_id, + actor_id, + workspace_id, + issue_activities, + epoch, +): + 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 + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="created a link", + verb="created", + actor_id=actor_id, + field="link", + new_value=requested_data.get("url", ""), + new_identifier=requested_data.get("id", None), + epoch=epoch, + ) + ) + + +def update_link_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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("url") != requested_data.get("url"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="updated a link", + verb="updated", + actor_id=actor_id, + field="link", + old_value=current_instance.get("url", ""), + old_identifier=current_instance.get("id"), + new_value=requested_data.get("url", ""), + new_identifier=current_instance.get("id", None), + epoch=epoch, + ) + ) + + +def delete_link_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + current_instance = json.loads(current_instance) if current_instance is not None else None + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="deleted the link", + verb="deleted", + actor_id=actor_id, + field="link", + old_value=current_instance.get("url", ""), + new_value="", + epoch=epoch, + ) + ) + + +def create_attachment_activity( + requested_data, + current_instance, + issue_id, + project_id, + actor_id, + workspace_id, + issue_activities, + epoch, +): + 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 + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="created an attachment", + verb="created", + actor_id=actor_id, + field="attachment", + new_value=current_instance.get("asset", ""), + new_identifier=current_instance.get("id", None), + epoch=epoch, + ) + ) + + +def delete_attachment_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="deleted the attachment", + verb="deleted", + actor_id=actor_id, + field="attachment", + epoch=epoch, + ) + ) + + +def create_issue_reaction_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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( + reaction=requested_data.get("reaction"), + project_id=project_id, + actor_id=actor_id, + ) + .values_list("id", flat=True) + .first() + ) + if issue_reaction is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="created", + old_value=None, + new_value=requested_data.get("reaction"), + field="reaction", + project_id=project_id, + workspace_id=workspace_id, + comment="added the reaction", + old_identifier=None, + new_identifier=issue_reaction, + epoch=epoch, + ) + ) + + +def delete_issue_reaction_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + current_instance = json.loads(current_instance) if current_instance is not None else None + if current_instance and current_instance.get("reaction") is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="deleted", + old_value=current_instance.get("reaction"), + new_value=None, + field="reaction", + project_id=project_id, + workspace_id=workspace_id, + comment="removed the reaction", + old_identifier=current_instance.get("identifier"), + new_identifier=None, + epoch=epoch, + ) + ) + + +def create_comment_reaction_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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( + reaction=requested_data.get("reaction"), + project_id=project_id, + actor_id=actor_id, + ) + .values_list("id", "comment__id") + .first() + ) + comment = IssueComment.objects.get(pk=comment_id, project_id=project_id) + if comment is not None and comment_reaction_id is not None and comment_id is not None: + issue_activities.append( + IssueActivity( + issue_id=comment.issue_id, + actor_id=actor_id, + verb="created", + old_value=None, + new_value=requested_data.get("reaction"), + field="reaction", + project_id=project_id, + workspace_id=workspace_id, + comment="added the reaction", + old_identifier=None, + new_identifier=comment_reaction_id, + epoch=epoch, + ) + ) + + +def delete_comment_reaction_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + current_instance = json.loads(current_instance) if current_instance is not None else None + if current_instance and current_instance.get("reaction") is not None: + issue_id = ( + IssueComment.objects.filter(pk=current_instance.get("comment_id"), project_id=project_id) + .values_list("issue_id", flat=True) + .first() + ) + if issue_id is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="deleted", + old_value=current_instance.get("reaction"), + new_value=None, + field="reaction", + project_id=project_id, + workspace_id=workspace_id, + comment="removed the reaction", + old_identifier=current_instance.get("identifier"), + new_identifier=None, + epoch=epoch, + ) + ) + + +def create_issue_vote_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=None, + new_value=requested_data.get("vote"), + field="vote", + project_id=project_id, + workspace_id=workspace_id, + comment="added the vote", + old_identifier=None, + new_identifier=None, + epoch=epoch, + ) + ) + + +def delete_issue_vote_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + current_instance = json.loads(current_instance) if current_instance is not None else None + if current_instance and current_instance.get("vote") is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="deleted", + old_value=current_instance.get("vote"), + new_value=None, + field="vote", + project_id=project_id, + workspace_id=workspace_id, + comment="removed the vote", + old_identifier=current_instance.get("identifier"), + new_identifier=None, + epoch=epoch, + ) + ) + + +def create_issue_relation_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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 is None and requested_data.get("issues") is not None: + for related_issue in requested_data.get("issues"): + issue = Issue.objects.get(pk=related_issue) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value="", + new_value=f"{issue.project.identifier}-{issue.sequence_id}", + field=requested_data.get("relation_type"), + project_id=project_id, + workspace_id=workspace_id, + comment=f"added {requested_data.get('relation_type')} relation", + old_identifier=related_issue, + epoch=epoch, + ) + ) + inverse_relation = get_inverse_relation(requested_data.get("relation_type")) + issue = Issue.objects.get(pk=issue_id) + issue_activities.append( + IssueActivity( + issue_id=related_issue, + actor_id=actor_id, + verb="updated", + old_value="", + new_value=f"{issue.project.identifier}-{issue.sequence_id}", + field=inverse_relation, + project_id=project_id, + workspace_id=workspace_id, + comment=f"added {inverse_relation} relation", + old_identifier=issue_id, + epoch=epoch, + ) + ) + + +def delete_issue_relation_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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 + issue = Issue.objects.get(pk=requested_data.get("related_issue")) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="deleted", + old_value=f"{issue.project.identifier}-{issue.sequence_id}", + new_value="", + field=requested_data.get("relation_type"), + project_id=project_id, + workspace_id=workspace_id, + comment=f"deleted {requested_data.get('relation_type')} relation", + old_identifier=requested_data.get("related_issue"), + epoch=epoch, + ) + ) + issue = Issue.objects.get(pk=issue_id) + issue_activities.append( + IssueActivity( + issue_id=requested_data.get("related_issue"), + actor_id=actor_id, + verb="deleted", + old_value=f"{issue.project.identifier}-{issue.sequence_id}", + new_value="", + 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") + ) + ), + project_id=project_id, + workspace_id=workspace_id, + comment=f"deleted {requested_data.get('relation_type')} relation", + old_identifier=requested_data.get("related_issue"), + epoch=epoch, + ) + ) + + +def create_draft_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="drafted the issue", + field="draft", + verb="created", + actor_id=actor_id, + epoch=epoch, + ) + ) + + +def update_draft_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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 requested_data.get("is_draft") is not None and requested_data.get("is_draft") is False: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="created the issue", + verb="updated", + actor_id=actor_id, + epoch=epoch, + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="updated the draft issue", + field="draft", + verb="updated", + actor_id=actor_id, + epoch=epoch, + ) + ) + + +def delete_draft_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + issue_activities.append( + IssueActivity( + project_id=project_id, + workspace_id=workspace_id, + comment="deleted the draft issue", + field="draft", + verb="deleted", + actor_id=actor_id, + epoch=epoch, + ) + ) + + +def create_intake_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + 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 + status_dict = { + -2: "Pending", + -1: "Rejected", + 0: "Snoozed", + 1: "Accepted", + 2: "Duplicate", + } + if requested_data.get("status") is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="updated the intake status", + field="intake", + verb=requested_data.get("status"), + actor_id=actor_id, + epoch=epoch, + old_value=status_dict.get(current_instance.get("status")), + new_value=status_dict.get(requested_data.get("status")), + ) + ) + + +# Receive message from room group +@shared_task +def issue_activity( + type, + requested_data, + current_instance, + issue_id, + actor_id, + project_id, + epoch, + subscriber=True, + notification=False, + origin=None, + intake=None, +): + try: + issue_activities = [] + + # check if project_id is valid + if not is_valid_uuid(str(project_id)): + return + + project = Project.objects.get(pk=project_id) + workspace_id = project.workspace_id + + if issue_id is not None: + if origin: + ri = redis_instance() + # set the request origin in redis + ri.set(str(issue_id), origin, ex=600) + issue = Issue.objects.filter(pk=issue_id).first() + if issue: + try: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + except Exception: + pass + + ACTIVITY_MAPPER = { + "issue.activity.created": create_issue_activity, + "issue.activity.updated": update_issue_activity, + "issue.activity.deleted": delete_issue_activity, + "comment.activity.created": create_comment_activity, + "comment.activity.updated": update_comment_activity, + "comment.activity.deleted": delete_comment_activity, + "cycle.activity.created": create_cycle_issue_activity, + "cycle.activity.deleted": delete_cycle_issue_activity, + "module.activity.created": create_module_issue_activity, + "module.activity.deleted": delete_module_issue_activity, + "link.activity.created": create_link_activity, + "link.activity.updated": update_link_activity, + "link.activity.deleted": delete_link_activity, + "attachment.activity.created": create_attachment_activity, + "attachment.activity.deleted": delete_attachment_activity, + "issue_relation.activity.created": create_issue_relation_activity, + "issue_relation.activity.deleted": delete_issue_relation_activity, + "issue_reaction.activity.created": create_issue_reaction_activity, + "issue_reaction.activity.deleted": delete_issue_reaction_activity, + "comment_reaction.activity.created": create_comment_reaction_activity, + "comment_reaction.activity.deleted": delete_comment_reaction_activity, + "issue_vote.activity.created": create_issue_vote_activity, + "issue_vote.activity.deleted": delete_issue_vote_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, + "intake.activity.created": create_intake_activity, + } + + func = ACTIVITY_MAPPER.get(type) + if func is not None: + func( + requested_data=requested_data, + current_instance=current_instance, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + actor_id=actor_id, + issue_activities=issue_activities, + epoch=epoch, + ) + + # Save all the values to database + issue_activities_created = IssueActivity.objects.bulk_create(issue_activities) + + if notification: + notifications.delay( + type=type, + issue_id=issue_id, + actor_id=actor_id, + project_id=project_id, + subscriber=subscriber, + issue_activities_created=json.dumps( + IssueActivitySerializer(issue_activities_created, many=True).data, + cls=DjangoJSONEncoder, + ), + requested_data=requested_data, + current_instance=current_instance, + ) + + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/issue_automation_task.py b/apps/api/plane/bgtasks/issue_automation_task.py new file mode 100644 index 00000000..1cc303b5 --- /dev/null +++ b/apps/api/plane/bgtasks/issue_automation_task.py @@ -0,0 +1,145 @@ +# Python imports +import json +from datetime import timedelta + +# Third party imports +from celery import shared_task +from django.db.models import Q + +# Django imports +from django.utils import timezone + +# Module imports +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import Issue, Project, State +from plane.utils.exception_logger import log_exception + + +@shared_task +def archive_and_close_old_issues(): + archive_old_issues() + close_old_issues() + + +def archive_old_issues(): + try: + # Get all the projects whose archive_in is greater than 0 + projects = Project.objects.filter(archive_in__gt=0) + + for project in projects: + project_id = project.id + archive_in = project.archive_in + + # Get all the issues whose updated_at in less that the archive_in month + issues = Issue.issue_objects.filter( + Q( + project=project_id, + archived_at__isnull=True, + 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()) & Q(issue_cycle__isnull=False)), + Q(issue_module__isnull=True) + | (Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False)), + ).filter( + 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: + # Set the archive time to current time + archive_at = timezone.now().date() + + issues_to_update = [] + for issue in issues: + issue.archived_at = archive_at + issues_to_update.append(issue) + + # Bulk Update the issues and log the activity + if issues_to_update: + Issue.objects.bulk_update(issues_to_update, ["archived_at"], batch_size=100) + _ = [ + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"archived_at": str(archive_at), "automation": True}), + actor_id=str(project.created_by_id), + issue_id=issue.id, + project_id=project_id, + current_instance=json.dumps({"archived_at": None}), + subscriber=False, + epoch=int(timezone.now().timestamp()), + notification=True, + ) + for issue in issues_to_update + ] + return + except Exception as e: + log_exception(e) + return + + +def close_old_issues(): + try: + # Get all the projects whose close_in is greater than 0 + projects = Project.objects.filter(close_in__gt=0).select_related("default_state") + + for project in projects: + project_id = project.id + close_in = project.close_in + + # Get all the issues whose updated_at in less that the close_in month + issues = Issue.issue_objects.filter( + Q( + project=project_id, + archived_at__isnull=True, + 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()) & Q(issue_cycle__isnull=False)), + Q(issue_module__isnull=True) + | (Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False)), + ).filter( + 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() + else: + close_state = project.default_state + + issues_to_update = [] + for issue in issues: + issue.state = close_state + issues_to_update.append(issue) + + # Bulk Update the issues and log the activity + if issues_to_update: + Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) + [ + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"closed_to": str(issue.state_id)}), + actor_id=str(project.created_by_id), + issue_id=issue.id, + project_id=project_id, + current_instance=None, + subscriber=False, + epoch=int(timezone.now().timestamp()), + notification=True, + ) + for issue in issues_to_update + ] + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/issue_description_version_sync.py b/apps/api/plane/bgtasks/issue_description_version_sync.py new file mode 100644 index 00000000..d10ebfcb --- /dev/null +++ b/apps/api/plane/bgtasks/issue_description_version_sync.py @@ -0,0 +1,121 @@ +# Python imports +from typing import Optional +import logging + +# Django imports +from django.utils import timezone +from django.db import transaction + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import Issue, IssueDescriptionVersion, ProjectMember +from plane.utils.exception_logger import log_exception + + +def get_owner_id(issue: Issue) -> Optional[int]: + """Get the owner ID of the issue""" + + if issue.updated_by_id: + return issue.updated_by_id + + if issue.created_by_id: + return issue.created_by_id + + # Find project admin as fallback + project_member = ProjectMember.objects.filter( + project_id=issue.project_id, + role=20, # Admin role + ).first() + + return project_member.member_id if project_member else None + + +@shared_task +def sync_issue_description_version(batch_size=5000, offset=0, countdown=300): + """Task to create IssueDescriptionVersion records for existing Issues in batches""" + try: + with transaction.atomic(): + base_query = Issue.objects + total_issues_count = base_query.count() + + if total_issues_count == 0: + return + + # Calculate batch range + end_offset = min(offset + batch_size, total_issues_count) + + # Fetch issues with related data + issues_batch = ( + base_query.order_by("created_at") + .select_related("workspace", "project") + .only( + "id", + "workspace_id", + "project_id", + "created_by_id", + "updated_by_id", + "description_binary", + "description_html", + "description_stripped", + "description", + )[offset:end_offset] + ) + + if not issues_batch: + return + + version_objects = [] + for issue in issues_batch: + # Validate required fields + if not issue.workspace_id or not issue.project_id: + logging.warning(f"Skipping {issue.id} - missing workspace_id or project_id") + continue + + # Determine owned_by_id + owned_by_id = get_owner_id(issue) + if owned_by_id is None: + logging.warning(f"Skipping issue {issue.id} - missing owned_by") + continue + + # Create version object + version_objects.append( + IssueDescriptionVersion( + workspace_id=issue.workspace_id, + project_id=issue.project_id, + created_by_id=issue.created_by_id, + updated_by_id=issue.updated_by_id, + owned_by_id=owned_by_id, + last_saved_at=timezone.now(), + issue_id=issue.id, + description_binary=issue.description_binary, + description_html=issue.description_html, + description_stripped=issue.description_stripped, + description_json=issue.description, + ) + ) + + # Bulk create version objects + if version_objects: + IssueDescriptionVersion.objects.bulk_create(version_objects) + + # Schedule next batch if needed + if end_offset < total_issues_count: + sync_issue_description_version.apply_async( + kwargs={ + "batch_size": batch_size, + "offset": end_offset, + "countdown": countdown, + }, + countdown=countdown, + ) + return + except Exception as e: + log_exception(e) + return + + +@shared_task +def schedule_issue_description_version(batch_size=5000, countdown=300): + sync_issue_description_version.delay(batch_size=int(batch_size), countdown=countdown) diff --git a/apps/api/plane/bgtasks/issue_description_version_task.py b/apps/api/plane/bgtasks/issue_description_version_task.py new file mode 100644 index 00000000..06d15705 --- /dev/null +++ b/apps/api/plane/bgtasks/issue_description_version_task.py @@ -0,0 +1,74 @@ +from celery import shared_task +from django.db import transaction +from django.utils import timezone +from typing import Optional, Dict +import json + +from plane.db.models import Issue, IssueDescriptionVersion +from plane.utils.exception_logger import log_exception + + +def should_update_existing_version( + version: IssueDescriptionVersion, user_id: str, max_time_difference: int = 600 +) -> bool: + if not version: + return + + time_difference = (timezone.now() - version.last_saved_at).total_seconds() + return str(version.owned_by_id) == str(user_id) and time_difference <= max_time_difference + + +def update_existing_version(version: IssueDescriptionVersion, issue) -> None: + version.description_json = issue.description + version.description_html = issue.description_html + version.description_binary = issue.description_binary + version.description_stripped = issue.description_stripped + version.last_saved_at = timezone.now() + + version.save( + update_fields=[ + "description_json", + "description_html", + "description_binary", + "description_stripped", + "last_saved_at", + ] + ) + + +@shared_task +def issue_description_version_task(updated_issue, issue_id, user_id, is_creating=False) -> Optional[bool]: + try: + # Parse updated issue data + current_issue: Dict = json.loads(updated_issue) if updated_issue else {} + + # Get current issue + issue = Issue.objects.get(id=issue_id) + + # Check if description has changed + if current_issue.get("description_html") == issue.description_html and not is_creating: + return + + with transaction.atomic(): + # Get latest version + latest_version = ( + IssueDescriptionVersion.objects.filter(issue_id=issue_id).order_by("-last_saved_at").first() + ) + + # Determine whether to update existing or create new version + if should_update_existing_version(version=latest_version, user_id=user_id): + update_existing_version(latest_version, issue) + else: + IssueDescriptionVersion.log_issue_description_version(issue, user_id) + + return + + except Issue.DoesNotExist: + # Issue no longer exists, skip processing + return + except json.JSONDecodeError as e: + log_exception(f"Invalid JSON for updated_issue: {e}") + return + except Exception as e: + log_exception(f"Error processing issue description version: {e}") + return diff --git a/apps/api/plane/bgtasks/issue_version_sync.py b/apps/api/plane/bgtasks/issue_version_sync.py new file mode 100644 index 00000000..761c26bc --- /dev/null +++ b/apps/api/plane/bgtasks/issue_version_sync.py @@ -0,0 +1,232 @@ +# Python imports +import json +from typing import Optional, List, Dict +from uuid import UUID +from itertools import groupby +import logging + +# Django imports +from django.utils import timezone +from django.db import transaction + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import ( + Issue, + IssueVersion, + ProjectMember, + CycleIssue, + ModuleIssue, + IssueActivity, + IssueAssignee, + IssueLabel, +) +from plane.utils.exception_logger import log_exception + + +@shared_task +def issue_task(updated_issue, issue_id, user_id): + try: + current_issue = json.loads(updated_issue) if updated_issue else {} + issue = Issue.objects.get(id=issue_id) + + updated_current_issue = {} + for key, value in current_issue.items(): + if getattr(issue, key) != value: + updated_current_issue[key] = value + + if updated_current_issue: + issue_version = IssueVersion.objects.filter(issue_id=issue_id).order_by("-last_saved_at").first() + + if ( + issue_version + and str(issue_version.owned_by) == str(user_id) + and (timezone.now() - issue_version.last_saved_at).total_seconds() <= 600 + ): + for key, value in updated_current_issue.items(): + setattr(issue_version, key, value) + issue_version.last_saved_at = timezone.now() + issue_version.save(update_fields=list(updated_current_issue.keys()) + ["last_saved_at"]) + else: + IssueVersion.log_issue_version(issue, user_id) + + return + except Issue.DoesNotExist: + return + except Exception as e: + log_exception(e) + return + + +def get_owner_id(issue: Issue) -> Optional[int]: + """Get the owner ID of the issue""" + + if issue.updated_by_id: + return issue.updated_by_id + + if issue.created_by_id: + return issue.created_by_id + + # Find project admin as fallback + project_member = ProjectMember.objects.filter( + project_id=issue.project_id, + role=20, # Admin role + ).first() + + return project_member.member_id if project_member else None + + +def get_related_data(issue_ids: List[UUID]) -> Dict: + """Get related data for the given issue IDs""" + + cycle_issues = {ci.issue_id: ci.cycle_id for ci in CycleIssue.objects.filter(issue_id__in=issue_ids)} + + # Get assignees with proper grouping + assignee_records = list( + IssueAssignee.objects.filter(issue_id__in=issue_ids).values_list("issue_id", "assignee_id").order_by("issue_id") + ) + assignees = {} + for issue_id, group in groupby(assignee_records, key=lambda x: x[0]): + assignees[issue_id] = [str(g[1]) for g in group] + + # Get labels with proper grouping + label_records = list( + IssueLabel.objects.filter(issue_id__in=issue_ids).values_list("issue_id", "label_id").order_by("issue_id") + ) + labels = {} + for issue_id, group in groupby(label_records, key=lambda x: x[0]): + labels[issue_id] = [str(g[1]) for g in group] + + # Get modules with proper grouping + module_records = list( + ModuleIssue.objects.filter(issue_id__in=issue_ids).values_list("issue_id", "module_id").order_by("issue_id") + ) + modules = {} + for issue_id, group in groupby(module_records, key=lambda x: x[0]): + modules[issue_id] = [str(g[1]) for g in group] + + # Get latest activities + latest_activities = {} + activities = IssueActivity.objects.filter(issue_id__in=issue_ids).order_by("issue_id", "-created_at") + for issue_id, activities_group in groupby(activities, key=lambda x: x.issue_id): + first_activity = next(activities_group, None) + if first_activity: + latest_activities[issue_id] = first_activity.id + + return { + "cycle_issues": cycle_issues, + "assignees": assignees, + "labels": labels, + "modules": modules, + "activities": latest_activities, + } + + +def create_issue_version(issue: Issue, related_data: Dict) -> Optional[IssueVersion]: + """Create IssueVersion object from the given issue and related data""" + + try: + if not issue.workspace_id or not issue.project_id: + logging.warning(f"Skipping issue {issue.id} - missing workspace_id or project_id") + return None + + owned_by_id = get_owner_id(issue) + if owned_by_id is None: + logging.warning(f"Skipping issue {issue.id} - missing owned_by") + return None + + return IssueVersion( + workspace_id=issue.workspace_id, + project_id=issue.project_id, + created_by_id=issue.created_by_id, + updated_by_id=issue.updated_by_id, + owned_by_id=owned_by_id, + last_saved_at=timezone.now(), + activity_id=related_data["activities"].get(issue.id), + properties=getattr(issue, "properties", {}), + meta=getattr(issue, "meta", {}), + issue_id=issue.id, + parent=issue.parent_id, + state=issue.state_id, + estimate_point=issue.estimate_point_id, + name=issue.name, + priority=issue.priority, + start_date=issue.start_date, + target_date=issue.target_date, + assignees=related_data["assignees"].get(issue.id, []), + sequence_id=issue.sequence_id, + labels=related_data["labels"].get(issue.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_id, + cycle=related_data["cycle_issues"].get(issue.id), + modules=related_data["modules"].get(issue.id, []), + ) + except Exception as e: + log_exception(e) + return None + + +@shared_task +def sync_issue_version(batch_size=5000, offset=0, countdown=300): + """Task to create IssueVersion records for existing Issues in batches""" + + try: + with transaction.atomic(): + base_query = Issue.objects + total_issues_count = base_query.count() + + if total_issues_count == 0: + return + + end_offset = min(offset + batch_size, total_issues_count) + + # Get issues batch with optimized queries + issues_batch = list( + base_query.order_by("created_at").select_related("workspace", "project").all()[offset:end_offset] + ) + + if not issues_batch: + return + + # Get all related data in bulk + issue_ids = [issue.id for issue in issues_batch] + related_data = get_related_data(issue_ids) + + issue_versions = [] + for issue in issues_batch: + version = create_issue_version(issue, related_data) + if version: + issue_versions.append(version) + + # Bulk create versions + if issue_versions: + IssueVersion.objects.bulk_create(issue_versions, batch_size=1000) + + # Schedule the next batch if there are more workspaces to process + if end_offset < total_issues_count: + sync_issue_version.apply_async( + kwargs={ + "batch_size": batch_size, + "offset": end_offset, + "countdown": countdown, + }, + countdown=countdown, + ) + + logging.info(f"Processed Issues: {end_offset}") + return + except Exception as e: + log_exception(e) + return + + +@shared_task +def schedule_issue_version(batch_size=5000, countdown=300): + sync_issue_version.delay(batch_size=int(batch_size), countdown=countdown) diff --git a/apps/api/plane/bgtasks/magic_link_code_task.py b/apps/api/plane/bgtasks/magic_link_code_task.py new file mode 100644 index 00000000..d8267e69 --- /dev/null +++ b/apps/api/plane/bgtasks/magic_link_code_task.py @@ -0,0 +1,60 @@ +# Python imports +import logging + +# Third party imports +from celery import shared_task + +# Django imports +# Third party imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Module imports +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception + + +@shared_task +def magic_link(email, key, token): + try: + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + # Send the mail + subject = f"Your unique Plane login code is {token}" + context = {"code": token, "email": email} + + html_content = render_to_string("emails/auth/magic_signin.html", context) + text_content = strip_tags(html_content) + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + logging.getLogger("plane.worker").info("Email sent successfully.") + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/notification_task.py b/apps/api/plane/bgtasks/notification_task.py new file mode 100644 index 00000000..6e571c0b --- /dev/null +++ b/apps/api/plane/bgtasks/notification_task.py @@ -0,0 +1,670 @@ +# Python imports +import json +import uuid +from uuid import UUID + + +# Module imports +from plane.db.models import ( + IssueMention, + IssueSubscriber, + Project, + User, + IssueAssignee, + Issue, + State, + EmailNotificationLog, + Notification, + IssueComment, + IssueActivity, + UserNotificationPreference, + ProjectMember, +) +from django.db.models import Subquery + +# Third Party imports +from celery import shared_task +from bs4 import BeautifulSoup + + +# =========== Issue Description Html Parsing and notification Functions ====================== + + +def update_mentions_for_issue(issue, project, new_mentions, removed_mention): + aggregated_issue_mentions = [] + for mention_id in new_mentions: + aggregated_issue_mentions.append( + IssueMention( + mention_id=mention_id, + issue=issue, + project=project, + workspace_id=project.workspace_id, + ) + ) + + IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100) + IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete() + + +def get_new_mentions(requested_instance, current_instance): + # requested_data is the newer instance of the current issue + # current_instance is the older instance of the current issue, saved in the database + + # extract mentions from both the instance of data + mentions_older = extract_mentions(current_instance) + + mentions_newer = extract_mentions(requested_instance) + + # Getting Set Difference from mentions_newer + new_mentions = [mention for mention in mentions_newer if mention not in mentions_older] + + return new_mentions + + +# Get Removed Mention +def get_removed_mentions(requested_instance, current_instance): + # requested_data is the newer instance of the current issue + # current_instance is the older instance of the current issue, saved in the database + + # extract mentions from both the instance of data + mentions_older = extract_mentions(current_instance) + mentions_newer = extract_mentions(requested_instance) + + # Getting Set Difference from mentions_newer + removed_mentions = [mention for mention in mentions_older if mention not in mentions_newer] + + return removed_mentions + + +# Adds mentions as subscribers +def extract_mentions_as_subscribers(project_id, issue_id, mentions): + # mentions is an array of User IDs representing the FILTERED set of mentioned users + + bulk_mention_subscribers = [] + + for mention_id in mentions: + # If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification # noqa: E501 + if ( + not IssueSubscriber.objects.filter( + 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 + ).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) + + bulk_mention_subscribers.append( + IssueSubscriber( + workspace_id=project.workspace_id, + project_id=project_id, + issue_id=issue_id, + subscriber_id=mention_id, + ) + ) + return bulk_mention_subscribers + + +# Parse Issue Description & extracts mentions +def extract_mentions(issue_instance): + try: + # issue_instance has to be a dictionary passed, containing the description_html and other set of activity data. # noqa: E501 + mentions = [] + # Convert string to dictionary + data = json.loads(issue_instance) + html = data.get("description_html") + soup = BeautifulSoup(html, "html.parser") + mention_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"}) + + mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags] + + return list(set(mentions)) + except Exception: + return [] + + +# =========== Comment Parsing and notification Functions ====================== +def extract_comment_mentions(comment_value): + try: + mentions = [] + soup = BeautifulSoup(comment_value, "html.parser") + mentions_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"}) + for mention_tag in mentions_tags: + mentions.append(mention_tag["entity_identifier"]) + return list(set(mentions)) + except Exception: + return [] + + +def get_new_comment_mentions(new_value, old_value): + mentions_newer = extract_comment_mentions(new_value) + if old_value is None: + return mentions_newer + + mentions_older = extract_comment_mentions(old_value) + # Getting Set Difference from mentions_newer + new_mentions = [mention for mention in mentions_newer if mention not in mentions_older] + + return new_mentions + + +def create_mention_notification(project, notification_comment, issue, actor_id, mention_id, issue_id, activity): + return Notification( + workspace=project.workspace, + sender="in_app:issue_activities:mentioned", + triggered_by_id=actor_id, + receiver_id=mention_id, + entity_identifier=issue_id, + entity_name="issue", + project=project, + message=notification_comment, + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(issue.project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(activity.get("id")), + "verb": str(activity.get("verb")), + "field": str(activity.get("field")), + "actor": str(activity.get("actor_id")), + "new_value": str(activity.get("new_value")), + "old_value": str(activity.get("old_value")), + "old_identifier": (str(activity.get("old_identifier")) if activity.get("old_identifier") else None), + "new_identifier": (str(activity.get("new_identifier")) if activity.get("new_identifier") else None), + }, + }, + ) + + +@shared_task +def notifications( + type, + issue_id, + project_id, + actor_id, + subscriber, + issue_activities_created, + requested_data, + current_instance, +): + try: + issue_activities_created = ( + json.loads(issue_activities_created) if issue_activities_created is not None else None + ) + if type not in [ + "cycle.activity.created", + "cycle.activity.deleted", + "module.activity.created", + "module.activity.deleted", + "issue_reaction.activity.created", + "issue_reaction.activity.deleted", + "comment_reaction.activity.created", + "comment_reaction.activity.deleted", + "issue_vote.activity.created", + "issue_vote.activity.deleted", + "issue_draft.activity.created", + "issue_draft.activity.updated", + "issue_draft.activity.deleted", + ]: + # Create Notifications + bulk_notifications = [] + bulk_email_logs = [] + + """ + Mention Tasks + 1. Perform Diffing and Extract the mentions, that mention notification needs to be sent + 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) + new_mentions = list(set(new_mentions) & {str(member) for member in project_members}) + removed_mention = get_removed_mentions(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) + mention_subscribers = extract_mentions_as_subscribers( + project_id=project_id, issue_id=issue_id, mentions=requested_mentions + ) + + for issue_activity in issue_activities_created: + issue_comment = issue_activity.get("issue_comment") + issue_comment_new_value = issue_activity.get("new_value") + issue_comment_old_value = issue_activity.get("old_value") + if issue_comment is not None: + # TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well. + + all_comment_mentions = all_comment_mentions + extract_comment_mentions(issue_comment_new_value) + + new_comment_mentions = get_new_comment_mentions( + old_value=issue_comment_old_value, + 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 + ) + """ + We will not send subscription activity notification to the below mentioned user sets + - Those who have been newly mentioned in the issue description, we will send mention notification to them. + - When the activity is a comment_created and there exist a mention in the comment, + then we have to send the "mention_in_comment" notification + - When the activity is a comment_updated and there exist a mention change, + then also we have to send the "mention_in_comment" notification + """ + + # --------------------------------------------------------------------------------------------------------- + issue_subscribers = list( + IssueSubscriber.objects.filter( + project_id=project_id, + issue_id=issue_id, + subscriber__in=Subquery(project_members), + ) + .exclude(subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])) + .values_list("subscriber", flat=True) + ) + + issue = Issue.objects.filter(pk=issue_id).first() + + if subscriber: + # add the user to issue subscriber + try: + _ = IssueSubscriber.objects.get_or_create( + project_id=project_id, issue_id=issue_id, subscriber_id=actor_id + ) + except Exception: + pass + + project = Project.objects.get(pk=project_id) + + issue_assignees = IssueAssignee.objects.filter( + 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)}) + + for subscriber in issue_subscribers: + if issue.created_by_id and issue.created_by_id == subscriber: + sender = "in_app:issue_activities:created" + elif subscriber in issue_assignees and issue.created_by_id not in issue_assignees: + sender = "in_app:issue_activities:assigned" + else: + sender = "in_app:issue_activities:subscribed" + + 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: + continue + + # Do not send notification for description update + if issue_activity.get("field") == "description": + continue + + # Check if the value should be sent or not + send_email = False + if issue_activity.get("field") == "state" and preference.state_change: + send_email = True + elif ( + issue_activity.get("field") == "state" + and preference.issue_completed + and State.objects.filter( + project_id=project_id, + pk=issue_activity.get("new_identifier"), + group="completed", + ).exists() + ): + send_email = True + elif issue_activity.get("field") == "comment" and preference.comment: + send_email = True + elif preference.property_change: + send_email = True + else: + send_email = False + + # If activity is of issue comment fetch the comment + issue_comment = ( + IssueComment.objects.filter( + id=issue_activity.get("issue_comment"), + issue_id=issue_id, + project_id=project_id, + workspace_id=project.workspace_id, + ).first() + if issue_activity.get("issue_comment") + else None + ) + + # Create in app notification + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender=sender, + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + project=project, + title=issue_activity.get("comment"), + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(issue.project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "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")), + "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")) + if issue_activity.get("old_identifier") + else None + ), + "new_identifier": ( + str(issue_activity.get("new_identifier")) + if issue_activity.get("new_identifier") + else None + ), + }, + }, + ) + ) + # Create email notification + if send_email: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(issue.project.identifier), + "project_id": str(issue.project.id), + "workspace_slug": str(issue.project.workspace.slug), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "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")), + "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")) + if issue_activity.get("old_identifier") + else None + ), + "new_identifier": ( + str(issue_activity.get("new_identifier")) + if issue_activity.get("new_identifier") + else None + ), + "activity_time": issue_activity.get("created_at"), + }, + }, + ) + ) + + # -------------------------------------------------------------------------------------------------------- # + + # Add Mentioned as Issue Subscribers + IssueSubscriber.objects.bulk_create( + mention_subscribers + comment_mention_subscribers, + batch_size=100, + ignore_conflicts=True, + ) + + last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first() + + actor = User.objects.get(pk=actor_id) + + for mention_id in comment_mentions: + if mention_id != actor_id: + preference = UserNotificationPreference.objects.get(user_id=mention_id) + for issue_activity in issue_activities_created: + notification = create_mention_notification( + project=project, + issue=issue, + notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", # noqa: E501 + actor_id=actor_id, + mention_id=mention_id, + issue_id=issue_id, + activity=issue_activity, + ) + + # check for email notifications + if preference.mention: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=mention_id, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "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), + "workspace_slug": str(issue.project.workspace.slug), + }, + "issue_activity": { + "id": str(issue_activity.get("id")), + "verb": str(issue_activity.get("verb")), + "field": str("mention"), + "actor": str(issue_activity.get("actor_id")), + "new_value": str(issue_activity.get("new_value")), + "old_value": str(issue_activity.get("old_value")), + "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") + else None + ), + "activity_time": issue_activity.get("created_at"), + }, + }, + ) + ) + bulk_notifications.append(notification) + + for mention_id in new_mentions: + if mention_id != actor_id: + preference = UserNotificationPreference.objects.get(user_id=mention_id) + if ( + last_activity is not None + and last_activity.field == "description" + and actor_id == str(last_activity.actor_id) + ): + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender="in_app:issue_activities:mentioned", + triggered_by_id=actor_id, + receiver_id=mention_id, + entity_identifier=issue_id, + entity_name="issue", + project=project, + message=f"You have been mentioned in the issue {issue.name}", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "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), + "workspace_slug": str(issue.project.workspace.slug), + }, + "issue_activity": { + "id": str(last_activity.id), + "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), + "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") + else None + ), + }, + }, + ) + ) + if preference.mention: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(issue.project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "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), + "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") + else None + ), + "activity_time": str(last_activity.created_at), + }, + }, + ) + ) + else: + for issue_activity in issue_activities_created: + notification = create_mention_notification( + project=project, + issue=issue, + notification_comment=f"You have been mentioned in the issue {issue.name}", + actor_id=actor_id, + mention_id=mention_id, + issue_id=issue_id, + activity=issue_activity, + ) + if preference.mention: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(issue.project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(issue_activity.get("id")), + "verb": str(issue_activity.get("verb")), + "field": str("mention"), + "actor": str(issue_activity.get("actor_id")), + "new_value": str(issue_activity.get("new_value")), + "old_value": str(issue_activity.get("old_value")), + "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") + else None + ), + "activity_time": issue_activity.get("created_at"), + }, + }, + ) + ) + bulk_notifications.append(notification) + + # save new mentions for the particular issue and remove the mentions that has been deleted from the description # noqa: E501 + update_mentions_for_issue( + issue=issue, + project=project, + new_mentions=new_mentions, + removed_mention=removed_mention, + ) + # Bulk create notifications + Notification.objects.bulk_create(bulk_notifications, batch_size=100) + EmailNotificationLog.objects.bulk_create(bulk_email_logs, batch_size=100, ignore_conflicts=True) + return + except Exception as e: + print(e) + return diff --git a/apps/api/plane/bgtasks/page_transaction_task.py b/apps/api/plane/bgtasks/page_transaction_task.py new file mode 100644 index 00000000..402d0a3e --- /dev/null +++ b/apps/api/plane/bgtasks/page_transaction_task.py @@ -0,0 +1,142 @@ +# Python imports +import logging + +# Django imports +from django.utils import timezone + +# Third-party imports +from bs4 import BeautifulSoup + +# App imports +from celery import shared_task +from plane.db.models import Page, PageLog +from plane.utils.exception_logger import log_exception + +logger = logging.getLogger("plane.worker") + +COMPONENT_MAP = { + "mention-component": { + "attributes": ["id", "entity_identifier", "entity_name", "entity_type"], + "extract": lambda m: { + "entity_name": m.get("entity_name"), + "entity_type": None, + "entity_identifier": m.get("entity_identifier"), + }, + }, + "image-component": { + "attributes": ["id", "src"], + "extract": lambda m: { + "entity_name": "image", + "entity_type": None, + "entity_identifier": m.get("src"), + }, + }, +} + +component_map = { + **COMPONENT_MAP, +} + + +def extract_all_components(description_html): + """ + Extracts all component types from the HTML value in a single pass. + Returns a dict mapping component_type -> list of extracted entities. + """ + try: + if not description_html: + return {component: [] for component in component_map.keys()} + + soup = BeautifulSoup(description_html, "html.parser") + results = {} + + for component, config in component_map.items(): + attributes = config.get("attributes", ["id"]) + component_tags = soup.find_all(component) + + entities = [] + for tag in component_tags: + entity = {attr: tag.get(attr) for attr in attributes} + entities.append(entity) + + results[component] = entities + + return results + + except Exception: + return {component: [] for component in component_map.keys()} + + +def get_entity_details(component: str, mention: dict): + """ + Normalizes mention attributes into entity_name, entity_type, entity_identifier. + """ + config = component_map.get(component) + if not config: + return {"entity_name": None, "entity_type": None, "entity_identifier": None} + return config["extract"](mention) + + +@shared_task +def page_transaction(new_description_html, old_description_html, page_id): + """ + Tracks changes in page content (mentions, embeds, etc.) + and logs them in PageLog for audit and reference. + """ + try: + page = Page.objects.get(pk=page_id) + + has_existing_logs = PageLog.objects.filter(page_id=page_id).exists() + + + # Extract all components in a single pass (optimized) + old_components = extract_all_components(old_description_html) + new_components = extract_all_components(new_description_html) + + new_transactions = [] + deleted_transaction_ids = set() + + for component in component_map.keys(): + old_entities = old_components[component] + new_entities = new_components[component] + + old_ids = {m.get("id") for m in old_entities if m.get("id")} + new_ids = {m.get("id") for m in new_entities if m.get("id")} + deleted_transaction_ids.update(old_ids - new_ids) + + for mention in new_entities: + mention_id = mention.get("id") + if not mention_id or (mention_id in old_ids and has_existing_logs): + continue + + details = get_entity_details(component, mention) + current_time = timezone.now() + + new_transactions.append( + PageLog( + transaction=mention_id, + page_id=page_id, + entity_identifier=details["entity_identifier"], + entity_name=details["entity_name"], + entity_type=details["entity_type"], + workspace_id=page.workspace_id, + created_at=current_time, + updated_at=current_time, + ) + ) + + + # Bulk insert and cleanup + if new_transactions: + PageLog.objects.bulk_create( + new_transactions, batch_size=50, ignore_conflicts=True + ) + + if deleted_transaction_ids: + PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete() + + except Page.DoesNotExist: + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/page_version_task.py b/apps/api/plane/bgtasks/page_version_task.py new file mode 100644 index 00000000..4de2387b --- /dev/null +++ b/apps/api/plane/bgtasks/page_version_task.py @@ -0,0 +1,45 @@ +# Python imports +import json + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import Page, PageVersion +from plane.utils.exception_logger import log_exception + + +@shared_task +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 {} + + # Create a version if description_html is updated + if current_instance.get("description_html") != page.description_html: + # Create a new page version + PageVersion.objects.create( + page_id=page_id, + workspace_id=page.workspace_id, + description_html=page.description_html, + description_binary=page.description_binary, + owned_by_id=user_id, + last_saved_at=page.updated_at, + description_json=page.description, + description_stripped=page.description_stripped, + ) + + # If page versions are greater than 20 delete the oldest one + if PageVersion.objects.filter(page_id=page_id).count() > 20: + # Delete the old page version + PageVersion.objects.filter(page_id=page_id).order_by("last_saved_at").first().delete() + + return + except Page.DoesNotExist: + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/project_add_user_email_task.py b/apps/api/plane/bgtasks/project_add_user_email_task.py new file mode 100644 index 00000000..af601469 --- /dev/null +++ b/apps/api/plane/bgtasks/project_add_user_email_task.py @@ -0,0 +1,85 @@ +# Python imports +import logging + +# Third party imports +from celery import shared_task + +# Third party imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + + +# Module imports +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception +from plane.db.models import ProjectMember +from plane.db.models import User + + +@shared_task +def project_add_user_email(current_site, project_member_id, invitor_id): + try: + # Get the invitor + invitor = User.objects.get(pk=invitor_id) + inviter_first_name = invitor.first_name + # Get the project member + project_member = ProjectMember.objects.get(pk=project_member_id) + # Get the project member details + project_name = project_member.project.name + workspace_name = project_member.workspace.name + member_email = project_member.member.email + project_url = f"{current_site}/{project_member.workspace.slug}/projects/{project_member.project_id}/issues" + # set the context + context = { + "project_name": project_name, + "workspace_name": workspace_name, + "email": member_email, + "inviter_first_name": inviter_first_name, + "project_url": project_url, + } + + # Get the email configuration + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + # Set the subject + subject = "You have been invited to a Plane project" + + # Render the email template + html_content = render_to_string("emails/notifications/project_addition.html", context) + text_content = strip_tags(html_content) + # Initialize the connection + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + # Send the email + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[member_email], + connection=connection, + ) + # Attach the html content + msg.attach_alternative(html_content, "text/html") + # Send the email + msg.send() + # Log the success + logging.getLogger("plane.worker").info("Email sent successfully.") + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/project_invitation_task.py b/apps/api/plane/bgtasks/project_invitation_task.py new file mode 100644 index 00000000..b8eed5e4 --- /dev/null +++ b/apps/api/plane/bgtasks/project_invitation_task.py @@ -0,0 +1,81 @@ +# Python imports +import logging + +# Third party imports +from celery import shared_task + +# Django imports +# Third party imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Module imports +from plane.db.models import Project, ProjectMemberInvite, User +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception + + +@shared_task +def project_invitation(email, project_id, token, current_site, invitor): + try: + user = User.objects.get(email=invitor) + project = Project.objects.get(pk=project_id) + project_member_invite = ProjectMemberInvite.objects.get(token=token, email=email) + + relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}" # noqa: E501 + abs_url = current_site + relativelink + + subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane" + + context = { + "email": email, + "first_name": user.first_name, + "project_name": project.name, + "invitation_url": abs_url, + } + + html_content = render_to_string("emails/invitations/project_invitation.html", context) + + text_content = strip_tags(html_content) + + project_member_invite.message = text_content + project_member_invite.save() + + # Configure email connection from the database + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[email], + connection=connection, + ) + + msg.attach_alternative(html_content, "text/html") + msg.send() + logging.getLogger("plane.worker").info("Email sent successfully.") + return + except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist): + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/recent_visited_task.py b/apps/api/plane/bgtasks/recent_visited_task.py new file mode 100644 index 00000000..eda297ce --- /dev/null +++ b/apps/api/plane/bgtasks/recent_visited_task.py @@ -0,0 +1,57 @@ +# Python imports +from django.utils import timezone +from django.db import DatabaseError + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import UserRecentVisit, Workspace +from plane.utils.exception_logger import log_exception + + +@shared_task +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( + entity_name=entity_name, + entity_identifier=entity_identifier, + user_id=user_id, + project_id=project_id, + workspace_id=workspace.id, + ).first() + + if recent_visited: + # Check if the database is available + try: + recent_visited.visited_at = timezone.now() + recent_visited.save(update_fields=["visited_at"]) + except DatabaseError: + pass + else: + recent_visited_count = UserRecentVisit.objects.filter(user_id=user_id, workspace_id=workspace.id).count() + if recent_visited_count == 20: + recent_visited = ( + UserRecentVisit.objects.filter(user_id=user_id, workspace_id=workspace.id) + .order_by("created_at") + .first() + ) + recent_visited.delete() + + recent_activity = UserRecentVisit.objects.create( + entity_name=entity_name, + entity_identifier=entity_identifier, + user_id=user_id, + visited_at=timezone.now(), + project_id=project_id, + workspace_id=workspace.id, + ) + 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"]) + + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/storage_metadata_task.py b/apps/api/plane/bgtasks/storage_metadata_task.py new file mode 100644 index 00000000..ea745053 --- /dev/null +++ b/apps/api/plane/bgtasks/storage_metadata_task.py @@ -0,0 +1,26 @@ +# 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/apps/api/plane/bgtasks/user_activation_email_task.py b/apps/api/plane/bgtasks/user_activation_email_task.py new file mode 100644 index 00000000..492564b3 --- /dev/null +++ b/apps/api/plane/bgtasks/user_activation_email_task.py @@ -0,0 +1,65 @@ +# Python imports +import logging + +# Django imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import User +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception + + +@shared_task +def user_activation_email(current_site, user_id): + try: + # Send email to user when account is activated + user = User.objects.get(id=user_id) + subject = f"{user.first_name or user.display_name or user.email} has been activated on Plane" + + context = {"email": str(user.email), "profile_url": current_site + "/profile"} + + # Send email to user + html_content = render_to_string("emails/user/user_activation.html", context) + + text_content = strip_tags(html_content) + # Configure email connection from the database + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[user.email], + connection=connection, + ) + + msg.attach_alternative(html_content, "text/html") + msg.send() + logging.getLogger("plane.worker").info("Email sent successfully.") + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/user_deactivation_email_task.py b/apps/api/plane/bgtasks/user_deactivation_email_task.py new file mode 100644 index 00000000..2595d805 --- /dev/null +++ b/apps/api/plane/bgtasks/user_deactivation_email_task.py @@ -0,0 +1,67 @@ +# Python imports +import logging + +# Django imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import User +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception + + +@shared_task +def user_deactivation_email(current_site, user_id): + try: + # Send email to user when account is deactivated + user = User.objects.get(id=user_id) + subject = f"{user.first_name or user.display_name or user.email} has been deactivated on Plane" + + context = {"email": str(user.email), "login_url": current_site + "/login"} + + # Send email to user + html_content = render_to_string("emails/user/user_deactivation.html", context) + + text_content = strip_tags(html_content) + # Configure email connection from the database + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + # Send email + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[user.email], + connection=connection, + ) + + # Attach HTML content + msg.attach_alternative(html_content, "text/html") + msg.send() + logging.getLogger("plane.worker").info("Email sent successfully.") + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/webhook_task.py b/apps/api/plane/bgtasks/webhook_task.py new file mode 100644 index 00000000..2504eb73 --- /dev/null +++ b/apps/api/plane/bgtasks/webhook_task.py @@ -0,0 +1,504 @@ +import hashlib +import hmac +import json +import logging +import uuid + +import requests +from typing import Any, Dict, List, Optional, Union + +# Third party imports +from celery import shared_task + +# Django imports +from django.conf import settings +from django.db.models import Prefetch +from django.core.mail import EmailMultiAlternatives, get_connection +from django.core.serializers.json import DjangoJSONEncoder +from django.template.loader import render_to_string +from django.utils.html import strip_tags +from django.core.exceptions import ObjectDoesNotExist + +# Module imports +from plane.api.serializers import ( + CycleIssueSerializer, + CycleSerializer, + IssueCommentSerializer, + IssueExpandSerializer, + ModuleIssueSerializer, + ModuleSerializer, + ProjectSerializer, + UserLiteSerializer, + IntakeIssueSerializer, +) +from plane.db.models import ( + Cycle, + CycleIssue, + Issue, + IssueComment, + Module, + ModuleIssue, + Project, + User, + Webhook, + WebhookLog, + IntakeIssue, + IssueLabel, + IssueAssignee, +) +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception +from plane.settings.mongo import MongoConnection + + +SERIALIZER_MAPPER = { + "project": ProjectSerializer, + "issue": IssueExpandSerializer, + "cycle": CycleSerializer, + "module": ModuleSerializer, + "cycle_issue": CycleIssueSerializer, + "module_issue": ModuleIssueSerializer, + "issue_comment": IssueCommentSerializer, + "user": UserLiteSerializer, + "intake_issue": IntakeIssueSerializer, +} + +MODEL_MAPPER = { + "project": Project, + "issue": Issue, + "cycle": Cycle, + "module": Module, + "cycle_issue": CycleIssue, + "module_issue": ModuleIssue, + "issue_comment": IssueComment, + "user": User, + "intake_issue": IntakeIssue, +} + + +logger = logging.getLogger("plane.worker") + + +def get_issue_prefetches(): + return [ + Prefetch("label_issue", queryset=IssueLabel.objects.select_related("label")), + Prefetch("issue_assignee", queryset=IssueAssignee.objects.select_related("assignee")), + ] + + + +def save_webhook_log( + webhook: Webhook, + request_method: str, + request_headers: str, + request_body: str, + response_status: str, + response_headers: str, + response_body: str, + retry_count: int, + event_type: str, +) -> None: + + # webhook_logs + mongo_collection = MongoConnection.get_collection("webhook_logs") + + log_data = { + "workspace_id": str(webhook.workspace_id), + "webhook": str(webhook.id), + "event_type": str(event_type), + "request_method": str(request_method), + "request_headers": str(request_headers), + "request_body": str(request_body), + "response_status": str(response_status), + "response_headers": str(response_headers), + "response_body": str(response_body), + "retry_count": retry_count, + } + + mongo_save_success = False + if mongo_collection is not None: + try: + # insert the log data into the mongo collection + mongo_collection.insert_one(log_data) + logger.info("Webhook log saved successfully to mongo") + mongo_save_success = True + except Exception as e: + log_exception(e) + logger.error(f"Failed to save webhook log: {e}") + mongo_save_success = False + + # if the mongo save is not successful, save the log data into the database + if not mongo_save_success: + try: + # insert the log data into the database + WebhookLog.objects.create(**log_data) + logger.info("Webhook log saved successfully to database") + except Exception as e: + log_exception(e) + logger.error(f"Failed to save webhook log: {e}") + + +def get_model_data(event: str, event_id: Union[str, List[str]], many: bool = False) -> Dict[str, Any]: + """ + Retrieve and serialize model data based on the event type. + + Args: + event (str): The type of event/model to retrieve data for + event_id (Union[str, List[str]]): The ID or list of IDs of the model instance(s) + many (bool): Whether to retrieve multiple instances + + Returns: + Dict[str, Any]: Serialized model data + + Raises: + ValueError: If serializer is not found for the event + ObjectDoesNotExist: If model instance is not found + """ + model = MODEL_MAPPER.get(event) + if model is None: + raise ValueError(f"Model not found for event: {event}") + + try: + if many: + queryset = model.objects.filter(pk__in=event_id) + else: + queryset = model.objects.get(pk=event_id) + + serializer = SERIALIZER_MAPPER.get(event) + + if serializer is None: + raise ValueError(f"Serializer not found for event: {event}") + + issue_prefetches = get_issue_prefetches() + if event == "issue": + if many: + queryset = queryset.prefetch_related(*issue_prefetches) + else: + issue_id = queryset.id + queryset = model.objects.filter(pk=issue_id).prefetch_related(*issue_prefetches).first() + + return serializer(queryset, many=many, context={"expand": ["labels", "assignees"]}).data + else: + return serializer(queryset, many=many).data + except ObjectDoesNotExist: + raise ObjectDoesNotExist(f"No {event} found with id: {event_id}") + + +@shared_task +def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_site: str, reason: str) -> None: + """ + Send an email notification when a webhook is deactivated. + + Args: + webhook_id (str): ID of the deactivated webhook + receiver_id (str): ID of the user to receive the notification + current_site (str): Current site URL + reason (str): Reason for webhook deactivation + """ + try: + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + receiver = User.objects.get(pk=receiver_id) + webhook = Webhook.objects.get(pk=webhook_id) + + # Get the webhook payload + subject = "Webhook Deactivated" + message = f"Webhook {webhook.url} has been deactivated due to failed requests." + + # Send the mail + context = { + "email": receiver.email, + "message": message, + "webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}", + } + html_content = render_to_string("emails/notifications/webhook-deactivate.html", context) + text_content = strip_tags(html_content) + + # Set the email connection + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + # Create the email message + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver.email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + logger.info("Email sent successfully.") + except Exception as e: + log_exception(e) + logger.error(f"Failed to send email: {e}") + + +@shared_task( + bind=True, + autoretry_for=(requests.RequestException,), + retry_backoff=600, + max_retries=5, + retry_jitter=True, +) +def webhook_send_task( + self, + webhook_id: str, + slug: str, + event: str, + event_data: Optional[Dict[str, Any]], + action: str, + current_site: str, + activity: Optional[Dict[str, Any]], +) -> None: + """ + Send webhook notifications to configured endpoints. + + Args: + webhook (str): Webhook ID + slug (str): Workspace slug + event (str): Event type + event_data (Optional[Dict[str, Any]]): Event data to be sent + action (str): HTTP method/action + current_site (str): Current site URL + activity (Optional[Dict[str, Any]]): Activity data + """ + try: + webhook = Webhook.objects.get(id=webhook_id, workspace__slug=slug) + + headers = { + "Content-Type": "application/json", + "User-Agent": "Autopilot", + "X-Plane-Delivery": str(uuid.uuid4()), + "X-Plane-Event": event, + } + + # # Your secret key + event_data = json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) if event_data is not None else None + + activity = json.loads(json.dumps(activity, cls=DjangoJSONEncoder)) if activity is not None else None + + action = { + "POST": "create", + "PATCH": "update", + "PUT": "update", + "DELETE": "delete", + }.get(action, action) + + payload = { + "event": event, + "action": action, + "webhook_id": str(webhook.id), + "workspace_id": str(webhook.workspace_id), + "data": event_data, + "activity": activity, + } + + # Use HMAC for generating signature + if webhook.secret_key: + hmac_signature = hmac.new( + webhook.secret_key.encode("utf-8"), + json.dumps(payload).encode("utf-8"), + hashlib.sha256, + ) + signature = hmac_signature.hexdigest() + headers["X-Plane-Signature"] = signature + except Exception as e: + log_exception(e) + logger.error(f"Failed to send webhook: {e}") + return + + try: + # Send the webhook event + response = requests.post(webhook.url, headers=headers, json=payload, timeout=30) + + # Log the webhook request + save_webhook_log( + webhook=webhook, + request_method=action, + request_headers=headers, + request_body=payload, + response_status=response.status_code, + response_headers=response.headers, + response_body=response.text, + retry_count=self.request.retries, + event_type=event, + ) + logger.info(f"Webhook {webhook.id} sent successfully") + except requests.RequestException as e: + # Log the failed webhook request + save_webhook_log( + webhook=webhook, + request_method=action, + request_headers=headers, + request_body=payload, + response_status=500, + response_headers="", + response_body=str(e), + retry_count=self.request.retries, + event_type=event, + ) + logger.error(f"Webhook {webhook.id} failed with error: {e}") + # Retry logic + if self.request.retries >= self.max_retries: + Webhook.objects.filter(pk=webhook.id).update(is_active=False) + if webhook: + # send email for the deactivation of the webhook + send_webhook_deactivation_email.delay( + webhook_id=webhook.id, + receiver_id=webhook.created_by_id, + reason=str(e), + current_site=current_site, + ) + return + raise requests.RequestException() + + except Exception as e: + log_exception(e) + return + + +@shared_task +def webhook_activity( + event: str, + verb: str, + field: Optional[str], + old_value: Any, + new_value: Any, + actor_id: str | uuid.UUID, + slug: str, + current_site: str, + event_id: str | uuid.UUID, + old_identifier: Optional[str], + new_identifier: Optional[str], +) -> None: + """ + Process and send webhook notifications for various activities in the system. + + This task filters relevant webhooks based on the event type and sends notifications + to all active webhooks for the workspace. + + Args: + event (str): Type of event (project, issue, module, cycle, issue_comment) + verb (str): Action performed (created, updated, deleted) + field (Optional[str]): Name of the field that was changed + old_value (Any): Previous value of the field + new_value (Any): New value of the field + actor_id (str | uuid.UUID): ID of the user who performed the action + slug (str): Workspace slug + current_site (str): Current site URL + event_id (str | uuid.UUID): ID of the event object + old_identifier (Optional[str]): Previous identifier if any + new_identifier (Optional[str]): New identifier if any + + Returns: + None + + Note: + The function silently returns on ObjectDoesNotExist exceptions to handle + race conditions where objects might have been deleted. + """ + try: + webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) + + if event == "project": + webhooks = webhooks.filter(project=True) + + if event == "issue": + webhooks = webhooks.filter(issue=True) + + if event == "module" or event == "module_issue": + webhooks = webhooks.filter(module=True) + + if event == "cycle" or event == "cycle_issue": + webhooks = webhooks.filter(cycle=True) + + if event == "issue_comment": + webhooks = webhooks.filter(issue_comment=True) + + for webhook in webhooks: + webhook_send_task.delay( + webhook_id=webhook.id, + slug=slug, + event=event, + event_data=({"id": event_id} if verb == "deleted" else get_model_data(event=event, event_id=event_id)), + action=verb, + current_site=current_site, + activity={ + "field": field, + "new_value": new_value, + "old_value": old_value, + "actor": get_model_data(event="user", event_id=actor_id), + "old_identifier": old_identifier, + "new_identifier": new_identifier, + }, + ) + return + except Exception as e: + # Return if a does not exist error occurs + if isinstance(e, ObjectDoesNotExist): + return + if settings.DEBUG: + print(e) + log_exception(e) + return + + +@shared_task +def model_activity(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: + webhook_activity.delay( + event=model_name, + verb="created", + field=None, + old_value=None, + new_value=None, + actor_id=actor_id, + slug=slug, + current_site=origin, + event_id=model_id, + old_identifier=None, + new_identifier=None, + ) + return + + # Load the current instance + current_instance = json.loads(current_instance) if current_instance is not None else None + + # Loop through all keys in requested data and check the current value and requested value + for key in requested_data: + # Check if key is present in current instance or not + if key in current_instance: + current_value = current_instance.get(key, None) + requested_value = requested_data.get(key, None) + if current_value != requested_value: + webhook_activity.delay( + event=model_name, + verb="updated", + field=key, + old_value=current_value, + new_value=requested_value, + actor_id=actor_id, + slug=slug, + current_site=origin, + event_id=model_id, + old_identifier=None, + new_identifier=None, + ) + + return diff --git a/apps/api/plane/bgtasks/work_item_link_task.py b/apps/api/plane/bgtasks/work_item_link_task.py new file mode 100644 index 00000000..721231be --- /dev/null +++ b/apps/api/plane/bgtasks/work_item_link_task.py @@ -0,0 +1,178 @@ +# Python imports +import logging + + +# Third party imports +from celery import shared_task +import requests +from bs4 import BeautifulSoup +from urllib.parse import urlparse, urljoin +import base64 +import ipaddress +from typing import Dict, Any +from typing import Optional +from plane.db.models import IssueLink +from plane.utils.exception_logger import log_exception + +logger = logging.getLogger("plane.worker") + + +DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501 + + +def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]: + """ + Crawls a URL to extract the title and favicon. + + Args: + url (str): The URL to crawl + + Returns: + str: JSON string containing title and base64-encoded favicon + """ + try: + # Prevent access to private IP ranges + parsed = urlparse(url) + + try: + ip = ipaddress.ip_address(parsed.hostname) + if ip.is_private or ip.is_loopback or ip.is_reserved: + raise ValueError("Access to private/internal networks is not allowed") + except ValueError: + # Not an IP address, continue with domain validation + pass + + # Set up headers to mimic a real browser + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501 + } + + soup = None + title = None + + try: + response = requests.get(url, headers=headers, timeout=1) + + soup = BeautifulSoup(response.content, "html.parser") + title_tag = soup.find("title") + title = title_tag.get_text().strip() if title_tag else None + + except requests.RequestException as e: + logger.warning(f"Failed to fetch HTML for title: {str(e)}") + + # Fetch and encode favicon + favicon_base64 = fetch_and_encode_favicon(headers, soup, url) + + # Prepare result + result = { + "title": title, + "favicon": favicon_base64["favicon_base64"], + "url": url, + "favicon_url": favicon_base64["favicon_url"], + } + + return result + + except Exception as e: + log_exception(e) + return { + "error": f"Unexpected error: {str(e)}", + "title": None, + "favicon": None, + "url": url, + } + + +def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[str]: + """ + Find the favicon URL from HTML soup. + + Args: + soup: BeautifulSoup object + base_url: Base URL for resolving relative paths + + Returns: + str: Absolute URL to favicon or None + """ + + if soup is not None: + # Look for various favicon link tags + favicon_selectors = [ + 'link[rel="icon"]', + 'link[rel="shortcut icon"]', + 'link[rel="apple-touch-icon"]', + 'link[rel="apple-touch-icon-precomposed"]', + ] + + for selector in favicon_selectors: + favicon_tag = soup.select_one(selector) + if favicon_tag and favicon_tag.get("href"): + return urljoin(base_url, favicon_tag["href"]) + + # Fallback to /favicon.ico + parsed_url = urlparse(base_url) + fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico" + + # Check if fallback exists + try: + response = requests.head(fallback_url, timeout=2) + if response.status_code == 200: + return fallback_url + except requests.RequestException as e: + log_exception(e, warning=True) + return None + + return None + + +def fetch_and_encode_favicon( + headers: Dict[str, str], soup: Optional[BeautifulSoup], url: str +) -> Dict[str, Optional[str]]: + """ + Fetch favicon and encode it as base64. + + Args: + favicon_url: URL to the favicon + headers: Request headers + + Returns: + str: Base64 encoded favicon with data URI prefix or None + """ + try: + favicon_url = find_favicon_url(soup, url) + if favicon_url is None: + return { + "favicon_url": None, + "favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}", + } + + response = requests.get(favicon_url, headers=headers, timeout=1) + + # Get content type + content_type = response.headers.get("content-type", "image/x-icon") + + # Convert to base64 + favicon_base64 = base64.b64encode(response.content).decode("utf-8") + + # Return as data URI + return { + "favicon_url": favicon_url, + "favicon_base64": f"data:{content_type};base64,{favicon_base64}", + } + + except Exception as e: + logger.warning(f"Failed to fetch favicon: {e}") + return { + "favicon_url": None, + "favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}", + } + + +@shared_task +def crawl_work_item_link_title(id: str, url: str) -> None: + meta_data = crawl_work_item_link_title_and_favicon(url) + issue_link = IssueLink.objects.get(id=id) + + issue_link.metadata = meta_data + + issue_link.save() diff --git a/apps/api/plane/bgtasks/workspace_invitation_task.py b/apps/api/plane/bgtasks/workspace_invitation_task.py new file mode 100644 index 00000000..f7480b36 --- /dev/null +++ b/apps/api/plane/bgtasks/workspace_invitation_task.py @@ -0,0 +1,85 @@ +# Python imports +import logging + +# Third party imports +from celery import shared_task + +# Django imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Module imports +from plane.db.models import User, Workspace, WorkspaceMemberInvite +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception + + +@shared_task +def workspace_invitation(email, workspace_id, token, current_site, inviter): + try: + user = User.objects.get(email=inviter) + + workspace = Workspace.objects.get(pk=workspace_id) + workspace_member_invite = WorkspaceMemberInvite.objects.get(token=token, email=email) + + # Relative link + relative_link = ( + f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501 + ) + + # The complete url including the domain + abs_url = str(current_site) + relative_link + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + # Subject of the email + subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" # noqa: E501 + + context = { + "email": email, + "first_name": user.first_name or user.display_name or user.email, + "workspace_name": workspace.name, + "abs_url": abs_url, + } + + html_content = render_to_string("emails/invitations/workspace_invitation.html", context) + + text_content = strip_tags(html_content) + + workspace_member_invite.message = text_content + workspace_member_invite.save() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + logging.getLogger("plane.worker").info("Email sent successfully") + return + except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist): + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/bgtasks/workspace_seed_task.py b/apps/api/plane/bgtasks/workspace_seed_task.py new file mode 100644 index 00000000..fb9980c3 --- /dev/null +++ b/apps/api/plane/bgtasks/workspace_seed_task.py @@ -0,0 +1,517 @@ +# Python imports +import os +import json +import time +import uuid +from typing import Dict +import logging +from datetime import timedelta + +# Django imports +from django.conf import settings +from django.utils import timezone + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import ( + Workspace, + WorkspaceMember, + Project, + ProjectMember, + IssueUserProperty, + State, + Label, + Issue, + IssueLabel, + IssueSequence, + IssueActivity, + Page, + ProjectPage, + Cycle, + Module, + CycleIssue, + ModuleIssue, + IssueView, +) + +logger = logging.getLogger("plane.worker") + + +def read_seed_file(filename): + """ + Read a JSON file from the seed directory. + + Args: + filename (str): Name of the JSON file to read + + Returns: + dict: Contents of the JSON file + """ + file_path = os.path.join(settings.SEED_DIR, "data", filename) + try: + with open(file_path, "r") as file: + return json.load(file) + except FileNotFoundError: + logger.error(f"Seed file {filename} not found in {settings.SEED_DIR}/data") + return None + except json.JSONDecodeError: + logger.error(f"Error decoding JSON from {filename}") + return None + + +def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]: + """Creates a project and associated members for a workspace. + + Creates a new project using the workspace name and sets up all necessary + member associations and user properties. + + Args: + workspace: The workspace to create the project in + + Returns: + A mapping of seed project IDs to actual project IDs + """ + project_seeds = read_seed_file("projects.json") + project_identifier = "".join(ch for ch in workspace.name if ch.isalnum())[:5] + + # Create members + workspace_members = WorkspaceMember.objects.filter(workspace=workspace).values("member_id", "role") + + projects_map: Dict[int, uuid.UUID] = {} + + if not project_seeds: + logger.warning("Task: workspace_seed_task -> No project seeds found. Skipping project creation.") + return projects_map + + for project_seed in project_seeds: + project_id = project_seed.pop("id") + # Remove the name from seed data since we want to use workspace name + project_seed.pop("name", None) + project_seed.pop("identifier", None) + + project = Project.objects.create( + **project_seed, + workspace=workspace, + name=workspace.name, # Use workspace name + identifier=project_identifier, + created_by_id=workspace.created_by_id, + # Enable all views in seed data + cycle_view=True, + module_view=True, + issue_views_view=True, + ) + + # Create project members + ProjectMember.objects.bulk_create( + [ + ProjectMember( + project=project, + member_id=workspace_member["member_id"], + role=workspace_member["role"], + workspace_id=workspace.id, + created_by_id=workspace.created_by_id, + ) + for workspace_member in workspace_members + ] + ) + + # Create issue user properties + IssueUserProperty.objects.bulk_create( + [ + IssueUserProperty( + project=project, + user_id=workspace_member["member_id"], + workspace_id=workspace.id, + display_filters={ + "layout": "list", + "calendar": {"layout": "month", "show_weekends": False}, + "group_by": "state", + "order_by": "sort_order", + "sub_issue": True, + "sub_group_by": None, + "show_empty_groups": True, + }, + display_properties={ + "key": True, + "link": True, + "cycle": False, + "state": True, + "labels": False, + "modules": False, + "assignee": True, + "due_date": False, + "estimate": True, + "priority": True, + "created_on": True, + "issue_type": True, + "start_date": False, + "updated_on": True, + "customer_count": True, + "sub_issue_count": False, + "attachment_count": False, + "customer_request_count": True, + }, + created_by_id=workspace.created_by_id, + ) + for workspace_member in workspace_members + ] + ) + # update map + projects_map[project_id] = project.id + logger.info(f"Task: workspace_seed_task -> Project {project_id} created") + + return projects_map + + +def create_project_states(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]: + """Creates states for each project in the workspace. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + + Returns: + A mapping of seed state IDs to actual state IDs + """ + + state_seeds = read_seed_file("states.json") + state_map: Dict[int, uuid.UUID] = {} + + if not state_seeds: + return state_map + + for state_seed in state_seeds: + state_id = state_seed.pop("id") + project_id = state_seed.pop("project_id") + + state = State.objects.create( + **state_seed, + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + ) + + state_map[state_id] = state.id + logger.info(f"Task: workspace_seed_task -> State {state_id} created") + return state_map + + +def create_project_labels(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]: + """Creates labels for each project in the workspace. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + + Returns: + A mapping of seed label IDs to actual label IDs + """ + label_seeds = read_seed_file("labels.json") + label_map: Dict[int, uuid.UUID] = {} + + if not label_seeds: + return label_map + + for label_seed in label_seeds: + label_id = label_seed.pop("id") + project_id = label_seed.pop("project_id") + label = Label.objects.create( + **label_seed, + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + ) + label_map[label_id] = label.id + + logger.info(f"Task: workspace_seed_task -> Label {label_id} created") + return label_map + + +def create_project_issues( + workspace: Workspace, + project_map: Dict[int, uuid.UUID], + states_map: Dict[int, uuid.UUID], + labels_map: Dict[int, uuid.UUID], + cycles_map: Dict[int, uuid.UUID], + module_map: Dict[int, uuid.UUID], +) -> None: + """Creates issues and their associated records for each project. + + Creates issues along with their sequences, activities, and label associations. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + states_map: Mapping of seed state IDs to actual state IDs + labels_map: Mapping of seed label IDs to actual label IDs + """ + issue_seeds = read_seed_file("issues.json") + + if not issue_seeds: + return + + for issue_seed in issue_seeds: + required_fields = ["id", "labels", "project_id", "state_id"] + # get the values + for field in required_fields: + if field not in issue_seed: + logger.error(f"Task: workspace_seed_task -> Required field '{field}' missing in issue seed") + continue + + # get the values + issue_id = issue_seed.pop("id") + labels = issue_seed.pop("labels") + project_id = issue_seed.pop("project_id") + state_id = issue_seed.pop("state_id") + cycle_id = issue_seed.pop("cycle_id") + module_ids = issue_seed.pop("module_ids") + + issue = Issue.objects.create( + **issue_seed, + state_id=states_map[state_id], + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + ) + IssueSequence.objects.create( + issue=issue, + project_id=project_map[project_id], + workspace_id=workspace.id, + created_by_id=workspace.created_by_id, + ) + + IssueActivity.objects.create( + issue=issue, + project_id=project_map[project_id], + workspace_id=workspace.id, + comment="created the issue", + verb="created", + actor_id=workspace.created_by_id, + epoch=time.time(), + ) + + # Create issue labels + for label_id in labels: + IssueLabel.objects.create( + issue=issue, + label_id=labels_map[label_id], + project_id=project_map[project_id], + workspace_id=workspace.id, + created_by_id=workspace.created_by_id, + ) + + # Create cycle issues + if cycle_id: + CycleIssue.objects.create( + issue=issue, + cycle_id=cycles_map[cycle_id], + project_id=project_map[project_id], + workspace_id=workspace.id, + created_by_id=workspace.created_by_id, + ) + + # Create module issues + if module_ids: + for module_id in module_ids: + ModuleIssue.objects.create( + issue=issue, + module_id=module_map[module_id], + project_id=project_map[project_id], + workspace_id=workspace.id, + created_by_id=workspace.created_by_id, + ) + + logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created") + return + + +def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None: + """Creates pages for each project in the workspace. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + """ + page_seeds = read_seed_file("pages.json") + + if not page_seeds: + return + + for page_seed in page_seeds: + page_id = page_seed.pop("id") + + page = Page.objects.create( + workspace_id=workspace.id, + is_global=False, + access=page_seed.get("access", Page.PUBLIC_ACCESS), + name=page_seed.get("name"), + description=page_seed.get("description", {}), + description_html=page_seed.get("description_html", "

    "), + description_binary=page_seed.get("description_binary", None), + description_stripped=page_seed.get("description_stripped", None), + created_by_id=workspace.created_by_id, + updated_by_id=workspace.created_by_id, + owned_by_id=workspace.created_by_id, + ) + + logger.info(f"Task: workspace_seed_task -> Page {page_id} created") + if page_seed.get("project_id") and page_seed.get("type") == "PROJECT": + ProjectPage.objects.create( + workspace_id=workspace.id, + project_id=project_map[page_seed.get("project_id")], + page_id=page.id, + created_by_id=workspace.created_by_id, + updated_by_id=workspace.created_by_id, + ) + + logger.info(f"Task: workspace_seed_task -> Project Page {page_id} created") + return + + +def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]: + # Create cycles + cycle_seeds = read_seed_file("cycles.json") + if not cycle_seeds: + return + + cycle_map: Dict[int, uuid.UUID] = {} + + for cycle_seed in cycle_seeds: + cycle_id = cycle_seed.pop("id") + project_id = cycle_seed.pop("project_id") + type = cycle_seed.pop("type") + + if type == "CURRENT": + start_date = timezone.now() + end_date = start_date + timedelta(days=14) + + if type == "UPCOMING": + # Get the last cycle + last_cycle = Cycle.objects.filter(project_id=project_map[project_id]).order_by("-end_date").first() + if last_cycle: + start_date = last_cycle.end_date + timedelta(days=1) + end_date = start_date + timedelta(days=14) + else: + start_date = timezone.now() + timedelta(days=14) + end_date = start_date + timedelta(days=14) + + cycle = Cycle.objects.create( + **cycle_seed, + start_date=start_date, + end_date=end_date, + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + owned_by_id=workspace.created_by_id, + ) + + cycle_map[cycle_id] = cycle.id + logger.info(f"Task: workspace_seed_task -> Cycle {cycle_id} created") + return cycle_map + + +def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None: + """Creates modules for each project in the workspace. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + """ + module_seeds = read_seed_file("modules.json") + if not module_seeds: + return + + module_map: Dict[int, uuid.UUID] = {} + + for index, module_seed in enumerate(module_seeds): + module_id = module_seed.pop("id") + project_id = module_seed.pop("project_id") + + start_date = timezone.now() + timedelta(days=index * 2) + end_date = start_date + timedelta(days=14) + + module = Module.objects.create( + **module_seed, + start_date=start_date, + target_date=end_date, + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + ) + module_map[module_id] = module.id + logger.info(f"Task: workspace_seed_task -> Module {module_id} created") + return module_map + + +def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None: + """Creates views for each project in the workspace. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + """ + + view_seeds = read_seed_file("views.json") + if not view_seeds: + return + + for view_seed in view_seeds: + project_id = view_seed.pop("project_id") + IssueView.objects.create( + **view_seed, + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + owned_by_id=workspace.created_by_id, + ) + + +@shared_task +def workspace_seed(workspace_id: uuid.UUID) -> None: + """Seeds a new workspace with initial project data. + + Creates a complete workspace setup including: + - Projects and project members + - Project states + - Project labels + - Issues and their associations + + Args: + workspace_id: ID of the workspace to seed + """ + try: + logger.info(f"Task: workspace_seed_task -> Seeding workspace {workspace_id}") + # Get the workspace + workspace = Workspace.objects.get(id=workspace_id) + + # Create a project with the same name as workspace + project_map = create_project_and_member(workspace) + + # Create project states + state_map = create_project_states(workspace, project_map) + + # Create project labels + label_map = create_project_labels(workspace, project_map) + + # Create project cycles + cycle_map = create_cycles(workspace, project_map) + + # Create project modules + module_map = create_modules(workspace, project_map) + + # create project issues + create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map) + + # create project views + create_views(workspace, project_map) + + # create project pages + create_pages(workspace, project_map) + + logger.info(f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully") + return + except Exception as e: + logger.error(f"Task: workspace_seed_task -> Failed to seed workspace {workspace_id}: {str(e)}") + raise e diff --git a/apps/api/plane/celery.py b/apps/api/plane/celery.py new file mode 100644 index 00000000..828f4a6d --- /dev/null +++ b/apps/api/plane/celery.py @@ -0,0 +1,99 @@ +# Python imports +import os +import logging + +# Third party imports +from celery import Celery +from pythonjsonlogger.jsonlogger import JsonFormatter +from celery.signals import after_setup_logger, after_setup_task_logger +from celery.schedules import crontab + +# Module imports +from plane.settings.redis import redis_instance + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") + +ri = redis_instance() + +app = Celery("plane") + +# Using a string here means the worker will not have to +# pickle the object when using Windows. +app.config_from_object("django.conf:settings", namespace="CELERY") + +app.conf.beat_schedule = { + # Intra day recurring jobs + "check-every-five-minutes-to-send-email-notifications": { + "task": "plane.bgtasks.email_notification_task.stack_email_notification", + "schedule": crontab(minute="*/5"), # Every 5 minutes + }, + "run-every-6-hours-for-instance-trace": { + "task": "plane.license.bgtasks.tracer.instance_traces", + "schedule": crontab(hour="*/6", minute=0), # Every 6 hours + }, + # Occurs once every day + "check-every-day-to-delete-hard-delete": { + "task": "plane.bgtasks.deletion_task.hard_delete", + "schedule": crontab(hour=0, minute=0), # UTC 00:00 + }, + "check-every-day-to-archive-and-close": { + "task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues", + "schedule": crontab(hour=1, minute=0), # UTC 01:00 + }, + "check-every-day-to-delete_exporter_history": { + "task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link", + "schedule": crontab(hour=1, minute=30), # UTC 01:30 + }, + "check-every-day-to-delete-file-asset": { + "task": "plane.bgtasks.file_asset_task.delete_unuploaded_file_asset", + "schedule": crontab(hour=2, minute=0), # UTC 02:00 + }, + "check-every-day-to-delete-api-logs": { + "task": "plane.bgtasks.cleanup_task.delete_api_logs", + "schedule": crontab(hour=2, minute=30), # UTC 02:30 + }, + "check-every-day-to-delete-email-notification-logs": { + "task": "plane.bgtasks.cleanup_task.delete_email_notification_logs", + "schedule": crontab(hour=2, minute=45), # UTC 02:45 + }, + "check-every-day-to-delete-page-versions": { + "task": "plane.bgtasks.cleanup_task.delete_page_versions", + "schedule": crontab(hour=3, minute=0), # UTC 03:00 + }, + "check-every-day-to-delete-issue-description-versions": { + "task": "plane.bgtasks.cleanup_task.delete_issue_description_versions", + "schedule": crontab(hour=3, minute=15), # UTC 03:15 + }, + "check-every-day-to-delete-webhook-logs": { + "task": "plane.bgtasks.cleanup_task.delete_webhook_logs", + "schedule": crontab(hour=3, minute=30), # UTC 03:30 + }, + "check-every-day-to-delete-exporter-history": { + "task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link", + "schedule": crontab(hour=3, minute=45), # UTC 03:45 + }, +} + + +# Setup logging +@after_setup_logger.connect +def setup_loggers(logger, *args, **kwargs): + formatter = JsonFormatter('"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s') + handler = logging.StreamHandler() + handler.setFormatter(fmt=formatter) + logger.addHandler(handler) + + +@after_setup_task_logger.connect +def setup_task_loggers(logger, *args, **kwargs): + formatter = JsonFormatter('"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s') + handler = logging.StreamHandler() + handler.setFormatter(fmt=formatter) + logger.addHandler(handler) + + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() + +app.conf.beat_scheduler = "django_celery_beat.schedulers.DatabaseScheduler" diff --git a/apps/api/plane/db/__init__.py b/apps/api/plane/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/db/apps.py b/apps/api/plane/db/apps.py new file mode 100644 index 00000000..7d4919d0 --- /dev/null +++ b/apps/api/plane/db/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DbConfig(AppConfig): + name = "plane.db" diff --git a/apps/api/plane/db/management/__init__.py b/apps/api/plane/db/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/db/management/commands/__init__.py b/apps/api/plane/db/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/db/management/commands/activate_user.py b/apps/api/plane/db/management/commands/activate_user.py new file mode 100644 index 00000000..5ebe8b74 --- /dev/null +++ b/apps/api/plane/db/management/commands/activate_user.py @@ -0,0 +1,34 @@ +# Django imports +from django.core.management import BaseCommand, CommandError + +# Module imports +from plane.db.models import User + + +class Command(BaseCommand): + help = "Make the user with the given email active" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("email", type=str, help="user email") + + def handle(self, *args, **options): + # get the user email from console + email = options.get("email", False) + + # raise error if email is not present + if not email: + raise CommandError("Error: Email is required") + + # filter the user + user = User.objects.filter(email=email).first() + + # Raise error if the user is not present + if not user: + raise CommandError(f"Error: User with {email} does not exists") + + # Activate the user + user.is_active = True + user.save() + + self.stdout.write(self.style.SUCCESS("User activated successfully")) diff --git a/apps/api/plane/db/management/commands/clear_cache.py b/apps/api/plane/db/management/commands/clear_cache.py new file mode 100644 index 00000000..1c66b3ea --- /dev/null +++ b/apps/api/plane/db/management/commands/clear_cache.py @@ -0,0 +1,26 @@ +# Django imports +from django.core.cache import cache +from django.core.management import BaseCommand + + +class Command(BaseCommand): + help = "Clear Cache before starting the server to remove stale values" + + def add_arguments(self, parser): + # Positional argument + 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']}")) + return + + cache.clear() + self.stdout.write(self.style.SUCCESS("Cache Cleared")) + return + except Exception: + # Another ClientError occurred + self.stdout.write(self.style.ERROR("Failed to clear cache")) + return diff --git a/apps/api/plane/db/management/commands/create_bucket.py b/apps/api/plane/db/management/commands/create_bucket.py new file mode 100644 index 00000000..555fe0aa --- /dev/null +++ b/apps/api/plane/db/management/commands/create_bucket.py @@ -0,0 +1,57 @@ +# Python imports +import os +import boto3 +from botocore.exceptions import ClientError + +# Django imports +from django.core.management import BaseCommand + + +class Command(BaseCommand): + help = "Create the default bucket for the instance" + + def handle(self, *args, **options): + # Create a session using the credentials from Django settings + try: + 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"), + ) + # 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) + # 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 = os.environ.get("AWS_S3_BUCKET_NAME") + if error_code == 404: + # Bucket does not exist, create it + self.stdout.write(self.style.WARNING(f"Bucket '{bucket_name}' does not exist. Creating bucket...")) + try: + s3_client.create_bucket(Bucket=bucket_name) + self.stdout.write(self.style.SUCCESS(f"Bucket '{bucket_name}' created successfully.")) + + # 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}")) + + # Handle the exception if access to the bucket is forbidden + elif error_code == 403: + # Access to the bucket is forbidden + self.stdout.write( + self.style.ERROR(f"Access to the bucket '{bucket_name}' is forbidden. Check permissions.") + ) + else: + # Another ClientError occurred + self.stdout.write(self.style.ERROR(f"Failed to check bucket: {e}")) + except Exception as ex: + # Handle any other exception + self.stdout.write(self.style.ERROR(f"An error occurred: {ex}")) diff --git a/apps/api/plane/db/management/commands/create_dummy_data.py b/apps/api/plane/db/management/commands/create_dummy_data.py new file mode 100644 index 00000000..220576b8 --- /dev/null +++ b/apps/api/plane/db/management/commands/create_dummy_data.py @@ -0,0 +1,70 @@ +# Django imports +from typing import Any +from django.core.management.base import BaseCommand, CommandError + +# Module imports +from plane.db.models import User, Workspace, WorkspaceMember + + +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: ") + + if workspace_slug == "": + raise CommandError("Workspace slug is required") + + if Workspace.objects.filter(slug=workspace_slug).exists(): + raise CommandError("Workspace already exists") + + creator = input("Your email: ") + + if creator == "" or not User.objects.filter(email=creator).exists(): + raise CommandError("User email is required and should have signed in plane") + + user = User.objects.get(email=creator) + + members = input("Enter Member emails (comma separated): ") + members = members.split(",") if members != "" else [] + # Create workspace + workspace = Workspace.objects.create(slug=workspace_slug, name=workspace_name, owner=user) + # Create workspace member + 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) for user_id in user_ids], + ignore_conflicts=True, + ) + + project_count = int(input("Number of projects to be created: ")) + + for i in range(project_count): + print(f"Please provide the following details for project {i + 1}:") + issue_count = int(input("Number of issues to be created: ")) + 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: ")) + intake_issue_count = int(input("Number of intake issues to be created: ")) + + from plane.bgtasks.dummy_data_task import create_dummy_data + + create_dummy_data( + slug=workspace_slug, + email=creator, + members=members, + issue_count=issue_count, + cycle_count=cycle_count, + module_count=module_count, + pages_count=pages_count, + intake_issue_count=intake_issue_count, + ) + + 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)}")) + return diff --git a/apps/api/plane/db/management/commands/create_instance_admin.py b/apps/api/plane/db/management/commands/create_instance_admin.py new file mode 100644 index 00000000..8d5a912e --- /dev/null +++ b/apps/api/plane/db/management/commands/create_instance_admin.py @@ -0,0 +1,39 @@ +# Django imports +from django.core.management.base import BaseCommand, CommandError + +# Module imports +from plane.license.models import Instance, InstanceAdmin +from plane.db.models import User + + +class Command(BaseCommand): + help = "Add a new instance admin" + + def add_arguments(self, parser): + # Positional argument + 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: + raise CommandError("Please provide the email of the admin.") + + user = User.objects.filter(email=admin_email).first() + if user is None: + raise CommandError("User with the provided email does not exist.") + + try: + # Get the instance + instance = Instance.objects.last() + + # Get or create an instance admin + _, created = InstanceAdmin.objects.get_or_create(user=user, instance=instance, role=20) + + if not created: + raise CommandError("The provided email is already an instance 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/apps/api/plane/db/management/commands/create_project_member.py b/apps/api/plane/db/management/commands/create_project_member.py new file mode 100644 index 00000000..d9b46524 --- /dev/null +++ b/apps/api/plane/db/management/commands/create_project_member.py @@ -0,0 +1,77 @@ +# 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/apps/api/plane/db/management/commands/fix_duplicate_sequences.py b/apps/api/plane/db/management/commands/fix_duplicate_sequences.py new file mode 100644 index 00000000..2b262606 --- /dev/null +++ b/apps/api/plane/db/management/commands/fix_duplicate_sequences.py @@ -0,0 +1,91 @@ +# Django imports +from django.core.management.base import BaseCommand, CommandError +from django.db.models import Max +from django.db import connection, transaction + +# Module imports +from plane.db.models import Project, Issue, IssueSequence +from plane.utils.uuid import convert_uuid_to_integer + + +class Command(BaseCommand): + help = "Fix duplicate sequences" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("issue_identifier", type=str, help="Issue Identifier") + + def strict_str_to_int(self, s): + if not s.isdigit() and not (s.startswith("-") and s[1:].isdigit()): + raise ValueError("Invalid integer string") + return int(s) + + def handle(self, *args, **options): + workspace_slug = input("Workspace slug: ") + + if not workspace_slug: + raise CommandError("Workspace slug is required") + + issue_identifier = options.get("issue_identifier", False) + + # Validate issue_identifier + if not issue_identifier: + raise CommandError("Issue identifier is required") + + # Validate issue identifier + try: + identifier = issue_identifier.split("-") + + if len(identifier) != 2: + raise ValueError("Invalid issue identifier format") + + project_identifier = identifier[0] + issue_sequence = self.strict_str_to_int(identifier[1]) + + # Fetch the project + project = Project.objects.get(identifier__iexact=project_identifier, workspace__slug=workspace_slug) + + # Get the issues + issues = Issue.objects.filter(project=project, sequence_id=issue_sequence) + # Check if there are duplicate issues + if not issues.count() > 1: + raise CommandError("No duplicate issues found with the given identifier") + + self.stdout.write(self.style.SUCCESS(f"{issues.count()} issues found with identifier {issue_identifier}")) + with transaction.atomic(): + # This ensures only one transaction per project can execute this code at a time + lock_key = convert_uuid_to_integer(project.id) + + # Acquire an exclusive lock using the project ID as the lock key + with connection.cursor() as cursor: + # Get an exclusive lock using the project ID as the lock key + cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) + + # Get the maximum sequence ID for the project + last_sequence = IssueSequence.objects.filter(project=project).aggregate(largest=Max("sequence"))[ + "largest" + ] + + bulk_issues = [] + bulk_issue_sequences = [] + + issue_sequence_map = {isq.issue_id: isq for isq in IssueSequence.objects.filter(project=project)} + + # change the ids of duplicate issues + for index, issue in enumerate(issues[1:]): + updated_sequence_id = last_sequence + index + 1 + issue.sequence_id = updated_sequence_id + bulk_issues.append(issue) + + # Find the same issue sequence instance from the above queryset + sequence_identifier = issue_sequence_map.get(issue.id) + if sequence_identifier: + sequence_identifier.sequence = updated_sequence_id + bulk_issue_sequences.append(sequence_identifier) + + Issue.objects.bulk_update(bulk_issues, ["sequence_id"]) + IssueSequence.objects.bulk_update(bulk_issue_sequences, ["sequence"]) + + self.stdout.write(self.style.SUCCESS("Sequence IDs updated successfully")) + except Exception as e: + raise CommandError(str(e)) diff --git a/apps/api/plane/db/management/commands/reset_password.py b/apps/api/plane/db/management/commands/reset_password.py new file mode 100644 index 00000000..9e483f51 --- /dev/null +++ b/apps/api/plane/db/management/commands/reset_password.py @@ -0,0 +1,62 @@ +# Python imports +import getpass + +# Django imports +from django.core.management import BaseCommand, CommandError + +# Third party imports +from zxcvbn import zxcvbn + +# Module imports +from plane.db.models import User + + +class Command(BaseCommand): + help = "Reset password of the user with the given email" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("email", type=str, help="user email") + + def handle(self, *args, **options): + # get the user email from console + email = options.get("email", False) + + # raise error if email is not present + if not email: + self.stderr.write("Error: Email is required") + return + + # filter the user + user = User.objects.filter(email=email).first() + + # Raise error if the user is not present + if not user: + self.stderr.write(f"Error: User with {email} does not exists") + return + + # get password for the user + password = getpass.getpass("Password: ") + confirm_password = getpass.getpass("Password (again): ") + + # If the passwords doesn't match raise error + if password != confirm_password: + self.stderr.write("Error: Your passwords didn't match.") + return + + # Blank passwords should not be allowed + if password.strip() == "": + self.stderr.write("Error: Blank passwords aren't allowed.") + return + + results = zxcvbn(password) + + if results["score"] < 3: + 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 successfully")) diff --git a/apps/api/plane/db/management/commands/sync_issue_description_version.py b/apps/api/plane/db/management/commands/sync_issue_description_version.py new file mode 100644 index 00000000..04e608a3 --- /dev/null +++ b/apps/api/plane/db/management/commands/sync_issue_description_version.py @@ -0,0 +1,19 @@ +# Django imports +from django.core.management.base import BaseCommand + +# Module imports +from plane.bgtasks.issue_description_version_sync import ( + schedule_issue_description_version, +) + + +class Command(BaseCommand): + help = "Creates IssueDescriptionVersion records for existing Issues in batches" + + def handle(self, *args, **options): + batch_size = input("Enter the batch size: ") + batch_countdown = input("Enter the batch countdown: ") + + schedule_issue_description_version.delay(batch_size=batch_size, countdown=int(batch_countdown)) + + self.stdout.write(self.style.SUCCESS("Successfully created issue description version task")) diff --git a/apps/api/plane/db/management/commands/sync_issue_version.py b/apps/api/plane/db/management/commands/sync_issue_version.py new file mode 100644 index 00000000..6c9a2cda --- /dev/null +++ b/apps/api/plane/db/management/commands/sync_issue_version.py @@ -0,0 +1,17 @@ +# Django imports +from django.core.management.base import BaseCommand + +# Module imports +from plane.bgtasks.issue_version_sync import schedule_issue_version + + +class Command(BaseCommand): + help = "Creates IssueVersion records for existing Issues in batches" + + def handle(self, *args, **options): + batch_size = input("Enter the batch size: ") + batch_countdown = input("Enter the batch countdown: ") + + schedule_issue_version.delay(batch_size=batch_size, countdown=int(batch_countdown)) + + self.stdout.write(self.style.SUCCESS("Successfully created issue version task")) diff --git a/apps/api/plane/db/management/commands/test_email.py b/apps/api/plane/db/management/commands/test_email.py new file mode 100644 index 00000000..22841a67 --- /dev/null +++ b/apps/api/plane/db/management/commands/test_email.py @@ -0,0 +1,63 @@ +from django.core.mail import EmailMultiAlternatives, get_connection +from django.core.management import BaseCommand, CommandError +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Module imports +from plane.license.utils.instance_value import get_email_configuration + + +class Command(BaseCommand): + """Django command to pause execution until db is available""" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("to_email", type=str, help="receiver's email") + + def handle(self, *args, **options): + receiver_email = options.get("to_email") + + if not receiver_email: + raise CommandError("Receiver email is required") + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + timeout=30, + ) + # Prepare email details + subject = "Test email from Plane" + + html_content = render_to_string("emails/test_email.html") + text_content = strip_tags(html_content) + + self.stdout.write(self.style.SUCCESS("Trying to send test email...")) + + # Send the email + try: + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver_email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + 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}")) diff --git a/apps/api/plane/db/management/commands/update_bucket.py b/apps/api/plane/db/management/commands/update_bucket.py new file mode 100644 index 00000000..47c28ff7 --- /dev/null +++ b/apps/api/plane/db/management/commands/update_bucket.py @@ -0,0 +1,183 @@ +# 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("Couldn'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/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py new file mode 100644 index 00000000..83832535 --- /dev/null +++ b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py @@ -0,0 +1,67 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from plane.db.models import Workspace + + +class Command(BaseCommand): + help = "Updates the slug of a soft-deleted workspace by appending the epoch timestamp" + + def add_arguments(self, parser): + parser.add_argument( + "slug", + type=str, + help="The slug of the workspace to update", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Run the command without making any changes", + ) + + def handle(self, *args, **options): + slug = options["slug"] + dry_run = options["dry_run"] + + # Get the workspace with the specified slug + try: + workspace = Workspace.all_objects.get(slug=slug) + except Workspace.DoesNotExist: + self.stdout.write(self.style.ERROR(f"Workspace with slug '{slug}' not found.")) + return + + # Check if the workspace is soft-deleted + if workspace.deleted_at is None: + self.stdout.write( + self.style.WARNING(f"Workspace '{workspace.name}' (slug: {workspace.slug}) is not deleted.") + ) + return + + # Check if the slug already has a timestamp appended + if "__" in workspace.slug and workspace.slug.split("__")[-1].isdigit(): + self.stdout.write( + self.style.WARNING( + f"Workspace '{workspace.name}' (slug: {workspace.slug}) already has a timestamp appended." + ) + ) + return + + # Get the deletion timestamp + deletion_timestamp = int(workspace.deleted_at.timestamp()) + + # Create the new slug with the deletion timestamp + new_slug = f"{workspace.slug}__{deletion_timestamp}" + + if dry_run: + self.stdout.write(f"Would update workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'") + else: + try: + with transaction.atomic(): + workspace.slug = new_slug + workspace.save(update_fields=["slug"]) + self.stdout.write( + self.style.SUCCESS( + f"Updated workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'" + ) + ) + except Exception as e: + self.stdout.write(self.style.ERROR(f"Error updating workspace '{workspace.name}': {str(e)}")) diff --git a/apps/api/plane/db/management/commands/wait_for_db.py b/apps/api/plane/db/management/commands/wait_for_db.py new file mode 100644 index 00000000..ec971f83 --- /dev/null +++ b/apps/api/plane/db/management/commands/wait_for_db.py @@ -0,0 +1,20 @@ +import time +from django.db import connections +from django.db.utils import OperationalError +from django.core.management import BaseCommand + + +class Command(BaseCommand): + """Django command to pause execution until db is available""" + + def handle(self, *args, **options): + self.stdout.write("Waiting for database...") + db_conn = None + while not db_conn: + try: + db_conn = connections["default"] + except OperationalError: + self.stdout.write("Database unavailable, waititng 1 second...") + time.sleep(1) + + self.stdout.write(self.style.SUCCESS("Database available!")) diff --git a/apps/api/plane/db/management/commands/wait_for_migrations.py b/apps/api/plane/db/management/commands/wait_for_migrations.py new file mode 100644 index 00000000..13b251de --- /dev/null +++ b/apps/api/plane/db/management/commands/wait_for_migrations.py @@ -0,0 +1,22 @@ +# wait_for_migrations.py +import time +from django.core.management.base import BaseCommand +from django.db.migrations.executor import MigrationExecutor +from django.db import connections, DEFAULT_DB_ALIAS + + +class Command(BaseCommand): + help = "Wait for database migrations to complete before starting Celery worker/beat" + + def handle(self, *args, **kwargs): + while self._pending_migrations(): + self.stdout.write("Waiting for database migrations to complete...") + time.sleep(10) # wait for 10 seconds before checking again + + self.stdout.write(self.style.SUCCESS("No migrations Pending. Starting processes ...")) + + def _pending_migrations(self): + connection = connections[DEFAULT_DB_ALIAS] + executor = MigrationExecutor(connection) + targets = executor.loader.graph.leaf_nodes() + return bool(executor.migration_plan(targets)) diff --git a/apps/api/plane/db/migrations/0001_initial.py b/apps/api/plane/db/migrations/0001_initial.py new file mode 100644 index 00000000..936d33fa --- /dev/null +++ b/apps/api/plane/db/migrations/0001_initial.py @@ -0,0 +1,2490 @@ +# Generated by Django 3.2.14 on 2022-10-26 19:37 + +from django.conf import settings +import django.contrib.auth.models +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "password", + models.CharField(max_length=128, verbose_name="password"), + ), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("username", models.CharField(max_length=128, unique=True)), + ( + "mobile_number", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "email", + models.CharField( + blank=True, max_length=255, null=True, unique=True + ), + ), + ("first_name", models.CharField(blank=True, max_length=255)), + ("last_name", models.CharField(blank=True, max_length=255)), + ("avatar", models.CharField(blank=True, max_length=255)), + ( + "date_joined", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "last_location", + models.CharField(blank=True, max_length=255), + ), + ( + "created_location", + models.CharField(blank=True, max_length=255), + ), + ("is_superuser", models.BooleanField(default=False)), + ("is_managed", models.BooleanField(default=False)), + ("is_password_expired", models.BooleanField(default=False)), + ("is_active", models.BooleanField(default=True)), + ("is_staff", models.BooleanField(default=False)), + ("is_email_verified", models.BooleanField(default=False)), + ("is_password_autoset", models.BooleanField(default=False)), + ("is_onboarded", models.BooleanField(default=False)), + ("token", models.CharField(blank=True, max_length=64)), + ( + "billing_address_country", + models.CharField(default="INDIA", max_length=255), + ), + ("billing_address", models.JSONField(null=True)), + ("has_billing_address", models.BooleanField(default=False)), + ( + "user_timezone", + models.CharField(default="Asia/Kolkata", max_length=255), + ), + ( + "last_active", + models.DateTimeField( + default=django.utils.timezone.now, null=True + ), + ), + ("last_login_time", models.DateTimeField(null=True)), + ("last_logout_time", models.DateTimeField(null=True)), + ( + "last_login_ip", + models.CharField(blank=True, max_length=255), + ), + ( + "last_logout_ip", + models.CharField(blank=True, max_length=255), + ), + ( + "last_login_medium", + models.CharField(default="email", max_length=20), + ), + ("last_login_uagent", models.TextField(blank=True)), + ("token_updated_at", models.DateTimeField(null=True)), + ("last_workspace_id", models.UUIDField(null=True)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "User", + "verbose_name_plural": "Users", + "db_table": "user", + "ordering": ("-created_at",), + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name="Cycle", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Cycle Name" + ), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="Cycle Description" + ), + ), + ("start_date", models.DateField(verbose_name="Start Date")), + ("end_date", models.DateField(verbose_name="End Date")), + ( + "status", + models.CharField( + choices=[ + ("started", "Started"), + ("completed", "Completed"), + ], + max_length=255, + verbose_name="Cycle Status", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cycle_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owned_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owned_by_cycle", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Cycle", + "verbose_name_plural": "Cycles", + "db_table": "cycle", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Issue", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Issue Name" + ), + ), + ( + "description", + models.JSONField( + blank=True, verbose_name="Issue Description" + ), + ), + ( + "priority", + models.CharField( + blank=True, + choices=[ + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ], + max_length=30, + null=True, + verbose_name="Issue Priority", + ), + ), + ("start_date", models.DateField(blank=True, null=True)), + ("target_date", models.DateField(blank=True, null=True)), + ( + "sequence_id", + models.IntegerField( + default=1, verbose_name="Issue Sequence ID" + ), + ), + ( + "attachments", + django.contrib.postgres.fields.ArrayField( + base_field=models.URLField(), + blank=True, + default=list, + size=10, + ), + ), + ], + options={ + "verbose_name": "Issue", + "verbose_name_plural": "Issues", + "db_table": "issue", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Project", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Project Name" + ), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="Project Description" + ), + ), + ( + "description_rt", + models.JSONField( + blank=True, + null=True, + verbose_name="Project Description RT", + ), + ), + ( + "description_html", + models.JSONField( + blank=True, + null=True, + verbose_name="Project Description HTML", + ), + ), + ( + "network", + models.PositiveSmallIntegerField( + choices=[(0, "Secret"), (2, "Public")], default=2 + ), + ), + ( + "identifier", + models.CharField( + blank=True, + max_length=5, + null=True, + verbose_name="Project Identifier", + ), + ), + ("slug", models.SlugField(blank=True, max_length=100)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="project_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "default_assignee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="default_assignee", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project_lead", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_lead", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="project_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Project", + "verbose_name_plural": "Projects", + "db_table": "project", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Team", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="Team Name"), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="Team Description" + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="team_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ], + options={ + "verbose_name": "Team", + "verbose_name_plural": "Teams", + "db_table": "team", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Workspace", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Workspace Name" + ), + ), + ( + "logo", + models.URLField( + blank=True, null=True, verbose_name="Logo" + ), + ), + ("slug", models.SlugField(max_length=100, unique=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspace_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owner_workspace", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspace_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Workspace", + "verbose_name_plural": "Workspaces", + "db_table": "workspace", + "ordering": ("-created_at",), + "unique_together": {("name", "owner")}, + }, + ), + migrations.CreateModel( + name="WorkspaceMemberInvite", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("email", models.CharField(max_length=255)), + ("accepted", models.BooleanField(default=False)), + ("token", models.CharField(max_length=255)), + ("message", models.TextField(null=True)), + ("responded_at", models.DateTimeField(null=True)), + ( + "role", + models.PositiveSmallIntegerField( + choices=[ + (20, "Owner"), + (15, "Admin"), + (10, "Member"), + (5, "Guest"), + ], + default=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacememberinvite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacememberinvite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_member_invite", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace Member Invite", + "verbose_name_plural": "Workspace Member Invites", + "db_table": "workspace_member_invite", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="View", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="View Name"), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="View Description" + ), + ), + ("query", models.JSONField(verbose_name="View Query")), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="view_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_view", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="view_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_view", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "View", + "verbose_name_plural": "Views", + "db_table": "view", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="TimelineIssue", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("sequence_id", models.FloatField(default=1.0)), + ("links", models.JSONField(blank=True, default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="timelineissue_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_timeline", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_timelineissue", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="timelineissue_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_timelineissue", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Timeline Issue", + "verbose_name_plural": "Timeline Issues", + "db_table": "issue_timeline", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="TeamMember", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="teammember_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "member", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_member", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_member", + to="db.team", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="teammember_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_member", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Team Member", + "verbose_name_plural": "Team Members", + "db_table": "team_member", + "ordering": ("-created_at",), + "unique_together": {("team", "member")}, + }, + ), + migrations.AddField( + model_name="team", + name="members", + field=models.ManyToManyField( + blank=True, + related_name="members", + through="db.TeamMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="team", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="team_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AddField( + model_name="team", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_team", + to="db.workspace", + ), + ), + migrations.CreateModel( + name="State", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="State Name" + ), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="State Description" + ), + ), + ( + "color", + models.CharField( + max_length=255, verbose_name="State Color" + ), + ), + ("slug", models.SlugField(blank=True, max_length=100)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="state_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_state", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="state_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_state", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "State", + "verbose_name_plural": "States", + "db_table": "state", + "ordering": ("-created_at",), + "unique_together": {("name", "project")}, + }, + ), + migrations.CreateModel( + name="SocialLoginConnection", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "medium", + models.CharField( + choices=[("Google", "google"), ("Github", "github")], + default=None, + max_length=20, + ), + ), + ( + "last_login_at", + models.DateTimeField( + default=django.utils.timezone.now, null=True + ), + ), + ( + "last_received_at", + models.DateTimeField( + default=django.utils.timezone.now, null=True + ), + ), + ("token_data", models.JSONField(null=True)), + ("extra_data", models.JSONField(null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="socialloginconnection_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="socialloginconnection_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_login_connections", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Social Login Connection", + "verbose_name_plural": "Social Login Connections", + "db_table": "social_login_connection", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Shortcut", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Cycle Name" + ), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="Cycle Description" + ), + ), + ( + "type", + models.CharField( + choices=[("repo", "Repo"), ("direct", "Direct")], + max_length=255, + verbose_name="Shortcut Type", + ), + ), + ( + "url", + models.URLField(blank=True, null=True, verbose_name="URL"), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="shortcut_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_shortcut", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="shortcut_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_shortcut", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Shortcut", + "verbose_name_plural": "Shortcuts", + "db_table": "shortcut", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="ProjectMemberInvite", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("email", models.CharField(max_length=255)), + ("accepted", models.BooleanField(default=False)), + ("token", models.CharField(max_length=255)), + ("message", models.TextField(null=True)), + ("responded_at", models.DateTimeField(null=True)), + ( + "role", + models.PositiveSmallIntegerField( + choices=[ + (20, "Admin"), + (15, "Member"), + (10, "Viewer"), + (5, "Guest"), + ], + default=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectmemberinvite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_projectmemberinvite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectmemberinvite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_projectmemberinvite", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Member Invite", + "verbose_name_plural": "Project Member Invites", + "db_table": "project_member_invite", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="ProjectIdentifier", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ("name", models.CharField(max_length=10)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectidentifier_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_identifier", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectidentifier_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Project Identifier", + "verbose_name_plural": "Project Identifiers", + "db_table": "project_identifier", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="project", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_project", + to="db.workspace", + ), + ), + migrations.CreateModel( + name="Label", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="label_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_label", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="label_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_label", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Label", + "verbose_name_plural": "Labels", + "db_table": "label", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="IssueSequence", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("sequence", models.PositiveBigIntegerField(default=1)), + ("deleted", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuesequence_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_sequence", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issuesequence", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuesequence_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issuesequence", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Sequence", + "verbose_name_plural": "Issue Sequences", + "db_table": "issue_sequence", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="IssueProperty", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("properties", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueproperty_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueproperty", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueproperty_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_property_user", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueproperty", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Property", + "verbose_name_plural": "Issue Properties", + "db_table": "issue_property", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="IssueLabel", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuelabel_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="label_issue", + to="db.issue", + ), + ), + ( + "label", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="label_issue", + to="db.label", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issuelabel", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuelabel_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issuelabel", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Label", + "verbose_name_plural": "Issue Labels", + "db_table": "issue_label", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="IssueComment", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "comment", + models.TextField(blank=True, verbose_name="Comment"), + ), + ( + "attachments", + django.contrib.postgres.fields.ArrayField( + base_field=models.URLField(), + blank=True, + default=list, + size=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuecomment_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issuecomment", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuecomment_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issuecomment", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Comment", + "verbose_name_plural": "Issue Comments", + "db_table": "issue_comment", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="IssueBlocker", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "block", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="blocker_issues", + to="db.issue", + ), + ), + ( + "blocked_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="blocked_issues", + to="db.issue", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueblocker_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueblocker", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueblocker_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueblocker", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Blocker", + "verbose_name_plural": "Issue Blockers", + "db_table": "issue_blocker", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="IssueAssignee", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "assignee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_assignee", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueassignee_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_assignee", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueassignee", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueassignee_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueassignee", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Assignee", + "verbose_name_plural": "Issue Assignees", + "db_table": "issue_assignee", + "ordering": ("-created_at",), + "unique_together": {("issue", "assignee")}, + }, + ), + migrations.CreateModel( + name="IssueActivity", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "verb", + models.CharField( + default="created", + max_length=255, + verbose_name="Action", + ), + ), + ( + "field", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Field Name", + ), + ), + ( + "old_value", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Old Value", + ), + ), + ( + "new_value", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="New Value", + ), + ), + ( + "comment", + models.TextField(blank=True, verbose_name="Comment"), + ), + ( + "attachments", + django.contrib.postgres.fields.ArrayField( + base_field=models.URLField(), + blank=True, + default=list, + size=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueactivity_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_activity", + to="db.issue", + ), + ), + ( + "issue_comment", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_comment", + to="db.issuecomment", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueactivity", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueactivity_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueactivity", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Activity", + "verbose_name_plural": "Issue Activities", + "db_table": "issue_activity", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="issue", + name="assignees", + field=models.ManyToManyField( + blank=True, + related_name="assignee", + through="db.IssueAssignee", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="issue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AddField( + model_name="issue", + name="labels", + field=models.ManyToManyField( + blank=True, + related_name="labels", + through="db.IssueLabel", + to="db.Label", + ), + ), + migrations.AddField( + model_name="issue", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="parent_issue", + to="db.issue", + ), + ), + migrations.AddField( + model_name="issue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issue", + to="db.project", + ), + ), + migrations.AddField( + model_name="issue", + name="state", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="state_issue", + to="db.state", + ), + ), + migrations.AddField( + model_name="issue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AddField( + model_name="issue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issue", + to="db.workspace", + ), + ), + migrations.CreateModel( + name="FileAsset", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("attributes", models.JSONField(default=dict)), + ("asset", models.FileField(upload_to="library-assets")), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="fileasset_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="fileasset_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "File Asset", + "verbose_name_plural": "File Assets", + "db_table": "file_asset", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="CycleIssue", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cycleissue_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "cycle", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_cycle", + to="db.cycle", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_cycle", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_cycleissue", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cycleissue_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_cycleissue", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Cycle Issue", + "verbose_name_plural": "Cycle Issues", + "db_table": "cycle_issue", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="cycle", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_cycle", + to="db.project", + ), + ), + migrations.AddField( + model_name="cycle", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cycle_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AddField( + model_name="cycle", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_cycle", + to="db.workspace", + ), + ), + migrations.CreateModel( + name="WorkspaceMember", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "role", + models.PositiveSmallIntegerField( + choices=[ + (20, "Owner"), + (15, "Admin"), + (10, "Member"), + (5, "Guest"), + ], + default=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacemember_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "member", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="member_workspace", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacemember_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_member", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace Member", + "verbose_name_plural": "Workspace Members", + "db_table": "workspace_member", + "ordering": ("-created_at",), + "unique_together": {("workspace", "member")}, + }, + ), + migrations.AlterUniqueTogether( + name="team", + unique_together={("name", "workspace")}, + ), + migrations.CreateModel( + name="ProjectMember", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("comment", models.TextField(blank=True, null=True)), + ( + "role", + models.PositiveSmallIntegerField( + choices=[ + (20, "Admin"), + (15, "Member"), + (10, "Viewer"), + (5, "Guest"), + ], + default=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectmember_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "member", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="member_project", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_projectmember", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectmember_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_projectmember", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Member", + "verbose_name_plural": "Project Members", + "db_table": "project_member", + "ordering": ("-created_at",), + "unique_together": {("project", "member")}, + }, + ), + migrations.AlterUniqueTogether( + name="project", + unique_together={("name", "workspace")}, + ), + ] diff --git a/apps/api/plane/db/migrations/0002_auto_20221104_2239.py b/apps/api/plane/db/migrations/0002_auto_20221104_2239.py new file mode 100644 index 00000000..d69ef1a7 --- /dev/null +++ b/apps/api/plane/db/migrations/0002_auto_20221104_2239.py @@ -0,0 +1,71 @@ +# Generated by Django 3.2.14 on 2022-11-04 17:09 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="state", + options={ + "ordering": ("sequence",), + "verbose_name": "State", + "verbose_name_plural": "States", + }, + ), + migrations.RenameField( + model_name="project", + old_name="description_rt", + new_name="description_text", + ), + migrations.AddField( + model_name="issueactivity", + name="actor", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_activities", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="issuecomment", + name="actor", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="state", + name="sequence", + field=models.PositiveIntegerField(default=65535), + ), + migrations.AddField( + model_name="workspace", + name="company_size", + field=models.PositiveIntegerField(default=10), + ), + migrations.AddField( + model_name="workspacemember", + name="company_role", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="cycleissue", + name="issue", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_cycle", + to="db.issue", + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0003_auto_20221109_2320.py b/apps/api/plane/db/migrations/0003_auto_20221109_2320.py new file mode 100644 index 00000000..763d52eb --- /dev/null +++ b/apps/api/plane/db/migrations/0003_auto_20221109_2320.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.14 on 2022-11-09 17:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0002_auto_20221104_2239"), + ] + + operations = [ + migrations.AlterField( + model_name="issueproperty", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_property_user", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterUniqueTogether( + name="issueproperty", + unique_together={("user", "project")}, + ), + ] diff --git a/apps/api/plane/db/migrations/0004_alter_state_sequence.py b/apps/api/plane/db/migrations/0004_alter_state_sequence.py new file mode 100644 index 00000000..f3489449 --- /dev/null +++ b/apps/api/plane/db/migrations/0004_alter_state_sequence.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.14 on 2022-11-10 19:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0003_auto_20221109_2320"), + ] + + operations = [ + migrations.AlterField( + model_name="state", + name="sequence", + field=models.FloatField(default=65535), + ), + ] diff --git a/apps/api/plane/db/migrations/0005_auto_20221114_2127.py b/apps/api/plane/db/migrations/0005_auto_20221114_2127.py new file mode 100644 index 00000000..8ab63a22 --- /dev/null +++ b/apps/api/plane/db/migrations/0005_auto_20221114_2127.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.14 on 2022-11-14 15:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0004_alter_state_sequence"), + ] + + operations = [ + migrations.AlterField( + model_name="cycle", + name="end_date", + field=models.DateField( + blank=True, null=True, verbose_name="End Date" + ), + ), + migrations.AlterField( + model_name="cycle", + name="start_date", + field=models.DateField( + blank=True, null=True, verbose_name="Start Date" + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0006_alter_cycle_status.py b/apps/api/plane/db/migrations/0006_alter_cycle_status.py new file mode 100644 index 00000000..3121f4fe --- /dev/null +++ b/apps/api/plane/db/migrations/0006_alter_cycle_status.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.14 on 2022-11-16 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0005_auto_20221114_2127"), + ] + + operations = [ + migrations.AlterField( + model_name="cycle", + name="status", + field=models.CharField( + choices=[ + ("draft", "Draft"), + ("started", "Started"), + ("completed", "Completed"), + ], + default="draft", + max_length=255, + verbose_name="Cycle Status", + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0007_label_parent.py b/apps/api/plane/db/migrations/0007_label_parent.py new file mode 100644 index 00000000..6e67a3c9 --- /dev/null +++ b/apps/api/plane/db/migrations/0007_label_parent.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.14 on 2022-11-28 20:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0006_alter_cycle_status"), + ] + + operations = [ + migrations.AddField( + model_name="label", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="parent_label", + to="db.label", + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0008_label_colour.py b/apps/api/plane/db/migrations/0008_label_colour.py new file mode 100644 index 00000000..3ca6b91c --- /dev/null +++ b/apps/api/plane/db/migrations/0008_label_colour.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.14 on 2022-11-29 19:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0007_label_parent"), + ] + + operations = [ + migrations.AddField( + model_name="label", + name="colour", + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/apps/api/plane/db/migrations/0009_auto_20221208_0310.py b/apps/api/plane/db/migrations/0009_auto_20221208_0310.py new file mode 100644 index 00000000..829baaa6 --- /dev/null +++ b/apps/api/plane/db/migrations/0009_auto_20221208_0310.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.14 on 2022-12-13 17:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0008_label_colour"), + ] + + operations = [ + migrations.AddField( + model_name="projectmember", + name="view_props", + field=models.JSONField(null=True), + ), + migrations.AddField( + model_name="state", + name="group", + field=models.CharField( + choices=[ + ("backlog", "Backlog"), + ("unstarted", "Unstarted"), + ("started", "Started"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + default="backlog", + max_length=20, + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0010_auto_20221213_0037.py b/apps/api/plane/db/migrations/0010_auto_20221213_0037.py new file mode 100644 index 00000000..1672a10a --- /dev/null +++ b/apps/api/plane/db/migrations/0010_auto_20221213_0037.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.14 on 2022-12-13 18:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0009_auto_20221208_0310"), + ] + + operations = [ + migrations.AddField( + model_name="projectidentifier", + name="workspace", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_identifiers", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="project", + name="identifier", + field=models.CharField( + max_length=5, verbose_name="Project Identifier" + ), + ), + migrations.AlterUniqueTogether( + name="project", + unique_together={ + ("name", "workspace"), + ("identifier", "workspace"), + }, + ), + migrations.AlterUniqueTogether( + name="projectidentifier", + unique_together={("name", "workspace")}, + ), + ] diff --git a/apps/api/plane/db/migrations/0011_auto_20221222_2357.py b/apps/api/plane/db/migrations/0011_auto_20221222_2357.py new file mode 100644 index 00000000..b52df301 --- /dev/null +++ b/apps/api/plane/db/migrations/0011_auto_20221222_2357.py @@ -0,0 +1,348 @@ +# Generated by Django 3.2.14 on 2022-12-22 18:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.project +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0010_auto_20221213_0037"), + ] + + operations = [ + migrations.CreateModel( + name="Module", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Module Name" + ), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="Module Description" + ), + ), + ( + "description_text", + models.JSONField( + blank=True, + null=True, + verbose_name="Module Description RT", + ), + ), + ( + "description_html", + models.JSONField( + blank=True, + null=True, + verbose_name="Module Description HTML", + ), + ), + ("start_date", models.DateField(null=True)), + ("target_date", models.DateField(null=True)), + ( + "status", + models.CharField( + choices=[ + ("backlog", "Backlog"), + ("planned", "Planned"), + ("in-progress", "In Progress"), + ("paused", "Paused"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + default="planned", + max_length=20, + ), + ), + ], + options={ + "verbose_name": "Module", + "verbose_name_plural": "Modules", + "db_table": "module", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="project", + name="icon", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="projectmember", + name="default_props", + field=models.JSONField( + default=plane.db.models.project.get_default_props + ), + ), + migrations.AddField( + model_name="user", + name="my_issues_prop", + field=models.JSONField(null=True), + ), + migrations.CreateModel( + name="ModuleMember", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulemember_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "member", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="db.module", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_modulemember", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulemember_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_modulemember", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Module Member", + "verbose_name_plural": "Module Members", + "db_table": "module_member", + "ordering": ("-created_at",), + "unique_together": {("module", "member")}, + }, + ), + migrations.AddField( + model_name="module", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="module_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AddField( + model_name="module", + name="lead", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="module_leads", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="module", + name="members", + field=models.ManyToManyField( + blank=True, + related_name="module_members", + through="db.ModuleMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="module", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_module", + to="db.project", + ), + ), + migrations.AddField( + model_name="module", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="module_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AddField( + model_name="module", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_module", + to="db.workspace", + ), + ), + migrations.CreateModel( + name="ModuleIssue", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="moduleissue_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_module", + to="db.issue", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_module", + to="db.module", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_moduleissue", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="moduleissue_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_moduleissue", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Module Issue", + "verbose_name_plural": "Module Issues", + "db_table": "module_issues", + "ordering": ("-created_at",), + "unique_together": {("module", "issue")}, + }, + ), + migrations.AlterUniqueTogether( + name="module", + unique_together={("name", "project")}, + ), + ] diff --git a/apps/api/plane/db/migrations/0012_auto_20230104_0117.py b/apps/api/plane/db/migrations/0012_auto_20230104_0117.py new file mode 100644 index 00000000..bc767dd5 --- /dev/null +++ b/apps/api/plane/db/migrations/0012_auto_20230104_0117.py @@ -0,0 +1,234 @@ +# Generated by Django 3.2.16 on 2023-01-03 19:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0011_auto_20221222_2357"), + ] + + operations = [ + migrations.AddField( + model_name="issueactivity", + name="new_identifier", + field=models.UUIDField(null=True), + ), + migrations.AddField( + model_name="issueactivity", + name="old_identifier", + field=models.UUIDField(null=True), + ), + migrations.AlterField( + model_name="moduleissue", + name="issue", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_module", + to="db.issue", + ), + ), + migrations.AlterUniqueTogether( + name="moduleissue", + unique_together=set(), + ), + migrations.AlterModelTable( + name="cycle", + table="cycles", + ), + migrations.AlterModelTable( + name="cycleissue", + table="cycle_issues", + ), + migrations.AlterModelTable( + name="fileasset", + table="file_assets", + ), + migrations.AlterModelTable( + name="issue", + table="issues", + ), + migrations.AlterModelTable( + name="issueactivity", + table="issue_activities", + ), + migrations.AlterModelTable( + name="issueassignee", + table="issue_assignees", + ), + migrations.AlterModelTable( + name="issueblocker", + table="issue_blockers", + ), + migrations.AlterModelTable( + name="issuecomment", + table="issue_comments", + ), + migrations.AlterModelTable( + name="issuelabel", + table="issue_labels", + ), + migrations.AlterModelTable( + name="issueproperty", + table="issue_properties", + ), + migrations.AlterModelTable( + name="issuesequence", + table="issue_sequences", + ), + migrations.AlterModelTable( + name="label", + table="labels", + ), + migrations.AlterModelTable( + name="module", + table="modules", + ), + migrations.AlterModelTable( + name="modulemember", + table="module_members", + ), + migrations.AlterModelTable( + name="project", + table="projects", + ), + migrations.AlterModelTable( + name="projectidentifier", + table="project_identifiers", + ), + migrations.AlterModelTable( + name="projectmember", + table="project_members", + ), + migrations.AlterModelTable( + name="projectmemberinvite", + table="project_member_invites", + ), + migrations.AlterModelTable( + name="shortcut", + table="shortcuts", + ), + migrations.AlterModelTable( + name="socialloginconnection", + table="social_login_connections", + ), + migrations.AlterModelTable( + name="state", + table="states", + ), + migrations.AlterModelTable( + name="team", + table="teams", + ), + migrations.AlterModelTable( + name="teammember", + table="team_members", + ), + migrations.AlterModelTable( + name="timelineissue", + table="issue_timelines", + ), + migrations.AlterModelTable( + name="user", + table="users", + ), + migrations.AlterModelTable( + name="view", + table="views", + ), + migrations.AlterModelTable( + name="workspace", + table="workspaces", + ), + migrations.AlterModelTable( + name="workspacemember", + table="workspace_members", + ), + migrations.AlterModelTable( + name="workspacememberinvite", + table="workspace_member_invites", + ), + migrations.CreateModel( + name="ModuleLink", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(max_length=255, null=True)), + ("url", models.URLField()), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulelink_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="link_module", + to="db.module", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_modulelink", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulelink_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_modulelink", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Module Link", + "verbose_name_plural": "Module Links", + "db_table": "module_links", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0013_auto_20230107_0041.py b/apps/api/plane/db/migrations/0013_auto_20230107_0041.py new file mode 100644 index 00000000..786e6cb5 --- /dev/null +++ b/apps/api/plane/db/migrations/0013_auto_20230107_0041.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.16 on 2023-01-06 19:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0012_auto_20230104_0117"), + ] + + operations = [ + migrations.AddField( + model_name="issue", + name="description_html", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="issue", + name="description_stripped", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="user", + name="role", + field=models.CharField(blank=True, max_length=300, null=True), + ), + migrations.AddField( + model_name="workspacemember", + name="view_props", + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name="issue", + name="description", + field=models.JSONField(blank=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py b/apps/api/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py new file mode 100644 index 00000000..5642ae15 --- /dev/null +++ b/apps/api/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.16 on 2023-01-07 05:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0013_auto_20230107_0041"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="workspacememberinvite", + unique_together={("email", "workspace")}, + ), + ] diff --git a/apps/api/plane/db/migrations/0015_auto_20230107_1636.py b/apps/api/plane/db/migrations/0015_auto_20230107_1636.py new file mode 100644 index 00000000..903c78b0 --- /dev/null +++ b/apps/api/plane/db/migrations/0015_auto_20230107_1636.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.16 on 2023-01-07 11:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0014_alter_workspacememberinvite_unique_together"), + ] + + operations = [ + migrations.RenameField( + model_name="issuecomment", + old_name="comment", + new_name="comment_stripped", + ), + migrations.AddField( + model_name="issuecomment", + name="comment_html", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="issuecomment", + name="comment_json", + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0016_auto_20230107_1735.py b/apps/api/plane/db/migrations/0016_auto_20230107_1735.py new file mode 100644 index 00000000..a22dc9a6 --- /dev/null +++ b/apps/api/plane/db/migrations/0016_auto_20230107_1735.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.16 on 2023-01-07 12:05 + +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.asset + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0015_auto_20230107_1636"), + ] + + operations = [ + migrations.AddField( + model_name="fileasset", + name="workspace", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="fileasset", + name="asset", + field=models.FileField( + upload_to=plane.db.models.asset.get_upload_path, + validators=[plane.db.models.asset.file_size], + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0017_alter_workspace_unique_together.py b/apps/api/plane/db/migrations/0017_alter_workspace_unique_together.py new file mode 100644 index 00000000..1ab721a3 --- /dev/null +++ b/apps/api/plane/db/migrations/0017_alter_workspace_unique_together.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.16 on 2023-01-07 17:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0016_auto_20230107_1735"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="workspace", + unique_together=set(), + ), + ] diff --git a/apps/api/plane/db/migrations/0018_auto_20230130_0119.py b/apps/api/plane/db/migrations/0018_auto_20230130_0119.py new file mode 100644 index 00000000..32f88653 --- /dev/null +++ b/apps/api/plane/db/migrations/0018_auto_20230130_0119.py @@ -0,0 +1,119 @@ +# Generated by Django 3.2.16 on 2023-01-29 19:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.api +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0017_alter_workspace_unique_together"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_bot", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="issue", + name="description", + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name="issue", + name="description_html", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="issue", + name="description_stripped", + field=models.TextField(blank=True, null=True), + ), + migrations.CreateModel( + name="APIToken", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "token", + models.CharField( + default=plane.db.models.api.generate_token, + max_length=255, + unique=True, + ), + ), + ( + "label", + models.CharField( + default=plane.db.models.api.generate_label_token, + max_length=255, + ), + ), + ( + "user_type", + models.PositiveSmallIntegerField( + choices=[(0, "Human"), (1, "Bot")], default=0 + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="apitoken_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="apitoken_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bot_tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "API Token", + "verbose_name_plural": "API Tokems", + "db_table": "api_tokens", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0019_auto_20230131_0049.py b/apps/api/plane/db/migrations/0019_auto_20230131_0049.py new file mode 100644 index 00000000..63545f49 --- /dev/null +++ b/apps/api/plane/db/migrations/0019_auto_20230131_0049.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.16 on 2023-01-30 19:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0018_auto_20230130_0119"), + ] + + operations = [ + migrations.AlterField( + model_name="issueactivity", + name="new_value", + field=models.TextField( + blank=True, null=True, verbose_name="New Value" + ), + ), + migrations.AlterField( + model_name="issueactivity", + name="old_value", + field=models.TextField( + blank=True, null=True, verbose_name="Old Value" + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0020_auto_20230214_0118.py b/apps/api/plane/db/migrations/0020_auto_20230214_0118.py new file mode 100644 index 00000000..4269f53b --- /dev/null +++ b/apps/api/plane/db/migrations/0020_auto_20230214_0118.py @@ -0,0 +1,73 @@ +# Generated by Django 3.2.16 on 2023-02-13 19:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0019_auto_20230131_0049"), + ] + + operations = [ + migrations.RenameField( + model_name="label", + old_name="colour", + new_name="color", + ), + migrations.AddField( + model_name="apitoken", + name="workspace", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="api_tokens", + to="db.workspace", + ), + ), + migrations.AddField( + model_name="issue", + name="completed_at", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="issue", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.AddField( + model_name="project", + name="cycle_view", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="project", + name="module_view", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="state", + name="default", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="issue", + name="description", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name="issue", + name="description_html", + field=models.TextField(blank=True, default="

    "), + ), + migrations.AlterField( + model_name="issuecomment", + name="comment_html", + field=models.TextField(blank=True, default="

    "), + ), + migrations.AlterField( + model_name="issuecomment", + name="comment_json", + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/apps/api/plane/db/migrations/0021_auto_20230223_0104.py b/apps/api/plane/db/migrations/0021_auto_20230223_0104.py new file mode 100644 index 00000000..0dc052c2 --- /dev/null +++ b/apps/api/plane/db/migrations/0021_auto_20230223_0104.py @@ -0,0 +1,622 @@ +# Generated by Django 3.2.16 on 2023-02-22 19:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0020_auto_20230214_0118"), + ] + + operations = [ + migrations.CreateModel( + name="GithubRepository", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=500)), + ("url", models.URLField(null=True)), + ("config", models.JSONField(default=dict)), + ("repository_id", models.BigIntegerField()), + ("owner", models.CharField(max_length=500)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubrepository_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_githubrepository", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubrepository_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_githubrepository", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Repository", + "verbose_name_plural": "Repositories", + "db_table": "github_repositories", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Integration", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(max_length=400)), + ("provider", models.CharField(max_length=400, unique=True)), + ( + "network", + models.PositiveIntegerField( + choices=[(1, "Private"), (2, "Public")], default=1 + ), + ), + ("description", models.JSONField(default=dict)), + ("author", models.CharField(blank=True, max_length=400)), + ("webhook_url", models.TextField(blank=True)), + ("webhook_secret", models.TextField(blank=True)), + ("redirect_url", models.TextField(blank=True)), + ("metadata", models.JSONField(default=dict)), + ("verified", models.BooleanField(default=False)), + ("avatar_url", models.URLField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="integration_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="integration_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Integration", + "verbose_name_plural": "Integrations", + "db_table": "integrations", + "ordering": ("-created_at",), + }, + ), + migrations.AlterField( + model_name="issueactivity", + name="issue", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_activity", + to="db.issue", + ), + ), + migrations.CreateModel( + name="WorkspaceIntegration", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("metadata", models.JSONField(default=dict)), + ("config", models.JSONField(default=dict)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="integrations", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "api_token", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="integrations", + to="db.apitoken", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspaceintegration_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "integration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="integrated_workspaces", + to="db.integration", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspaceintegration_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_integrations", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace Integration", + "verbose_name_plural": "Workspace Integrations", + "db_table": "workspace_integrations", + "ordering": ("-created_at",), + "unique_together": {("workspace", "integration")}, + }, + ), + migrations.CreateModel( + name="IssueLink", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(max_length=255, null=True)), + ("url", models.URLField()), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuelink_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_link", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issuelink", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuelink_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_issuelink", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Link", + "verbose_name_plural": "Issue Links", + "db_table": "issue_links", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="GithubRepositorySync", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("credentials", models.JSONField(default=dict)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_syncs", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubrepositorysync_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "label", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="repo_syncs", + to="db.label", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_githubrepositorysync", + to="db.project", + ), + ), + ( + "repository", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="syncs", + to="db.githubrepository", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubrepositorysync_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_githubrepositorysync", + to="db.workspace", + ), + ), + ( + "workspace_integration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="github_syncs", + to="db.workspaceintegration", + ), + ), + ], + options={ + "verbose_name": "Github Repository Sync", + "verbose_name_plural": "Github Repository Syncs", + "db_table": "github_repository_syncs", + "ordering": ("-created_at",), + "unique_together": {("project", "repository")}, + }, + ), + migrations.CreateModel( + name="GithubIssueSync", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("repo_issue_id", models.BigIntegerField()), + ("github_issue_id", models.BigIntegerField()), + ("issue_url", models.URLField()), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubissuesync_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="github_syncs", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_githubissuesync", + to="db.project", + ), + ), + ( + "repository_sync", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_syncs", + to="db.githubrepositorysync", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubissuesync_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_githubissuesync", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Github Issue Sync", + "verbose_name_plural": "Github Issue Syncs", + "db_table": "github_issue_syncs", + "ordering": ("-created_at",), + "unique_together": {("repository_sync", "issue")}, + }, + ), + migrations.CreateModel( + name="GithubCommentSync", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("repo_comment_id", models.BigIntegerField()), + ( + "comment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comment_syncs", + to="db.issuecomment", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubcommentsync_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue_sync", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comment_syncs", + to="db.githubissuesync", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_githubcommentsync", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubcommentsync_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_githubcommentsync", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Github Comment Sync", + "verbose_name_plural": "Github Comment Syncs", + "db_table": "github_comment_syncs", + "ordering": ("-created_at",), + "unique_together": {("issue_sync", "comment")}, + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0022_auto_20230307_0304.py b/apps/api/plane/db/migrations/0022_auto_20230307_0304.py new file mode 100644 index 00000000..69bd577d --- /dev/null +++ b/apps/api/plane/db/migrations/0022_auto_20230307_0304.py @@ -0,0 +1,291 @@ +# Generated by Django 3.2.16 on 2023-03-06 21:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0021_auto_20230223_0104"), + ] + + operations = [ + migrations.RemoveField( + model_name="cycle", + name="status", + ), + migrations.RemoveField( + model_name="project", + name="slug", + ), + migrations.AddField( + model_name="issuelink", + name="metadata", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="modulelink", + name="metadata", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="project", + name="cover_image", + field=models.URLField(blank=True, null=True), + ), + migrations.CreateModel( + name="ProjectFavorite", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectfavorite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_projectfavorite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectfavorite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_projectfavorite", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Favorite", + "verbose_name_plural": "Project Favorites", + "db_table": "project_favorites", + "ordering": ("-created_at",), + "unique_together": {("project", "user")}, + }, + ), + migrations.CreateModel( + name="ModuleFavorite", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulefavorite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="module_favorites", + to="db.module", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_modulefavorite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulefavorite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="module_favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_modulefavorite", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Module Favorite", + "verbose_name_plural": "Module Favorites", + "db_table": "module_favorites", + "ordering": ("-created_at",), + "unique_together": {("module", "user")}, + }, + ), + migrations.CreateModel( + name="CycleFavorite", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cyclefavorite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "cycle", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cycle_favorites", + to="db.cycle", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_cyclefavorite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cyclefavorite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cycle_favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_cyclefavorite", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Cycle Favorite", + "verbose_name_plural": "Cycle Favorites", + "db_table": "cycle_favorites", + "ordering": ("-created_at",), + "unique_together": {("cycle", "user")}, + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0023_auto_20230316_0040.py b/apps/api/plane/db/migrations/0023_auto_20230316_0040.py new file mode 100644 index 00000000..6f6103ca --- /dev/null +++ b/apps/api/plane/db/migrations/0023_auto_20230316_0040.py @@ -0,0 +1,305 @@ +# Generated by Django 3.2.16 on 2023-03-15 19:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0022_auto_20230307_0304"), + ] + + operations = [ + migrations.CreateModel( + name="Importer", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "service", + models.CharField( + choices=[("github", "GitHub")], max_length=50 + ), + ), + ( + "status", + models.CharField( + choices=[ + ("queued", "Queued"), + ("processing", "Processing"), + ("completed", "Completed"), + ("failed", "Failed"), + ], + default="queued", + max_length=50, + ), + ), + ("metadata", models.JSONField(default=dict)), + ("config", models.JSONField(default=dict)), + ("data", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="importer_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "initiated_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="imports", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_importer", + to="db.project", + ), + ), + ( + "token", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="importer", + to="db.apitoken", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="importer_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_importer", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Importer", + "verbose_name_plural": "Importers", + "db_table": "importers", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="IssueView", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="View Name"), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="View Description" + ), + ), + ("query", models.JSONField(verbose_name="View Query")), + ( + "access", + models.PositiveSmallIntegerField( + choices=[(0, "Private"), (1, "Public")], default=1 + ), + ), + ("query_data", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueview_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueview", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueview_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_issueview", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue View", + "verbose_name_plural": "Issue Views", + "db_table": "issue_views", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="IssueViewFavorite", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueviewfavorite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueviewfavorite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueviewfavorite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_view_favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "view", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="view_favorites", + to="db.issueview", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueviewfavorite", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "View Favorite", + "verbose_name_plural": "View Favorites", + "db_table": "view_favorites", + "ordering": ("-created_at",), + "unique_together": {("view", "user")}, + }, + ), + migrations.AlterUniqueTogether( + name="label", + unique_together={("name", "project")}, + ), + migrations.DeleteModel( + name="View", + ), + ] diff --git a/apps/api/plane/db/migrations/0024_auto_20230322_0138.py b/apps/api/plane/db/migrations/0024_auto_20230322_0138.py new file mode 100644 index 00000000..7a95d519 --- /dev/null +++ b/apps/api/plane/db/migrations/0024_auto_20230322_0138.py @@ -0,0 +1,314 @@ +# Generated by Django 3.2.16 on 2023-03-21 20:08 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0023_auto_20230316_0040"), + ] + + operations = [ + migrations.CreateModel( + name="Page", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.JSONField(blank=True, default=dict)), + ( + "description_html", + models.TextField(blank=True, default="

    "), + ), + ( + "description_stripped", + models.TextField(blank=True, null=True), + ), + ( + "access", + models.PositiveSmallIntegerField( + choices=[(0, "Public"), (1, "Private")], default=0 + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="page_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owned_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pages", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Page", + "verbose_name_plural": "Pages", + "db_table": "pages", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="project", + name="issue_views_view", + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name="importer", + name="service", + field=models.CharField( + choices=[("github", "GitHub"), ("jira", "Jira")], max_length=50 + ), + ), + migrations.AlterField( + model_name="project", + name="cover_image", + field=models.URLField(blank=True, max_length=800, null=True), + ), + migrations.CreateModel( + name="PageBlock", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.JSONField(blank=True, default=dict)), + ( + "description_html", + models.TextField(blank=True, default="

    "), + ), + ( + "description_stripped", + models.TextField(blank=True, null=True), + ), + ("completed_at", models.DateTimeField(null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pageblock_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="blocks", + to="db.issue", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="blocks", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pageblock", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pageblock_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_pageblock", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Page Block", + "verbose_name_plural": "Page Blocks", + "db_table": "page_blocks", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="page", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_page", + to="db.project", + ), + ), + migrations.AddField( + model_name="page", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="page_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AddField( + model_name="page", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_page", + to="db.workspace", + ), + ), + migrations.CreateModel( + name="PageFavorite", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pagefavorite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="page_favorites", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pagefavorite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pagefavorite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="page_favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_pagefavorite", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Page Favorite", + "verbose_name_plural": "Page Favorites", + "db_table": "page_favorites", + "ordering": ("-created_at",), + "unique_together": {("page", "user")}, + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0025_auto_20230331_0203.py b/apps/api/plane/db/migrations/0025_auto_20230331_0203.py new file mode 100644 index 00000000..702d74cf --- /dev/null +++ b/apps/api/plane/db/migrations/0025_auto_20230331_0203.py @@ -0,0 +1,131 @@ +# Generated by Django 3.2.18 on 2023-03-30 20:33 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0024_auto_20230322_0138"), + ] + + operations = [ + migrations.AddField( + model_name="page", + name="color", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="pageblock", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.AddField( + model_name="pageblock", + name="sync", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="project", + name="page_view", + field=models.BooleanField(default=True), + ), + migrations.CreateModel( + name="PageLabel", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pagelabel_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "label", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="page_labels", + to="db.label", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="page_labels", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pagelabel", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pagelabel_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_pagelabel", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Page Label", + "verbose_name_plural": "Page Labels", + "db_table": "page_labels", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="page", + name="labels", + field=models.ManyToManyField( + blank=True, + related_name="pages", + through="db.PageLabel", + to="db.Label", + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0026_alter_projectmember_view_props.py b/apps/api/plane/db/migrations/0026_alter_projectmember_view_props.py new file mode 100644 index 00000000..310087f9 --- /dev/null +++ b/apps/api/plane/db/migrations/0026_alter_projectmember_view_props.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.18 on 2023-04-04 21:50 + +from django.db import migrations, models +import plane.db.models.project + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0025_auto_20230331_0203"), + ] + + operations = [ + migrations.AlterField( + model_name="projectmember", + name="view_props", + field=models.JSONField( + default=plane.db.models.project.get_default_props + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0027_auto_20230409_0312.py b/apps/api/plane/db/migrations/0027_auto_20230409_0312.py new file mode 100644 index 00000000..0377c84e --- /dev/null +++ b/apps/api/plane/db/migrations/0027_auto_20230409_0312.py @@ -0,0 +1,297 @@ +# Generated by Django 3.2.18 on 2023-04-08 21:42 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.issue +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0026_alter_projectmember_view_props"), + ] + + operations = [ + migrations.CreateModel( + name="Estimate", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ( + "description", + models.TextField( + blank=True, verbose_name="Estimate Description" + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="estimate_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_estimate", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="estimate_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_estimate", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Estimate", + "verbose_name_plural": "Estimates", + "db_table": "estimates", + "ordering": ("name",), + "unique_together": {("name", "project")}, + }, + ), + migrations.RemoveField( + model_name="issue", + name="attachments", + ), + migrations.AddField( + model_name="issue", + name="estimate_point", + field=models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(7), + ], + ), + ), + migrations.CreateModel( + name="IssueAttachment", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("attributes", models.JSONField(default=dict)), + ( + "asset", + models.FileField( + upload_to=plane.db.models.issue.get_upload_path, + validators=[plane.db.models.issue.file_size], + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueattachment_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_attachment", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueattachment", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueattachment_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_issueattachment", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Attachment", + "verbose_name_plural": "Issue Attachments", + "db_table": "issue_attachments", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="project", + name="estimate", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projects", + to="db.estimate", + ), + ), + migrations.CreateModel( + name="EstimatePoint", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "key", + models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(7), + ], + ), + ), + ("description", models.TextField(blank=True)), + ("value", models.CharField(max_length=20)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="estimatepoint_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "estimate", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="points", + to="db.estimate", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_estimatepoint", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="estimatepoint_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_estimatepoint", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Estimate Point", + "verbose_name_plural": "Estimate Points", + "db_table": "estimate_points", + "ordering": ("value",), + "unique_together": {("value", "estimate")}, + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0028_auto_20230414_1703.py b/apps/api/plane/db/migrations/0028_auto_20230414_1703.py new file mode 100644 index 00000000..ffccccff --- /dev/null +++ b/apps/api/plane/db/migrations/0028_auto_20230414_1703.py @@ -0,0 +1,106 @@ +# Generated by Django 3.2.18 on 2023-04-14 11:33 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0027_auto_20230409_0312"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="theme", + field=models.JSONField(default=dict), + ), + migrations.AlterField( + model_name="issue", + name="estimate_point", + field=models.IntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(7), + ], + ), + ), + migrations.CreateModel( + name="WorkspaceTheme", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=300)), + ("colors", models.JSONField(default=dict)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="themes", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacetheme_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="workspacetheme_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="themes", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace Theme", + "verbose_name_plural": "Workspace Themes", + "db_table": "workspace_themes", + "ordering": ("-created_at",), + "unique_together": {("workspace", "name")}, + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0029_auto_20230502_0126.py b/apps/api/plane/db/migrations/0029_auto_20230502_0126.py new file mode 100644 index 00000000..cd2b1b86 --- /dev/null +++ b/apps/api/plane/db/migrations/0029_auto_20230502_0126.py @@ -0,0 +1,116 @@ +# Generated by Django 3.2.18 on 2023-05-01 19:56 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0028_auto_20230414_1703"), + ] + + operations = [ + migrations.AddField( + model_name="cycle", + name="view_props", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="importer", + name="imported_data", + field=models.JSONField(null=True), + ), + migrations.AddField( + model_name="module", + name="view_props", + field=models.JSONField(default=dict), + ), + migrations.CreateModel( + name="SlackProjectSync", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("access_token", models.CharField(max_length=300)), + ("scopes", models.TextField()), + ("bot_user_id", models.CharField(max_length=50)), + ("webhook_url", models.URLField(max_length=1000)), + ("data", models.JSONField(default=dict)), + ("team_id", models.CharField(max_length=30)), + ("team_name", models.CharField(max_length=300)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="slackprojectsync_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_slackprojectsync", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="slackprojectsync_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_slackprojectsync", + to="db.workspace", + ), + ), + ( + "workspace_integration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="slack_syncs", + to="db.workspaceintegration", + ), + ), + ], + options={ + "verbose_name": "Slack Project Sync", + "verbose_name_plural": "Slack Project Syncs", + "db_table": "slack_project_syncs", + "ordering": ("-created_at",), + "unique_together": {("team_id", "project")}, + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0030_alter_estimatepoint_unique_together.py b/apps/api/plane/db/migrations/0030_alter_estimatepoint_unique_together.py new file mode 100644 index 00000000..63db205d --- /dev/null +++ b/apps/api/plane/db/migrations/0030_alter_estimatepoint_unique_together.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.18 on 2023-05-05 14:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0029_auto_20230502_0126"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="estimatepoint", + unique_together=set(), + ), + ] diff --git a/apps/api/plane/db/migrations/0031_analyticview.py b/apps/api/plane/db/migrations/0031_analyticview.py new file mode 100644 index 00000000..f4520a8f --- /dev/null +++ b/apps/api/plane/db/migrations/0031_analyticview.py @@ -0,0 +1,81 @@ +# Generated by Django 3.2.18 on 2023-05-12 11:31 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0030_alter_estimatepoint_unique_together"), + ] + + operations = [ + migrations.CreateModel( + name="AnalyticView", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ("query", models.JSONField()), + ("query_dict", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="analyticview_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="analyticview_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="analytics", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Analytic", + "verbose_name_plural": "Analytics", + "db_table": "analytic_views", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0032_auto_20230520_2015.py b/apps/api/plane/db/migrations/0032_auto_20230520_2015.py new file mode 100644 index 00000000..c781d298 --- /dev/null +++ b/apps/api/plane/db/migrations/0032_auto_20230520_2015.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.19 on 2023-05-20 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0031_analyticview"), + ] + + operations = [ + migrations.RenameField( + model_name="project", + old_name="icon", + new_name="emoji", + ), + migrations.AddField( + model_name="project", + name="icon_prop", + field=models.JSONField(null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0033_auto_20230618_2125.py b/apps/api/plane/db/migrations/0033_auto_20230618_2125.py new file mode 100644 index 00000000..1705aead --- /dev/null +++ b/apps/api/plane/db/migrations/0033_auto_20230618_2125.py @@ -0,0 +1,216 @@ +# Generated by Django 3.2.19 on 2023-06-18 15:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0032_auto_20230520_2015"), + ] + + operations = [ + migrations.CreateModel( + name="Inbox", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ( + "description", + models.TextField( + blank=True, verbose_name="Inbox Description" + ), + ), + ("is_default", models.BooleanField(default=False)), + ("view_props", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="inbox_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ], + options={ + "verbose_name": "Inbox", + "verbose_name_plural": "Inboxes", + "db_table": "inboxes", + "ordering": ("name",), + }, + ), + migrations.AddField( + model_name="project", + name="inbox_view", + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name="InboxIssue", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "status", + models.IntegerField( + choices=[ + (-2, "Pending"), + (-1, "Rejected"), + (0, "Snoozed"), + (1, "Accepted"), + (2, "Duplicate"), + ], + default=-2, + ), + ), + ("snoozed_till", models.DateTimeField(null=True)), + ("source", models.TextField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="inboxissue_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "duplicate_to", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="inbox_duplicate", + to="db.issue", + ), + ), + ( + "inbox", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_inbox", + to="db.inbox", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_inbox", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_inboxissue", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="inboxissue_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_inboxissue", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "InboxIssue", + "verbose_name_plural": "InboxIssues", + "db_table": "inbox_issues", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="inbox", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_inbox", + to="db.project", + ), + ), + migrations.AddField( + model_name="inbox", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="inbox_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AddField( + model_name="inbox", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_inbox", + to="db.workspace", + ), + ), + migrations.AlterUniqueTogether( + name="inbox", + unique_together={("name", "project")}, + ), + ] diff --git a/apps/api/plane/db/migrations/0034_auto_20230628_1046.py b/apps/api/plane/db/migrations/0034_auto_20230628_1046.py new file mode 100644 index 00000000..dd6d21f6 --- /dev/null +++ b/apps/api/plane/db/migrations/0034_auto_20230628_1046.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.19 on 2023-06-28 05:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0033_auto_20230618_2125"), + ] + + operations = [ + migrations.RemoveField( + model_name="timelineissue", + name="created_by", + ), + migrations.RemoveField( + model_name="timelineissue", + name="issue", + ), + migrations.RemoveField( + model_name="timelineissue", + name="project", + ), + migrations.RemoveField( + model_name="timelineissue", + name="updated_by", + ), + migrations.RemoveField( + model_name="timelineissue", + name="workspace", + ), + migrations.DeleteModel( + name="Shortcut", + ), + migrations.DeleteModel( + name="TimelineIssue", + ), + ] diff --git a/apps/api/plane/db/migrations/0035_auto_20230704_2225.py b/apps/api/plane/db/migrations/0035_auto_20230704_2225.py new file mode 100644 index 00000000..806bfef5 --- /dev/null +++ b/apps/api/plane/db/migrations/0035_auto_20230704_2225.py @@ -0,0 +1,46 @@ +# Generated by Django 3.2.19 on 2023-07-04 16:55 + +from django.db import migrations, models + + +def update_company_organization_size(apps, schema_editor): + Model = apps.get_model("db", "Workspace") + updated_size = [] + for obj in Model.objects.all(): + obj.organization_size = str(obj.company_size) + updated_size.append(obj) + + Model.objects.bulk_update( + updated_size, ["organization_size"], batch_size=100 + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0034_auto_20230628_1046"), + ] + + operations = [ + migrations.AddField( + model_name="workspace", + name="organization_size", + field=models.CharField(default="2-10", max_length=20), + ), + migrations.RunPython(update_company_organization_size), + migrations.AlterField( + model_name="workspace", + name="name", + field=models.CharField( + max_length=80, verbose_name="Workspace Name" + ), + ), + migrations.AlterField( + model_name="workspace", + name="slug", + field=models.SlugField(max_length=48, unique=True), + ), + migrations.RemoveField( + model_name="workspace", + name="company_size", + ), + ] diff --git a/apps/api/plane/db/migrations/0036_alter_workspace_organization_size.py b/apps/api/plane/db/migrations/0036_alter_workspace_organization_size.py new file mode 100644 index 00000000..86748c77 --- /dev/null +++ b/apps/api/plane/db/migrations/0036_alter_workspace_organization_size.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.19 on 2023-07-05 07:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0035_auto_20230704_2225"), + ] + + operations = [ + migrations.AlterField( + model_name="workspace", + name="organization_size", + field=models.CharField(max_length=20), + ), + ] diff --git a/apps/api/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py b/apps/api/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py new file mode 100644 index 00000000..e659133d --- /dev/null +++ b/apps/api/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py @@ -0,0 +1,276 @@ +# Generated by Django 4.2.3 on 2023-07-19 06:52 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.user +import uuid + + +def onboarding_default_steps(apps, schema_editor): + default_onboarding_schema = { + "workspace_join": True, + "profile_complete": True, + "workspace_create": True, + "workspace_invite": True, + } + + Model = apps.get_model("db", "User") + updated_user = [] + for obj in Model.objects.filter(is_onboarded=True): + obj.onboarding_step = default_onboarding_schema + obj.is_tour_completed = True + updated_user.append(obj) + + Model.objects.bulk_update( + updated_user, ["onboarding_step", "is_tour_completed"], batch_size=100 + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0036_alter_workspace_organization_size"), + ] + + operations = [ + migrations.AddField( + model_name="issue", + name="archived_at", + field=models.DateField(null=True), + ), + migrations.AddField( + model_name="project", + name="archive_in", + field=models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(12), + ], + ), + ), + migrations.AddField( + model_name="project", + name="close_in", + field=models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(12), + ], + ), + ), + migrations.AddField( + model_name="project", + name="default_state", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="default_state", + to="db.state", + ), + ), + migrations.AddField( + model_name="user", + name="is_tour_completed", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="user", + name="onboarding_step", + field=models.JSONField( + default=plane.db.models.user.get_default_onboarding + ), + ), + migrations.RunPython(onboarding_default_steps), + migrations.CreateModel( + name="Notification", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("data", models.JSONField(null=True)), + ("entity_identifier", models.UUIDField(null=True)), + ("entity_name", models.CharField(max_length=255)), + ("title", models.TextField()), + ("message", models.JSONField(null=True)), + ( + "message_html", + models.TextField(blank=True, default="

    "), + ), + ("message_stripped", models.TextField(blank=True, null=True)), + ("sender", models.CharField(max_length=255)), + ("read_at", models.DateTimeField(null=True)), + ("snoozed_till", models.DateTimeField(null=True)), + ("archived_at", models.DateTimeField(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", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="db.project", + ), + ), + ( + "receiver", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="received_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "triggered_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="triggered_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "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="notifications", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Notification", + "verbose_name_plural": "Notifications", + "db_table": "notifications", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="IssueSubscriber", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_subscribers", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "subscriber", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_subscribers", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "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 Subscriber", + "verbose_name_plural": "Issue Subscribers", + "db_table": "issue_subscribers", + "ordering": ("-created_at",), + "unique_together": {("issue", "subscriber")}, + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0038_auto_20230720_1505.py b/apps/api/plane/db/migrations/0038_auto_20230720_1505.py new file mode 100644 index 00000000..5f11d9ad --- /dev/null +++ b/apps/api/plane/db/migrations/0038_auto_20230720_1505.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.3 on 2023-07-20 09:35 + +from django.db import migrations + + +def restructure_theming(apps, schema_editor): + Model = apps.get_model("db", "User") + updated_user = [] + for obj in Model.objects.exclude(theme={}).all(): + current_theme = obj.theme + updated_theme = { + "primary": current_theme.get("accent", ""), + "background": current_theme.get("bgBase", ""), + "sidebarBackground": current_theme.get("sidebar", ""), + "text": current_theme.get("textBase", ""), + "sidebarText": current_theme.get("textBase", ""), + "palette": f"""{current_theme.get("bgBase","")},{current_theme.get("textBase", "")},{current_theme.get("accent", "")},{current_theme.get("sidebar","")},{current_theme.get("textBase", "")}""", + "darkPalette": current_theme.get("darkPalette", ""), + } + obj.theme = updated_theme + updated_user.append(obj) + + Model.objects.bulk_update(updated_user, ["theme"], batch_size=100) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0037_issue_archived_at_project_archive_in_and_more"), + ] + + operations = [migrations.RunPython(restructure_theming)] diff --git a/apps/api/plane/db/migrations/0039_auto_20230723_2203.py b/apps/api/plane/db/migrations/0039_auto_20230723_2203.py new file mode 100644 index 00000000..26849d7f --- /dev/null +++ b/apps/api/plane/db/migrations/0039_auto_20230723_2203.py @@ -0,0 +1,105 @@ +# Generated by Django 4.2.3 on 2023-07-23 16:33 +import random +from django.db import migrations, models +import plane.db.models.workspace + + +def rename_field(apps, schema_editor): + Model = apps.get_model("db", "IssueActivity") + updated_activity = [] + for obj in Model.objects.filter(field="assignee"): + obj.field = "assignees" + updated_activity.append(obj) + + Model.objects.bulk_update(updated_activity, ["field"], batch_size=100) + + +def update_workspace_member_props(apps, schema_editor): + Model = apps.get_model("db", "WorkspaceMember") + + updated_workspace_member = [] + + for obj in Model.objects.all(): + if obj.view_props is None: + obj.view_props = { + "filters": {"type": None}, + "groupByProperty": None, + "issueView": "list", + "orderBy": "-created_at", + "properties": { + "assignee": True, + "due_date": True, + "key": True, + "labels": True, + "priority": True, + "state": True, + "sub_issue_count": True, + "attachment_count": True, + "link": True, + "estimate": True, + "created_on": True, + "updated_on": True, + }, + "showEmptyGroups": True, + } + else: + current_view_props = obj.view_props + obj.view_props = { + "filters": {"type": None}, + "groupByProperty": None, + "issueView": "list", + "orderBy": "-created_at", + "showEmptyGroups": True, + "properties": current_view_props, + } + + updated_workspace_member.append(obj) + + Model.objects.bulk_update( + updated_workspace_member, ["view_props"], batch_size=100 + ) + + +def update_project_member_sort_order(apps, schema_editor): + Model = apps.get_model("db", "ProjectMember") + + updated_project_members = [] + + for obj in Model.objects.all(): + obj.sort_order = random.randint(1, 65536) + updated_project_members.append(obj) + + Model.objects.bulk_update( + updated_project_members, ["sort_order"], batch_size=100 + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0038_auto_20230720_1505"), + ] + + operations = [ + migrations.RunPython(rename_field), + migrations.RunPython(update_workspace_member_props), + migrations.AlterField( + model_name="workspacemember", + name="view_props", + field=models.JSONField( + default=plane.db.models.workspace.get_default_props + ), + ), + migrations.AddField( + model_name="workspacemember", + name="default_props", + field=models.JSONField( + default=plane.db.models.workspace.get_default_props + ), + ), + migrations.AddField( + model_name="projectmember", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.RunPython(update_project_member_sort_order), + ] diff --git a/apps/api/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py b/apps/api/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py new file mode 100644 index 00000000..76f8e627 --- /dev/null +++ b/apps/api/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py @@ -0,0 +1,216 @@ +# Generated by Django 4.2.3 on 2023-08-01 06:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.project +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0039_auto_20230723_2203"), + ] + + operations = [ + migrations.AddField( + model_name="projectmember", + name="preferences", + field=models.JSONField( + default=plane.db.models.project.get_default_preferences + ), + ), + migrations.AddField( + model_name="user", + name="cover_image", + field=models.URLField(blank=True, max_length=800, null=True), + ), + migrations.CreateModel( + name="IssueReaction", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("reaction", models.CharField(max_length=20)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_reactions", + 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", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_reactions", + 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 Reaction", + "verbose_name_plural": "Issue Reactions", + "db_table": "issue_reactions", + "ordering": ("-created_at",), + "unique_together": {("issue", "actor", "reaction")}, + }, + ), + migrations.CreateModel( + name="CommentReaction", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("reaction", models.CharField(max_length=20)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comment_reactions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "comment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comment_reactions", + to="db.issuecomment", + ), + ), + ( + "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", + ), + ), + ( + "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": "Comment Reaction", + "verbose_name_plural": "Comment Reactions", + "db_table": "comment_reactions", + "ordering": ("-created_at",), + "unique_together": {("comment", "actor", "reaction")}, + }, + ), + migrations.AlterField( + model_name="project", + name="identifier", + field=models.CharField( + max_length=12, verbose_name="Project Identifier" + ), + ), + migrations.AlterField( + model_name="projectidentifier", + name="name", + field=models.CharField(max_length=12), + ), + ] diff --git a/apps/api/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py b/apps/api/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py new file mode 100644 index 00000000..91119dbb --- /dev/null +++ b/apps/api/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py @@ -0,0 +1,498 @@ +# Generated by Django 4.2.3 on 2023-08-14 07:12 + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.exporter +import plane.db.models.project +import uuid +import random +import string + + +def generate_display_name(apps, schema_editor): + UserModel = apps.get_model("db", "User") + updated_users = [] + for obj in UserModel.objects.all(): + obj.display_name = ( + obj.email.split("@")[0] + if len(obj.email.split("@")) + else "".join(random.choice(string.ascii_letters) for _ in range(6)) + ) + updated_users.append(obj) + UserModel.objects.bulk_update( + updated_users, ["display_name"], batch_size=100 + ) + + +def rectify_field_issue_activity(apps, schema_editor): + Model = apps.get_model("db", "IssueActivity") + updated_activity = [] + for obj in Model.objects.filter(field="assignee"): + obj.field = "assignees" + updated_activity.append(obj) + + Model.objects.bulk_update(updated_activity, ["field"], batch_size=100) + + +def update_assignee_issue_activity(apps, schema_editor): + Model = apps.get_model("db", "IssueActivity") + updated_activity = [] + + # Get all the users + User = apps.get_model("db", "User") + users = User.objects.values("id", "email", "display_name") + + for obj in Model.objects.filter(field="assignees"): + if bool(obj.new_value) and not bool(obj.old_value): + # Get user from list + assigned_user = [ + user for user in users if user.get("email") == obj.new_value + ] + if assigned_user: + obj.new_value = assigned_user[0].get("display_name") + obj.new_identifier = assigned_user[0].get("id") + # Update the comment + words = obj.comment.split() + words[-1] = assigned_user[0].get("display_name") + obj.comment = " ".join(words) + + if bool(obj.old_value) and not bool(obj.new_value): + # Get user from list + assigned_user = [ + user for user in users if user.get("email") == obj.old_value + ] + if assigned_user: + obj.old_value = assigned_user[0].get("display_name") + obj.old_identifier = assigned_user[0].get("id") + # Update the comment + words = obj.comment.split() + words[-1] = assigned_user[0].get("display_name") + obj.comment = " ".join(words) + + updated_activity.append(obj) + + Model.objects.bulk_update( + updated_activity, + [ + "old_value", + "new_value", + "old_identifier", + "new_identifier", + "comment", + ], + batch_size=200, + ) + + +def update_name_activity(apps, schema_editor): + Model = apps.get_model("db", "IssueActivity") + update_activity = [] + for obj in Model.objects.filter(field="name"): + obj.comment = obj.comment.replace("start date", "name") + update_activity.append(obj) + + Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000) + + +def random_cycle_order(apps, schema_editor): + CycleModel = apps.get_model("db", "Cycle") + updated_cycles = [] + for obj in CycleModel.objects.all(): + obj.sort_order = random.randint(1, 65536) + updated_cycles.append(obj) + CycleModel.objects.bulk_update( + updated_cycles, ["sort_order"], batch_size=100 + ) + + +def random_module_order(apps, schema_editor): + ModuleModel = apps.get_model("db", "Module") + updated_modules = [] + for obj in ModuleModel.objects.all(): + obj.sort_order = random.randint(1, 65536) + updated_modules.append(obj) + ModuleModel.objects.bulk_update( + updated_modules, ["sort_order"], batch_size=100 + ) + + +def update_user_issue_properties(apps, schema_editor): + IssuePropertyModel = apps.get_model("db", "IssueProperty") + updated_issue_properties = [] + for obj in IssuePropertyModel.objects.all(): + obj.properties["start_date"] = True + updated_issue_properties.append(obj) + IssuePropertyModel.objects.bulk_update( + updated_issue_properties, ["properties"], batch_size=100 + ) + + +def workspace_member_properties(apps, schema_editor): + WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember") + updated_workspace_members = [] + for obj in WorkspaceMemberModel.objects.all(): + obj.view_props["properties"]["start_date"] = True + obj.default_props["properties"]["start_date"] = True + updated_workspace_members.append(obj) + + WorkspaceMemberModel.objects.bulk_update( + updated_workspace_members, + ["view_props", "default_props"], + batch_size=100, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0040_projectmember_preferences_user_cover_image_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="cycle", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.AddField( + model_name="issuecomment", + name="access", + field=models.CharField( + choices=[("INTERNAL", "INTERNAL"), ("EXTERNAL", "EXTERNAL")], + default="INTERNAL", + max_length=100, + ), + ), + migrations.AddField( + model_name="module", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.AddField( + model_name="user", + name="display_name", + field=models.CharField(default="", max_length=255), + ), + migrations.CreateModel( + name="ExporterHistory", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "project", + django.contrib.postgres.fields.ArrayField( + base_field=models.UUIDField(default=uuid.uuid4), + blank=True, + null=True, + size=None, + ), + ), + ( + "provider", + models.CharField( + choices=[ + ("json", "json"), + ("csv", "csv"), + ("xlsx", "xlsx"), + ], + max_length=50, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("queued", "Queued"), + ("processing", "Processing"), + ("completed", "Completed"), + ("failed", "Failed"), + ], + default="queued", + max_length=50, + ), + ), + ("reason", models.TextField(blank=True)), + ("key", models.TextField(blank=True)), + ( + "url", + models.URLField(blank=True, max_length=800, null=True), + ), + ( + "token", + models.CharField( + default=plane.db.models.exporter.generate_token, + max_length=255, + 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", + ), + ), + ( + "initiated_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_exporters", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "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_exporters", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Exporter", + "verbose_name_plural": "Exporters", + "db_table": "exporters", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="ProjectDeployBoard", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "anchor", + models.CharField( + db_index=True, + default=plane.db.models.project.get_anchor, + max_length=255, + unique=True, + ), + ), + ("comments", models.BooleanField(default=False)), + ("reactions", models.BooleanField(default=False)), + ("votes", models.BooleanField(default=False)), + ( + "views", + models.JSONField( + default=plane.db.models.project.get_default_views + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "inbox", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="bord_inbox", + to="db.inbox", + ), + ), + ( + "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": "Project Deploy Board", + "verbose_name_plural": "Project Deploy Boards", + "db_table": "project_deploy_boards", + "ordering": ("-created_at",), + "unique_together": {("project", "anchor")}, + }, + ), + migrations.CreateModel( + name="IssueVote", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "vote", + models.IntegerField( + choices=[(-1, "DOWNVOTE"), (1, "UPVOTE")] + ), + ), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="votes", + 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", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="votes", + 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 Vote", + "verbose_name_plural": "Issue Votes", + "db_table": "issue_votes", + "ordering": ("-created_at",), + "unique_together": {("issue", "actor")}, + }, + ), + migrations.AlterField( + model_name="modulelink", + name="title", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.RunPython(generate_display_name), + migrations.RunPython(rectify_field_issue_activity), + migrations.RunPython(update_assignee_issue_activity), + migrations.RunPython(update_name_activity), + migrations.RunPython(random_cycle_order), + migrations.RunPython(random_module_order), + migrations.RunPython(update_user_issue_properties), + migrations.RunPython(workspace_member_properties), + ] diff --git a/apps/api/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py b/apps/api/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py new file mode 100644 index 00000000..f1fa99a3 --- /dev/null +++ b/apps/api/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py @@ -0,0 +1,766 @@ +# Generated by Django 4.2.3 on 2023-08-29 06:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +def update_user_timezones(apps, schema_editor): + UserModel = apps.get_model("db", "User") + updated_users = [] + for obj in UserModel.objects.all(): + obj.user_timezone = "UTC" + updated_users.append(obj) + UserModel.objects.bulk_update( + updated_users, ["user_timezone"], batch_size=100 + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0041_cycle_sort_order_issuecomment_access_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="user_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="issuelink", + name="title", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.RunPython(update_user_timezones), + migrations.AlterField( + model_name="issuevote", + name="vote", + field=models.IntegerField( + choices=[(-1, "DOWNVOTE"), (1, "UPVOTE")], default=1 + ), + ), + migrations.CreateModel( + name="ProjectPublicMember", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "member", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="public_project_members", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Public Member", + "verbose_name_plural": "Project Public Members", + "db_table": "project_public_members", + "ordering": ("-created_at",), + "unique_together": {("project", "member")}, + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py b/apps/api/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py new file mode 100644 index 00000000..f7998c4a --- /dev/null +++ b/apps/api/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py @@ -0,0 +1,173 @@ +# Generated by Django 4.2.3 on 2023-09-12 07:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +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( + IssueRelation( + issue_id=blocked_issue.block_id, + related_issue_id=blocked_issue.blocked_by_id, + relation_type="blocked_by", + project_id=blocked_issue.project_id, + workspace_id=blocked_issue.workspace_id, + created_by_id=blocked_issue.created_by_id, + updated_by_id=blocked_issue.updated_by_id, + ) + ) + IssueRelation.objects.bulk_create( + updated_issue_relation, batch_size=100 + ) + except Exception as e: + print(e) + + +def update_issue_priority_choice(apps, schema_editor): + IssueModel = apps.get_model("db", "Issue") + updated_issues = [] + for obj in IssueModel.objects.filter(priority=None): + obj.priority = "none" + updated_issues.append(obj) + IssueModel.objects.bulk_update( + updated_issues, ["priority"], batch_size=100 + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0042_alter_analyticview_created_by_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="IssueRelation", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "relation_type", + models.CharField( + choices=[ + ("duplicate", "Duplicate"), + ("relates_to", "Relates To"), + ("blocked_by", "Blocked By"), + ], + default="blocked_by", + max_length=20, + verbose_name="Issue Relation Type", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_relation", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "related_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_related", + to="db.issue", + ), + ), + ( + "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 Relation", + "verbose_name_plural": "Issue Relations", + "db_table": "issue_relations", + "ordering": ("-created_at",), + "unique_together": {("issue", "related_issue")}, + }, + ), + migrations.AddField( + model_name="issue", + name="is_draft", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="issue", + name="priority", + field=models.CharField( + choices=[ + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ], + default="none", + max_length=30, + verbose_name="Issue Priority", + ), + ), + migrations.RunPython(create_issue_relation), + migrations.RunPython(update_issue_priority_choice), + ] diff --git a/apps/api/plane/db/migrations/0044_auto_20230913_0709.py b/apps/api/plane/db/migrations/0044_auto_20230913_0709.py new file mode 100644 index 00000000..d42b3431 --- /dev/null +++ b/apps/api/plane/db/migrations/0044_auto_20230913_0709.py @@ -0,0 +1,172 @@ +# Generated by Django 4.2.3 on 2023-09-13 07:09 + +from django.db import migrations + + +def workspace_member_props(old_props): + new_props = { + "filters": { + "priority": old_props.get("filters", {}).get("priority", None), + "state": old_props.get("filters", {}).get("state", None), + "state_group": old_props.get("filters", {}).get( + "state_group", None + ), + "assignees": old_props.get("filters", {}).get("assignees", None), + "created_by": old_props.get("filters", {}).get("created_by", None), + "labels": old_props.get("filters", {}).get("labels", None), + "start_date": old_props.get("filters", {}).get("start_date", None), + "target_date": old_props.get("filters", {}).get( + "target_date", None + ), + "subscriber": old_props.get("filters", {}).get("subscriber", None), + }, + "display_filters": { + "group_by": old_props.get("groupByProperty", None), + "order_by": old_props.get("orderBy", "-created_at"), + "type": old_props.get("filters", {}).get("type", None), + "sub_issue": old_props.get("showSubIssues", True), + "show_empty_groups": old_props.get("showEmptyGroups", True), + "layout": old_props.get("issueView", "list"), + "calendar_date_range": old_props.get("calendarDateRange", ""), + }, + "display_properties": { + "assignee": old_props.get("properties", {}).get("assignee", True), + "attachment_count": old_props.get("properties", {}).get( + "attachment_count", True + ), + "created_on": old_props.get("properties", {}).get( + "created_on", True + ), + "due_date": old_props.get("properties", {}).get("due_date", True), + "estimate": old_props.get("properties", {}).get("estimate", True), + "key": old_props.get("properties", {}).get("key", True), + "labels": old_props.get("properties", {}).get("labels", True), + "link": old_props.get("properties", {}).get("link", True), + "priority": old_props.get("properties", {}).get("priority", True), + "start_date": old_props.get("properties", {}).get( + "start_date", True + ), + "state": old_props.get("properties", {}).get("state", True), + "sub_issue_count": old_props.get("properties", {}).get( + "sub_issue_count", True + ), + "updated_on": old_props.get("properties", {}).get( + "updated_on", True + ), + }, + } + return new_props + + +def project_member_props(old_props): + new_props = { + "filters": { + "priority": old_props.get("filters", {}).get("priority", None), + "state": old_props.get("filters", {}).get("state", None), + "state_group": old_props.get("filters", {}).get( + "state_group", None + ), + "assignees": old_props.get("filters", {}).get("assignees", None), + "created_by": old_props.get("filters", {}).get("created_by", None), + "labels": old_props.get("filters", {}).get("labels", None), + "start_date": old_props.get("filters", {}).get("start_date", None), + "target_date": old_props.get("filters", {}).get( + "target_date", None + ), + "subscriber": old_props.get("filters", {}).get("subscriber", None), + }, + "display_filters": { + "group_by": old_props.get("groupByProperty", None), + "order_by": old_props.get("orderBy", "-created_at"), + "type": old_props.get("filters", {}).get("type", None), + "sub_issue": old_props.get("showSubIssues", True), + "show_empty_groups": old_props.get("showEmptyGroups", True), + "layout": old_props.get("issueView", "list"), + "calendar_date_range": old_props.get("calendarDateRange", ""), + }, + } + return new_props + + +def cycle_module_props(old_props): + new_props = { + "filters": { + "priority": old_props.get("filters", {}).get("priority", None), + "state": old_props.get("filters", {}).get("state", None), + "state_group": old_props.get("filters", {}).get( + "state_group", None + ), + "assignees": old_props.get("filters", {}).get("assignees", None), + "created_by": old_props.get("filters", {}).get("created_by", None), + "labels": old_props.get("filters", {}).get("labels", None), + "start_date": old_props.get("filters", {}).get("start_date", None), + "target_date": old_props.get("filters", {}).get( + "target_date", None + ), + "subscriber": old_props.get("filters", {}).get("subscriber", None), + }, + } + return new_props + + +def update_workspace_member_view_props(apps, schema_editor): + WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember") + updated_workspace_member = [] + for obj in WorkspaceMemberModel.objects.all(): + obj.view_props = workspace_member_props(obj.view_props) + obj.default_props = workspace_member_props(obj.default_props) + updated_workspace_member.append(obj) + WorkspaceMemberModel.objects.bulk_update( + updated_workspace_member, + ["view_props", "default_props"], + batch_size=100, + ) + + +def update_project_member_view_props(apps, schema_editor): + ProjectMemberModel = apps.get_model("db", "ProjectMember") + updated_project_member = [] + for obj in ProjectMemberModel.objects.all(): + obj.view_props = project_member_props(obj.view_props) + obj.default_props = project_member_props(obj.default_props) + updated_project_member.append(obj) + ProjectMemberModel.objects.bulk_update( + updated_project_member, ["view_props", "default_props"], batch_size=100 + ) + + +def update_cycle_props(apps, schema_editor): + CycleModel = apps.get_model("db", "Cycle") + updated_cycle = [] + for obj in CycleModel.objects.all(): + if "filter" in obj.view_props: + obj.view_props = cycle_module_props(obj.view_props) + updated_cycle.append(obj) + CycleModel.objects.bulk_update( + updated_cycle, ["view_props"], batch_size=100 + ) + + +def update_module_props(apps, schema_editor): + ModuleModel = apps.get_model("db", "Module") + updated_module = [] + for obj in ModuleModel.objects.all(): + if "filter" in obj.view_props: + obj.view_props = cycle_module_props(obj.view_props) + updated_module.append(obj) + ModuleModel.objects.bulk_update( + updated_module, ["view_props"], batch_size=100 + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0043_alter_analyticview_created_by_and_more"), + ] + + operations = [ + migrations.RunPython(update_workspace_member_view_props), + migrations.RunPython(update_project_member_view_props), + migrations.RunPython(update_cycle_props), + migrations.RunPython(update_module_props), + ] diff --git a/apps/api/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py b/apps/api/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py new file mode 100644 index 00000000..9ac52882 --- /dev/null +++ b/apps/api/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py @@ -0,0 +1,140 @@ +# Generated by Django 4.2.5 on 2023-09-29 10:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.workspace +import uuid + + +def update_issue_activity_priority(apps, schema_editor): + IssueActivity = apps.get_model("db", "IssueActivity") + updated_issue_activity = [] + for obj in IssueActivity.objects.filter(field="priority"): + # Set the old and new value to none if it is empty for Priority + obj.new_value = obj.new_value or "none" + obj.old_value = obj.old_value or "none" + updated_issue_activity.append(obj) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["new_value", "old_value"], + batch_size=2000, + ) + + +def update_issue_activity_blocked(apps, schema_editor): + IssueActivity = apps.get_model("db", "IssueActivity") + updated_issue_activity = [] + for obj in IssueActivity.objects.filter(field="blocks"): + # Set the field to blocked_by + obj.field = "blocked_by" + updated_issue_activity.append(obj) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["field"], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0044_auto_20230913_0709"), + ] + + operations = [ + migrations.CreateModel( + name="GlobalView", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="View Name"), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="View Description" + ), + ), + ("query", models.JSONField(verbose_name="View Query")), + ( + "access", + models.PositiveSmallIntegerField( + choices=[(0, "Private"), (1, "Public")], default=1 + ), + ), + ("query_data", models.JSONField(default=dict)), + ("sort_order", models.FloatField(default=65535)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="global_views", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Global View", + "verbose_name_plural": "Global Views", + "db_table": "global_views", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="workspacemember", + name="issue_props", + field=models.JSONField( + default=plane.db.models.workspace.get_issue_props + ), + ), + migrations.AddField( + model_name="issueactivity", + name="epoch", + field=models.FloatField(null=True), + ), + migrations.RunPython(update_issue_activity_priority), + migrations.RunPython(update_issue_activity_blocked), + ] diff --git a/apps/api/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py b/apps/api/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py new file mode 100644 index 00000000..be58c8f5 --- /dev/null +++ b/apps/api/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py @@ -0,0 +1,2008 @@ +# Generated by Django 4.2.5 on 2023-11-15 09:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.issue +import uuid +import random + + +def random_sort_ordering(apps, schema_editor): + Label = apps.get_model("db", "Label") + + bulk_labels = [] + for label in Label.objects.all(): + label.sort_order = random.randint(0, 65535) + bulk_labels.append(label) + + Label.objects.bulk_update(bulk_labels, ["sort_order"], batch_size=1000) + + +class Migration(migrations.Migration): + dependencies = [ + ( + "db", + "0045_issueactivity_epoch_workspacemember_issue_props_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="label", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.AlterField( + model_name="analyticview", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="analyticview", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="apitoken", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="apitoken", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="cycle", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="cycle", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="cycle", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="cycle", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="cyclefavorite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="cyclefavorite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="cyclefavorite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="cyclefavorite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="cycleissue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="cycleissue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="cycleissue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="cycleissue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="estimate", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="estimate", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="estimate", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="estimate", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="estimatepoint", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="estimatepoint", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="estimatepoint", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="estimatepoint", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="fileasset", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="fileasset", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="githubcommentsync", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="githubcommentsync", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="githubcommentsync", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="githubcommentsync", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="githubissuesync", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="githubissuesync", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="githubissuesync", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="githubissuesync", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="githubrepository", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="githubrepository", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="githubrepository", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="githubrepository", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="githubrepositorysync", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="githubrepositorysync", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="githubrepositorysync", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="githubrepositorysync", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="importer", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="importer", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="importer", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="importer", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="inbox", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="inbox", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="inbox", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="inbox", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="inboxissue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="inboxissue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="inboxissue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="inboxissue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="integration", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="integration", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueactivity", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueactivity", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueactivity", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueactivity", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueassignee", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueassignee", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueassignee", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueassignee", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueattachment", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueattachment", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueattachment", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueattachment", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueblocker", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueblocker", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueblocker", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueblocker", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issuecomment", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issuecomment", + name="issue", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_comments", + to="db.issue", + ), + ), + migrations.AlterField( + model_name="issuecomment", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issuecomment", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issuecomment", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issuelabel", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issuelabel", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issuelabel", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issuelabel", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issuelink", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issuelink", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issuelink", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issuelink", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueproperty", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueproperty", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueproperty", + name="properties", + field=models.JSONField( + default=plane.db.models.issue.get_default_properties + ), + ), + migrations.AlterField( + model_name="issueproperty", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueproperty", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issuesequence", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issuesequence", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issuesequence", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issuesequence", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueview", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueview", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueview", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueview", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueviewfavorite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueviewfavorite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueviewfavorite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueviewfavorite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="label", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="label", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="label", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="label", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="module", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="module", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="module", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="module", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="modulefavorite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="modulefavorite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="modulefavorite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="modulefavorite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="moduleissue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="moduleissue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="moduleissue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="moduleissue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="modulelink", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="modulelink", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="modulelink", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="modulelink", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="modulemember", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="modulemember", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="modulemember", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="modulemember", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="page", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="page", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="page", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="page", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="pageblock", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="pageblock", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="pageblock", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="pageblock", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="pagefavorite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="pagefavorite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="pagefavorite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="pagefavorite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="pagelabel", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="pagelabel", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="pagelabel", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="pagelabel", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="project", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="project", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="projectfavorite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="projectfavorite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="projectfavorite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="projectfavorite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="projectidentifier", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="projectidentifier", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="projectmember", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="projectmember", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="projectmember", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="projectmember", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="projectmemberinvite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="projectmemberinvite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="projectmemberinvite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="projectmemberinvite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="slackprojectsync", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="slackprojectsync", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="slackprojectsync", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="slackprojectsync", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="socialloginconnection", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="socialloginconnection", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="state", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="state", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="state", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="state", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="team", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="team", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="teammember", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="teammember", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="workspace", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="workspace", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="workspaceintegration", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="workspaceintegration", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="workspacemember", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="workspacemember", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="workspacememberinvite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="workspacememberinvite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="workspacetheme", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="workspacetheme", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.CreateModel( + name="IssueMention", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_mention", + to="db.issue", + ), + ), + ( + "mention", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_mention", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Mention", + "verbose_name_plural": "Issue Mentions", + "db_table": "issue_mentions", + "ordering": ("-created_at",), + "unique_together": {("issue", "mention")}, + }, + ), + migrations.RunPython(random_sort_ordering), + ] diff --git a/apps/api/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py b/apps/api/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py new file mode 100644 index 00000000..f0a52a35 --- /dev/null +++ b/apps/api/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py @@ -0,0 +1,296 @@ +# Generated by Django 4.2.5 on 2023-11-15 11:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.api +import plane.db.models.webhook +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0046_label_sort_order_alter_analyticview_created_by_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Webhook", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "url", + models.URLField( + validators=[ + plane.db.models.webhook.validate_schema, + plane.db.models.webhook.validate_domain, + ] + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "secret_key", + models.CharField( + default=plane.db.models.webhook.generate_token, + max_length=255, + ), + ), + ("project", models.BooleanField(default=False)), + ("issue", models.BooleanField(default=False)), + ("module", models.BooleanField(default=False)), + ("cycle", models.BooleanField(default=False)), + ("issue_comment", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_webhooks", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Webhook", + "verbose_name_plural": "Webhooks", + "db_table": "webhooks", + "ordering": ("-created_at",), + "unique_together": {("workspace", "url")}, + }, + ), + migrations.AddField( + model_name="apitoken", + name="description", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="apitoken", + name="expired_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="apitoken", + name="is_active", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="apitoken", + name="last_used", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="projectmember", + name="is_active", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="workspacemember", + name="is_active", + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name="apitoken", + name="token", + field=models.CharField( + db_index=True, + default=plane.db.models.api.generate_token, + max_length=255, + unique=True, + ), + ), + migrations.CreateModel( + name="WebhookLog", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "event_type", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "request_method", + models.CharField(blank=True, max_length=10, null=True), + ), + ("request_headers", models.TextField(blank=True, null=True)), + ("request_body", models.TextField(blank=True, null=True)), + ("response_status", models.TextField(blank=True, null=True)), + ("response_headers", models.TextField(blank=True, null=True)), + ("response_body", models.TextField(blank=True, null=True)), + ("retry_count", models.PositiveSmallIntegerField(default=0)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "webhook", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="logs", + to="db.webhook", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="webhook_logs", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Webhook Log", + "verbose_name_plural": "Webhook Logs", + "db_table": "webhook_logs", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="APIActivityLog", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("token_identifier", models.CharField(max_length=255)), + ("path", models.CharField(max_length=255)), + ("method", models.CharField(max_length=10)), + ("query_params", models.TextField(blank=True, null=True)), + ("headers", models.TextField(blank=True, null=True)), + ("body", models.TextField(blank=True, null=True)), + ("response_code", models.PositiveIntegerField()), + ("response_body", models.TextField(blank=True, null=True)), + ( + "ip_address", + models.GenericIPAddressField(blank=True, null=True), + ), + ( + "user_agent", + models.CharField(blank=True, max_length=512, null=True), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "API Activity Log", + "verbose_name_plural": "API Activity Logs", + "db_table": "api_activity_logs", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0048_auto_20231116_0713.py b/apps/api/plane/db/migrations/0048_auto_20231116_0713.py new file mode 100644 index 00000000..791affed --- /dev/null +++ b/apps/api/plane/db/migrations/0048_auto_20231116_0713.py @@ -0,0 +1,141 @@ +# Generated by Django 4.2.5 on 2023-11-13 12:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ( + "db", + "0047_webhook_apitoken_description_apitoken_expired_at_and_more", + ), + ] + + operations = [ + migrations.CreateModel( + name="PageLog", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("transaction", models.UUIDField(default=uuid.uuid4)), + ("entity_identifier", models.UUIDField(null=True)), + ( + "entity_name", + models.CharField( + choices=[ + ("to_do", "To Do"), + ("issue", "issue"), + ("image", "Image"), + ("video", "Video"), + ("file", "File"), + ("link", "Link"), + ("cycle", "Cycle"), + ("module", "Module"), + ("back_link", "Back Link"), + ("forward_link", "Forward Link"), + ("page_mention", "Page Mention"), + ("user_mention", "User Mention"), + ], + max_length=30, + verbose_name="Transaction Type", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="page_log", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Page Log", + "verbose_name_plural": "Page Logs", + "db_table": "page_logs", + "ordering": ("-created_at",), + "unique_together": {("page", "transaction")}, + }, + ), + migrations.AddField( + model_name="page", + name="archived_at", + field=models.DateField(null=True), + ), + migrations.AddField( + model_name="page", + name="is_locked", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="page", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="child_page", + to="db.page", + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0049_auto_20231116_0713.py b/apps/api/plane/db/migrations/0049_auto_20231116_0713.py new file mode 100644 index 00000000..d59fc5a8 --- /dev/null +++ b/apps/api/plane/db/migrations/0049_auto_20231116_0713.py @@ -0,0 +1,74 @@ +# Generated by Django 4.2.5 on 2023-11-15 09:16 + +# Python imports +import uuid + +from django.db import migrations + + +def update_pages(apps, schema_editor): + try: + Page = apps.get_model("db", "Page") + PageBlock = apps.get_model("db", "PageBlock") + PageLog = apps.get_model("db", "PageLog") + + updated_pages = [] + page_logs = [] + + # looping through all the pages + for page in Page.objects.all(): + page_blocks = PageBlock.objects.filter( + page_id=page.id, + project_id=page.project_id, + workspace_id=page.workspace_id, + ).order_by("sort_order") + + if page_blocks: + # looping through all the page blocks in a page + for page_block in page_blocks: + if page_block.issue is not None: + project_identifier = page.project.identifier + sequence_id = page_block.issue.sequence_id + transaction = uuid.uuid4().hex + embed_component = f'' + page.description_html += embed_component + + # create the page transaction for the issue + page_logs.append( + PageLog( + page_id=page_block.page_id, + transaction=transaction, + entity_identifier=page_block.issue_id, + entity_name="issue", + project_id=page.project_id, + workspace_id=page.workspace_id, + created_by_id=page_block.created_by_id, + updated_by_id=page_block.updated_by_id, + ) + ) + else: + # adding the page block name and description to the page description + page.description_html += f"

    {page_block.name}

    " + page.description_html += page_block.description_html + + updated_pages.append(page) + + Page.objects.bulk_update( + updated_pages, + ["description_html"], + batch_size=100, + ) + PageLog.objects.bulk_create(page_logs, batch_size=100) + + except Exception as e: + print(e) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0048_auto_20231116_0713"), + ] + + operations = [ + migrations.RunPython(update_pages), + ] diff --git a/apps/api/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py b/apps/api/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py new file mode 100644 index 00000000..327a5ab7 --- /dev/null +++ b/apps/api/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.5 on 2023-11-17 08:48 + +from django.db import migrations, models +import plane.db.models.workspace + + +def user_password_autoset(apps, schema_editor): + User = apps.get_model("db", "User") + User.objects.update(is_password_autoset=True) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0049_auto_20231116_0713"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="use_case", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="workspace", + name="organization_size", + field=models.CharField(blank=True, max_length=20, null=True), + ), + migrations.AddField( + model_name="fileasset", + name="is_deleted", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="workspace", + name="slug", + field=models.SlugField( + max_length=48, + unique=True, + validators=[plane.db.models.workspace.slug_validator], + ), + ), + migrations.RunPython(user_password_autoset), + ] diff --git a/apps/api/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py b/apps/api/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py new file mode 100644 index 00000000..886cee52 --- /dev/null +++ b/apps/api/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py @@ -0,0 +1,82 @@ +# Generated by Django 4.2.7 on 2023-12-29 10:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0050_user_use_case_alter_workspace_organization_size"), + ] + + operations = [ + migrations.AddField( + model_name="cycle", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="cycle", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="inboxissue", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="inboxissue", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issue", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issue", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issuecomment", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issuecomment", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="label", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="label", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="module", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="module", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="state", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="state", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0052_auto_20231220_1141.py b/apps/api/plane/db/migrations/0052_auto_20231220_1141.py new file mode 100644 index 00000000..e2d41748 --- /dev/null +++ b/apps/api/plane/db/migrations/0052_auto_20231220_1141.py @@ -0,0 +1,379 @@ +# Generated by Django 4.2.7 on 2023-12-20 11:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.cycle +import plane.db.models.issue +import plane.db.models.module +import plane.db.models.view +import plane.db.models.workspace +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0051_cycle_external_id_cycle_external_source_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="issueview", + old_name="query_data", + new_name="filters", + ), + migrations.RenameField( + model_name="issueproperty", + old_name="properties", + new_name="display_properties", + ), + migrations.AlterField( + model_name="issueproperty", + name="display_properties", + field=models.JSONField( + default=plane.db.models.issue.get_default_display_properties + ), + ), + migrations.AddField( + model_name="issueproperty", + name="display_filters", + field=models.JSONField( + default=plane.db.models.issue.get_default_display_filters + ), + ), + migrations.AddField( + model_name="issueproperty", + name="filters", + field=models.JSONField( + default=plane.db.models.issue.get_default_filters + ), + ), + migrations.AddField( + model_name="issueview", + name="display_filters", + field=models.JSONField( + default=plane.db.models.view.get_default_display_filters + ), + ), + migrations.AddField( + model_name="issueview", + name="display_properties", + field=models.JSONField( + default=plane.db.models.view.get_default_display_properties + ), + ), + migrations.AddField( + model_name="issueview", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.AlterField( + model_name="issueview", + 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="WorkspaceUserProperties", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "filters", + models.JSONField( + default=plane.db.models.workspace.get_default_filters + ), + ), + ( + "display_filters", + models.JSONField( + default=plane.db.models.workspace.get_default_display_filters + ), + ), + ( + "display_properties", + models.JSONField( + default=plane.db.models.workspace.get_default_display_properties + ), + ), + ( + "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="workspace_user_properties", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_user_properties", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace User Property", + "verbose_name_plural": "Workspace User Property", + "db_table": "workspace_user_properties", + "ordering": ("-created_at",), + "unique_together": {("workspace", "user")}, + }, + ), + migrations.CreateModel( + name="ModuleUserProperties", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "filters", + models.JSONField( + default=plane.db.models.module.get_default_filters + ), + ), + ( + "display_filters", + models.JSONField( + default=plane.db.models.module.get_default_display_filters + ), + ), + ( + "display_properties", + models.JSONField( + default=plane.db.models.module.get_default_display_properties + ), + ), + ( + "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", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="module_user_properties", + to="db.module", + ), + ), + ( + "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", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="module_user_properties", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Module User Property", + "verbose_name_plural": "Module User Property", + "db_table": "module_user_properties", + "ordering": ("-created_at",), + "unique_together": {("module", "user")}, + }, + ), + migrations.CreateModel( + name="CycleUserProperties", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "filters", + models.JSONField( + default=plane.db.models.cycle.get_default_filters + ), + ), + ( + "display_filters", + models.JSONField( + default=plane.db.models.cycle.get_default_display_filters + ), + ), + ( + "display_properties", + models.JSONField( + default=plane.db.models.cycle.get_default_display_properties + ), + ), + ( + "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="cycle_user_properties", + to="db.cycle", + ), + ), + ( + "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", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cycle_user_properties", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Cycle User Property", + "verbose_name_plural": "Cycle User Properties", + "db_table": "cycle_user_properties", + "ordering": ("-created_at",), + "unique_together": {("cycle", "user")}, + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0053_auto_20240102_1315.py b/apps/api/plane/db/migrations/0053_auto_20240102_1315.py new file mode 100644 index 00000000..666e5148 --- /dev/null +++ b/apps/api/plane/db/migrations/0053_auto_20240102_1315.py @@ -0,0 +1,82 @@ +# Generated by Django 4.2.7 on 2024-01-02 13:15 +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( + WorkspaceUserProperties( + user_id=workspace_members.member_id, + display_filters=workspace_members.view_props.get( + "display_filters" + ), + display_properties=workspace_members.view_props.get( + "display_properties" + ), + workspace_id=workspace_members.workspace_id, + ) + ) + WorkspaceUserProperties.objects.bulk_create( + 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( + project_id=issue_property.project_id, + member_id=issue_property.user_id, + ).first() + if project_member: + issue_property.filters = project_member.view_props.get("filters") + issue_property.display_filters = project_member.view_props.get( + "display_filters" + ) + updated_issue_user_properties.append(issue_property) + + IssueProperty.objects.bulk_update( + updated_issue_user_properties, + ["filters", "display_filters"], + batch_size=2000, + ) + + +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(): + updated_issue_views.append( + IssueView( + workspace_id=global_view.workspace_id, + name=global_view.name, + description=global_view.description, + query=global_view.query, + access=global_view.access, + filters=global_view.query_data.get("filters", {}), + sort_order=global_view.sort_order, + created_by_id=global_view.created_by_id, + updated_by_id=global_view.updated_by_id, + ) + ) + IssueView.objects.bulk_create(updated_issue_views, batch_size=100) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0052_auto_20231220_1141"), + ] + + operations = [ + migrations.RunPython(workspace_user_properties), + migrations.RunPython(project_user_properties), + migrations.RunPython(issue_view), + ] diff --git a/apps/api/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py b/apps/api/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py new file mode 100644 index 00000000..b3b5cc8c --- /dev/null +++ b/apps/api/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py @@ -0,0 +1,210 @@ +# Generated by Django 4.2.7 on 2024-01-08 06:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0053_auto_20240102_1315"), + ] + + operations = [ + migrations.CreateModel( + name="Dashboard", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ( + "description_html", + models.TextField(blank=True, default="

    "), + ), + ("identifier", models.UUIDField(null=True)), + ("is_default", models.BooleanField(default=False)), + ( + "type_identifier", + models.CharField( + choices=[ + ("workspace", "Workspace"), + ("project", "Project"), + ("home", "Home"), + ("team", "Team"), + ("user", "User"), + ], + default="home", + max_length=30, + verbose_name="Dashboard Type", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owned_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="dashboards", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "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": "Dashboard", + "verbose_name_plural": "Dashboards", + "db_table": "dashboards", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Widget", + fields=[ + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ("key", models.CharField(max_length=255)), + ("filters", models.JSONField(default=dict)), + ], + options={ + "verbose_name": "Widget", + "verbose_name_plural": "Widgets", + "db_table": "widgets", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="DashboardWidget", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("is_visible", models.BooleanField(default=True)), + ("sort_order", models.FloatField(default=65535)), + ("filters", models.JSONField(default=dict)), + ("properties", 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", + ), + ), + ( + "dashboard", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="dashboard_widgets", + to="db.dashboard", + ), + ), + ( + "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", + ), + ), + ( + "widget", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="dashboard_widgets", + to="db.widget", + ), + ), + ], + options={ + "verbose_name": "Dashboard Widget", + "verbose_name_plural": "Dashboard Widgets", + "db_table": "dashboard_widgets", + "ordering": ("-created_at",), + "unique_together": {("widget", "dashboard")}, + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0055_auto_20240108_0648.py b/apps/api/plane/db/migrations/0055_auto_20240108_0648.py new file mode 100644 index 00000000..b13fcdea --- /dev/null +++ b/apps/api/plane/db/migrations/0055_auto_20240108_0648.py @@ -0,0 +1,99 @@ +# Generated by Django 4.2.7 on 2024-01-08 06:48 + +from django.db import migrations + + +def create_widgets(apps, schema_editor): + Widget = apps.get_model("db", "Widget") + widgets_list = [ + {"key": "overview_stats", "filters": {}}, + { + "key": "assigned_issues", + "filters": { + "duration": "this_week", + "tab": "upcoming", + }, + }, + { + "key": "created_issues", + "filters": { + "duration": "this_week", + "tab": "upcoming", + }, + }, + { + "key": "issues_by_state_groups", + "filters": { + "duration": "this_week", + }, + }, + { + "key": "issues_by_priority", + "filters": { + "duration": "this_week", + }, + }, + {"key": "recent_activity", "filters": {}}, + {"key": "recent_projects", "filters": {}}, + {"key": "recent_collaborators", "filters": {}}, + ] + Widget.objects.bulk_create( + [ + Widget( + key=widget["key"], + filters=widget["filters"], + ) + for widget in widgets_list + ], + batch_size=10, + ) + + +def create_dashboards(apps, schema_editor): + Dashboard = apps.get_model("db", "Dashboard") + User = apps.get_model("db", "User") + Dashboard.objects.bulk_create( + [ + Dashboard( + name="Home dashboard", + description_html="

    ", + identifier=None, + owned_by_id=user_id, + type_identifier="home", + is_default=True, + ) + for user_id in User.objects.values_list("id", flat=True) + ], + batch_size=2000, + ) + + +def create_dashboard_widgets(apps, schema_editor): + Widget = apps.get_model("db", "Widget") + Dashboard = apps.get_model("db", "Dashboard") + DashboardWidget = apps.get_model("db", "DashboardWidget") + + updated_dashboard_widget = [ + DashboardWidget( + widget_id=widget_id, + dashboard_id=dashboard_id, + ) + for widget_id in Widget.objects.values_list("id", flat=True) + for dashboard_id in Dashboard.objects.values_list("id", flat=True) + ] + + DashboardWidget.objects.bulk_create( + updated_dashboard_widget, batch_size=2000 + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0054_dashboard_widget_dashboardwidget"), + ] + + operations = [ + migrations.RunPython(create_widgets), + migrations.RunPython(create_dashboards), + migrations.RunPython(create_dashboard_widgets), + ] diff --git a/apps/api/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py b/apps/api/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py new file mode 100644 index 00000000..2e664594 --- /dev/null +++ b/apps/api/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py @@ -0,0 +1,184 @@ +# Generated by Django 4.2.7 on 2024-01-22 08:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0055_auto_20240108_0648"), + ] + + operations = [ + migrations.CreateModel( + name="UserNotificationPreference", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("property_change", models.BooleanField(default=True)), + ("state_change", models.BooleanField(default=True)), + ("comment", models.BooleanField(default=True)), + ("mention", models.BooleanField(default=True)), + ("issue_completed", 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", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_notification_preferences", + 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", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notification_preferences", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_notification_preferences", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "UserNotificationPreference", + "verbose_name_plural": "UserNotificationPreferences", + "db_table": "user_notification_preferences", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="EmailNotificationLog", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("entity_identifier", models.UUIDField(null=True)), + ("entity_name", models.CharField(max_length=255)), + ("data", models.JSONField(null=True)), + ("processed_at", models.DateTimeField(null=True)), + ("sent_at", models.DateTimeField(null=True)), + ("entity", models.CharField(max_length=200)), + ( + "old_value", + models.CharField(blank=True, max_length=300, null=True), + ), + ( + "new_value", + models.CharField(blank=True, max_length=300, 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", + ), + ), + ( + "receiver", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="email_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "triggered_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="triggered_emails", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "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": "Email Notification Log", + "verbose_name_plural": "Email Notification Logs", + "db_table": "email_notification_logs", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0057_auto_20240122_0901.py b/apps/api/plane/db/migrations/0057_auto_20240122_0901.py new file mode 100644 index 00000000..a143917d --- /dev/null +++ b/apps/api/plane/db/migrations/0057_auto_20240122_0901.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.7 on 2024-01-22 09:01 + +from django.db import migrations + + +def create_notification_preferences(apps, schema_editor): + UserNotificationPreference = apps.get_model( + "db", "UserNotificationPreference" + ) + User = apps.get_model("db", "User") + + bulk_notification_preferences = [] + for user_id in User.objects.filter(is_bot=False).values_list( + "id", flat=True + ): + bulk_notification_preferences.append( + UserNotificationPreference( + user_id=user_id, + created_by_id=user_id, + ) + ) + UserNotificationPreference.objects.bulk_create( + bulk_notification_preferences, batch_size=1000, ignore_conflicts=True + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0056_usernotificationpreference_emailnotificationlog"), + ] + + operations = [migrations.RunPython(create_notification_preferences)] diff --git a/apps/api/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py b/apps/api/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py new file mode 100644 index 00000000..411cd47b --- /dev/null +++ b/apps/api/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.7 on 2024-01-24 18:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0057_auto_20240122_0901"), + ] + + operations = [ + migrations.AlterField( + model_name="moduleissue", + name="issue", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_module", + to="db.issue", + ), + ), + migrations.AlterUniqueTogether( + name="moduleissue", + unique_together={("issue", "module")}, + ), + ] diff --git a/apps/api/plane/db/migrations/0059_auto_20240208_0957.py b/apps/api/plane/db/migrations/0059_auto_20240208_0957.py new file mode 100644 index 00000000..30d816a9 --- /dev/null +++ b/apps/api/plane/db/migrations/0059_auto_20240208_0957.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.7 on 2024-02-08 09:57 + +from django.db import migrations + + +def widgets_filter_change(apps, schema_editor): + Widget = apps.get_model("db", "Widget") + widgets_to_update = [] + + # Define the filter dictionaries for each widget key + filters_mapping = { + "assigned_issues": {"duration": "none", "tab": "pending"}, + "created_issues": {"duration": "none", "tab": "pending"}, + "issues_by_state_groups": {"duration": "none"}, + "issues_by_priority": {"duration": "none"}, + } + + # Iterate over widgets and update filters if applicable + for widget in Widget.objects.all(): + if widget.key in filters_mapping: + widget.filters = filters_mapping[widget.key] + widgets_to_update.append(widget) + + # Bulk update the widgets + Widget.objects.bulk_update(widgets_to_update, ["filters"], batch_size=10) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0058_alter_moduleissue_issue_and_more"), + ] + operations = [migrations.RunPython(widgets_filter_change)] diff --git a/apps/api/plane/db/migrations/0060_cycle_progress_snapshot.py b/apps/api/plane/db/migrations/0060_cycle_progress_snapshot.py new file mode 100644 index 00000000..575836a3 --- /dev/null +++ b/apps/api/plane/db/migrations/0060_cycle_progress_snapshot.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2024-02-08 09:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0059_auto_20240208_0957"), + ] + + operations = [ + migrations.AddField( + model_name="cycle", + name="progress_snapshot", + field=models.JSONField(default=dict), + ), + ] diff --git a/apps/api/plane/db/migrations/0061_project_logo_props.py b/apps/api/plane/db/migrations/0061_project_logo_props.py new file mode 100644 index 00000000..d8752d9d --- /dev/null +++ b/apps/api/plane/db/migrations/0061_project_logo_props.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.7 on 2024-03-03 16:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + def update_project_logo_props(apps, schema_editor): + Project = apps.get_model("db", "Project") + + bulk_update_project_logo = [] + # Iterate through projects and update logo_props + for project in Project.objects.all(): + project.logo_props["in_use"] = "emoji" if project.emoji else "icon" + project.logo_props["emoji"] = { + "value": project.emoji if project.emoji else "", + "url": "", + } + project.logo_props["icon"] = { + "name": ( + project.icon_prop.get("name", "") + if project.icon_prop + else "" + ), + "color": ( + project.icon_prop.get("color", "") + if project.icon_prop + else "" + ), + } + bulk_update_project_logo.append(project) + + # Bulk update logo_props for all projects + Project.objects.bulk_update( + bulk_update_project_logo, ["logo_props"], batch_size=1000 + ) + + dependencies = [ + ("db", "0060_cycle_progress_snapshot"), + ] + + operations = [ + migrations.AlterField( + model_name="issuelink", + name="url", + field=models.TextField(), + ), + migrations.AddField( + model_name="project", + name="logo_props", + field=models.JSONField(default=dict), + ), + migrations.RunPython(update_project_logo_props), + ] diff --git a/apps/api/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py b/apps/api/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py new file mode 100644 index 00000000..be3f9fc2 --- /dev/null +++ b/apps/api/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.7 on 2024-03-19 08:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0061_project_logo_props'), + ] + + operations = [ + migrations.AddField( + model_name="cycle", + name="archived_at", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="module", + name="archived_at", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="project", + name="archived_at", + field=models.DateTimeField(null=True), + ), + migrations.AlterField( + model_name="socialloginconnection", + name="medium", + field=models.CharField( + choices=[ + ("Google", "google"), + ("Github", "github"), + ("Jira", "jira"), + ], + default=None, + max_length=20, + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0063_state_is_triage_alter_state_group.py b/apps/api/plane/db/migrations/0063_state_is_triage_alter_state_group.py new file mode 100644 index 00000000..66303dfe --- /dev/null +++ b/apps/api/plane/db/migrations/0063_state_is_triage_alter_state_group.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.10 on 2024-04-02 12:18 + +from django.db import migrations, models + + +def update_project_state_group(apps, schema_editor): + State = apps.get_model("db", "State") + + # Update states in bulk + State.objects.filter(group="backlog", name="Triage").update( + is_triage=True, group="triage" + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0062_cycle_archived_at_module_archived_at_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="state", + name="is_triage", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="state", + name="group", + field=models.CharField( + choices=[ + ("backlog", "Backlog"), + ("unstarted", "Unstarted"), + ("started", "Started"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ("triage", "Triage"), + ], + default="backlog", + max_length=20, + ), + ), + migrations.RunPython(update_project_state_group), + ] diff --git a/apps/api/plane/db/migrations/0064_auto_20240409_1134.py b/apps/api/plane/db/migrations/0064_auto_20240409_1134.py new file mode 100644 index 00000000..53e5938a --- /dev/null +++ b/apps/api/plane/db/migrations/0064_auto_20240409_1134.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.10 on 2024-04-09 11:34 + +from django.db import migrations, models +import plane.db.models.page + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0063_state_is_triage_alter_state_group'), + ] + + operations = [ + migrations.AddField( + model_name="page", + name="view_props", + field=models.JSONField( + default=plane.db.models.page.get_view_props + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0065_auto_20240415_0937.py b/apps/api/plane/db/migrations/0065_auto_20240415_0937.py new file mode 100644 index 00000000..4698c712 --- /dev/null +++ b/apps/api/plane/db/migrations/0065_auto_20240415_0937.py @@ -0,0 +1,462 @@ +# Generated by Django 4.2.10 on 2024-04-04 08:47 + +import uuid + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + +import plane.db.models.user + + +def migrate_user_profile(apps, schema_editor): + Profile = apps.get_model("db", "Profile") + User = apps.get_model("db", "User") + + Profile.objects.bulk_create( + [ + Profile( + user_id=user.get("id"), + theme=user.get("theme"), + is_tour_completed=user.get("is_tour_completed"), + use_case=user.get("use_case"), + is_onboarded=user.get("is_onboarded"), + last_workspace_id=user.get("last_workspace_id"), + billing_address_country=user.get("billing_address_country"), + billing_address=user.get("billing_address"), + has_billing_address=user.get("has_billing_address"), + ) + for user in User.objects.values( + "id", + "theme", + "is_tour_completed", + "onboarding_step", + "use_case", + "role", + "is_onboarded", + "last_workspace_id", + "billing_address_country", + "billing_address", + "has_billing_address", + ) + ], + batch_size=1000, + ) + + +def user_favorite_migration(apps, schema_editor): + # Import the models + CycleFavorite = apps.get_model("db", "CycleFavorite") + ModuleFavorite = apps.get_model("db", "ModuleFavorite") + ProjectFavorite = apps.get_model("db", "ProjectFavorite") + PageFavorite = apps.get_model("db", "PageFavorite") + IssueViewFavorite = apps.get_model("db", "IssueViewFavorite") + UserFavorite = apps.get_model("db", "UserFavorite") + + # List of source models + source_models = [ + CycleFavorite, + ModuleFavorite, + ProjectFavorite, + PageFavorite, + IssueViewFavorite, + ] + + entity_mapper = { + "CycleFavorite": "cycle", + "ModuleFavorite": "module", + "ProjectFavorite": "project", + "PageFavorite": "page", + "IssueViewFavorite": "view", + } + + for source_model in source_models: + entity_type = entity_mapper[source_model.__name__] + UserFavorite.objects.bulk_create( + [ + UserFavorite( + user_id=obj.user_id, + entity_type=entity_type, + entity_identifier=str(getattr(obj, entity_type).id), + project_id=obj.project_id, + workspace_id=obj.workspace_id, + created_by_id=obj.created_by_id, + updated_by_id=obj.updated_by_id, + ) + for obj in source_model.objects.all().iterator() + ], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0064_auto_20240409_1134"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="avatar", + field=models.TextField(blank=True), + ), + migrations.CreateModel( + name="Session", + fields=[ + ( + "session_data", + models.TextField(verbose_name="session data"), + ), + ( + "expire_date", + models.DateTimeField( + db_index=True, verbose_name="expire date" + ), + ), + ( + "device_info", + models.JSONField(blank=True, default=None, null=True), + ), + ( + "session_key", + models.CharField( + max_length=128, primary_key=True, serialize=False + ), + ), + ("user_id", models.CharField(max_length=50, null=True)), + ], + options={ + "verbose_name": "session", + "verbose_name_plural": "sessions", + "db_table": "sessions", + "abstract": False, + }, + ), + migrations.CreateModel( + name="Profile", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("theme", models.JSONField(default=dict)), + ("is_tour_completed", models.BooleanField(default=False)), + ( + "onboarding_step", + models.JSONField( + default=plane.db.models.user.get_default_onboarding + ), + ), + ("use_case", models.TextField(blank=True, null=True)), + ( + "role", + models.CharField(blank=True, max_length=300, null=True), + ), + ("is_onboarded", models.BooleanField(default=False)), + ("last_workspace_id", models.UUIDField(null=True)), + ( + "billing_address_country", + models.CharField(default="INDIA", max_length=255), + ), + ("billing_address", models.JSONField(null=True)), + ("has_billing_address", models.BooleanField(default=False)), + ("company_name", models.CharField(blank=True, max_length=255)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Profile", + "verbose_name_plural": "Profiles", + "db_table": "profiles", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Account", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("provider_account_id", models.CharField(max_length=255)), + ( + "provider", + models.CharField( + choices=[("google", "Google"), ("github", "Github")] + ), + ), + ("access_token", models.TextField()), + ("access_token_expired_at", models.DateTimeField(null=True)), + ("refresh_token", models.TextField(blank=True, null=True)), + ("refresh_token_expired_at", models.DateTimeField(null=True)), + ( + "last_connected_at", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("metadata", models.JSONField(default=dict)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="accounts", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Account", + "verbose_name_plural": "Accounts", + "db_table": "accounts", + "ordering": ("-created_at",), + "unique_together": {("provider", "provider_account_id")}, + }, + ), + migrations.RunPython(migrate_user_profile), + migrations.RemoveField( + model_name="user", + name="billing_address", + ), + migrations.RemoveField( + model_name="user", + name="billing_address_country", + ), + migrations.RemoveField( + model_name="user", + name="has_billing_address", + ), + migrations.RemoveField( + model_name="user", + name="is_onboarded", + ), + migrations.RemoveField( + model_name="user", + name="is_tour_completed", + ), + migrations.RemoveField( + model_name="user", + name="last_workspace_id", + ), + migrations.RemoveField( + model_name="user", + name="my_issues_prop", + ), + migrations.RemoveField( + model_name="user", + name="onboarding_step", + ), + migrations.RemoveField( + model_name="user", + name="role", + ), + migrations.RemoveField( + model_name="user", + name="theme", + ), + migrations.RemoveField( + model_name="user", + name="use_case", + ), + migrations.AddField( + model_name="globalview", + name="logo_props", + field=models.JSONField(default=dict), + ), + # Pages + migrations.AddField( + model_name="page", + name="logo_props", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="page", + name="description_binary", + field=models.BinaryField(null=True), + ), + migrations.AlterField( + model_name="page", + name="name", + field=models.CharField(blank=True, max_length=255), + ), + # Estimates + migrations.AddField( + model_name="estimate", + name="type", + field=models.CharField(default="Categories", max_length=255), + ), + migrations.AlterField( + model_name="estimatepoint", + name="key", + field=models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(12), + ], + ), + ), + migrations.AlterField( + model_name="issue", + name="estimate_point", + field=models.IntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(12), + ], + ), + ), + # workspace user properties + migrations.AlterModelTable( + name="workspaceuserproperties", + table="workspace_user_properties", + ), + # Favorites + migrations.CreateModel( + name="UserFavorite", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("entity_type", models.CharField(max_length=100)), + ("entity_identifier", models.UUIDField(blank=True, null=True)), + ( + "name", + models.CharField(blank=True, max_length=255, null=True), + ), + ("is_folder", models.BooleanField(default=False)), + ("sequence", models.IntegerField(default=65535)), + ( + "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", + ), + ), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="parent_folder", + to="db.userfavorite", + ), + ), + ( + "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", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "User Favorite", + "verbose_name_plural": "User Favorites", + "db_table": "user_favorites", + "ordering": ("-created_at",), + "unique_together": { + ("entity_type", "user", "entity_identifier") + }, + }, + ), + migrations.RunPython(user_favorite_migration), + ] diff --git a/apps/api/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py b/apps/api/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py new file mode 100644 index 00000000..2ad5b748 --- /dev/null +++ b/apps/api/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py @@ -0,0 +1,58 @@ +# Generated by Django 4.2.11 on 2024-05-22 15:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0065_auto_20240415_0937"), + ] + + operations = [ + migrations.AddField( + model_name="account", + name="id_token", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="cycle", + name="logo_props", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="module", + name="logo_props", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="issueview", + name="logo_props", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="inbox", + name="logo_props", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="dashboard", + name="logo_props", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="widget", + name="logo_props", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="issue", + name="description_binary", + field=models.BinaryField(null=True), + ), + migrations.AddField( + model_name="team", + name="logo_props", + field=models.JSONField(default=dict), + ), + ] diff --git a/apps/api/plane/db/migrations/0067_issue_estimate.py b/apps/api/plane/db/migrations/0067_issue_estimate.py new file mode 100644 index 00000000..b341f986 --- /dev/null +++ b/apps/api/plane/db/migrations/0067_issue_estimate.py @@ -0,0 +1,260 @@ +# # Generated by Django 4.2.7 on 2024-05-24 09:47 +# Python imports +import uuid +from uuid import uuid4 +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +import plane.db.models.deploy_board + + +def issue_estimate_point(apps, schema_editor): + Issue = apps.get_model("db", "Issue") + Project = apps.get_model("db", "Project") + EstimatePoint = apps.get_model("db", "EstimatePoint") + IssueActivity = apps.get_model("db", "IssueActivity") + updated_estimate_point = [] + updated_issue_activity = [] + + # loop through all the projects + for project in Project.objects.filter(estimate__isnull=False): + estimate_points = EstimatePoint.objects.filter( + estimate=project.estimate, project=project + ) + + for issue_activity in IssueActivity.objects.filter( + field="estimate_point", project=project + ): + if issue_activity.new_value: + new_identifier = estimate_points.filter( + key=issue_activity.new_value + ).first().id + issue_activity.new_identifier = new_identifier + new_value = estimate_points.filter( + key=issue_activity.new_value + ).first().value + issue_activity.new_value = new_value + + if issue_activity.old_value: + old_identifier = estimate_points.filter( + key=issue_activity.old_value + ).first().id + issue_activity.old_identifier = old_identifier + old_value = estimate_points.filter( + key=issue_activity.old_value + ).first().value + issue_activity.old_value = old_value + updated_issue_activity.append(issue_activity) + + for issue in Issue.objects.filter( + point__isnull=False, project=project + ): + # get the estimate id for the corresponding estimate point in the issue + estimate = estimate_points.filter(key=issue.point).first() + issue.estimate_point = estimate + updated_estimate_point.append(issue) + + Issue.objects.bulk_update( + updated_estimate_point, ["estimate_point"], batch_size=1000 + ) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["new_value", "old_value", "new_identifier", "old_identifier"], + batch_size=1000, + ) + + +def last_used_estimate(apps, schema_editor): + Project = apps.get_model("db", "Project") + Estimate = apps.get_model("db", "Estimate") + + # Get all estimate ids used in projects + estimate_ids = Project.objects.filter(estimate__isnull=False).values_list( + "estimate", flat=True + ) + + # Update all matching estimates + Estimate.objects.filter(id__in=estimate_ids).update(last_used=True) + + +def populate_deploy_board(apps, schema_editor): + DeployBoard = apps.get_model("db", "DeployBoard") + ProjectDeployBoard = apps.get_model("db", "ProjectDeployBoard") + + DeployBoard.objects.bulk_create( + [ + DeployBoard( + entity_identifier=deploy_board.project_id, + project_id=deploy_board.project_id, + entity_name="project", + anchor=uuid4().hex, + is_comments_enabled=deploy_board.comments, + is_reactions_enabled=deploy_board.reactions, + inbox=deploy_board.inbox, + is_votes_enabled=deploy_board.votes, + view_props=deploy_board.views, + workspace_id=deploy_board.workspace_id, + created_at=deploy_board.created_at, + updated_at=deploy_board.updated_at, + created_by_id=deploy_board.created_by_id, + updated_by_id=deploy_board.updated_by_id, + ) + for deploy_board in ProjectDeployBoard.objects.all() + ], + batch_size=100, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0066_account_id_token_cycle_logo_props_module_logo_props"), + ] + + operations = [ + migrations.CreateModel( + name="DeployBoard", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("entity_identifier", models.UUIDField(null=True)), + ( + "entity_name", + models.CharField( + choices=[ + ("project", "Project"), + ("issue", "Issue"), + ("module", "Module"), + ("cycle", "Task"), + ("page", "Page"), + ("view", "View"), + ], + max_length=30, + ), + ), + ( + "anchor", + models.CharField( + db_index=True, + default=plane.db.models.deploy_board.get_anchor, + max_length=255, + unique=True, + ), + ), + ("is_comments_enabled", models.BooleanField(default=False)), + ("is_reactions_enabled", models.BooleanField(default=False)), + ("is_votes_enabled", models.BooleanField(default=False)), + ("view_props", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "inbox", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="board_inbox", + to="db.inbox", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Deploy Board", + "verbose_name_plural": "Deploy Boards", + "db_table": "deploy_boards", + "ordering": ("-created_at",), + "unique_together": {("entity_name", "entity_identifier")}, + }, + ), + migrations.AddField( + model_name="estimate", + name="last_used", + field=models.BooleanField(default=False), + ), + # Rename the existing field + migrations.RenameField( + model_name="issue", + old_name="estimate_point", + new_name="point", + ), + # Add a new field with the original name as a foreign key + migrations.AddField( + model_name="issue", + name="estimate_point", + field=models.ForeignKey( + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_estimates", + to="db.EstimatePoint", + blank=True, + null=True, + ), + ), + migrations.AlterField( + model_name="estimate", + name="type", + field=models.CharField(default="categories", max_length=255), + ), + migrations.AlterField( + model_name="estimatepoint", + name="value", + field=models.CharField(max_length=255), + ), + migrations.RunPython(issue_estimate_point), + migrations.RunPython(last_used_estimate), + migrations.RunPython(populate_deploy_board), + ] diff --git a/apps/api/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py b/apps/api/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py new file mode 100644 index 00000000..50475c2a --- /dev/null +++ b/apps/api/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py @@ -0,0 +1,257 @@ +# Generated by Django 4.2.11 on 2024-06-07 12:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +def migrate_pages(apps, schema_editor): + ProjectPage = apps.get_model("db", "ProjectPage") + Page = apps.get_model("db", "Page") + ProjectPage.objects.bulk_create( + [ + ProjectPage( + workspace_id=page.get("workspace_id"), + project_id=page.get("project_id"), + page_id=page.get("id"), + created_by_id=page.get("created_by_id"), + updated_by_id=page.get("updated_by_id"), + ) + for page in Page.objects.values( + "workspace_id", + "project_id", + "id", + "created_by_id", + "updated_by_id", + ) + ], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0067_issue_estimate"), + ] + + operations = [ + migrations.AddField( + model_name="page", + name="is_global", + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name="ProjectPage", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pages", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pages", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pages", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Page", + "verbose_name_plural": "Project Pages", + "db_table": "project_pages", + "ordering": ("-created_at",), + "unique_together": {("project", "page")}, + }, + ), + migrations.CreateModel( + name="TeamPage", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_pages", + to="db.page", + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_pages", + to="db.team", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_pages", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Team Page", + "verbose_name_plural": "Team Pages", + "db_table": "team_pages", + "ordering": ("-created_at",), + "unique_together": {("team", "page")}, + }, + ), + migrations.AddField( + model_name="page", + name="projects", + field=models.ManyToManyField( + related_name="pages", through="db.ProjectPage", to="db.project" + ), + ), + migrations.AddField( + model_name="page", + name="teams", + field=models.ManyToManyField( + related_name="pages", through="db.TeamPage", to="db.team" + ), + ), + migrations.RunPython(migrate_pages), + migrations.RemoveField( + model_name="page", + name="project", + ), + migrations.AlterField( + model_name="page", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pages", + to="db.workspace", + ), + ), + migrations.RemoveField( + model_name="pagelabel", + name="project", + ), + migrations.RemoveField( + model_name="pagelog", + name="project", + ), + migrations.AlterField( + model_name="pagelabel", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_page_label", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="pagelog", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_page_log", + to="db.workspace", + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0069_alter_account_provider_and_more.py b/apps/api/plane/db/migrations/0069_alter_account_provider_and_more.py new file mode 100644 index 00000000..8c806aba --- /dev/null +++ b/apps/api/plane/db/migrations/0069_alter_account_provider_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 4.2.11 on 2024-06-03 17:16 + +from django.db import migrations, models +from django.conf import settings +from django.db.models import F +import django.db.models.deletion + + +def populate_views_owned_by(apps, schema_editor): + IssueView = apps.get_model("db", "IssueView") + + # update all existing views to be owned by the user who created them + IssueView.objects.update(owned_by_id=F("created_by_id")) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0068_remove_pagelabel_project_remove_pagelog_project_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name="account", + name="provider", + field=models.CharField( + choices=[ + ("google", "Google"), + ("github", "Github"), + ("gitlab", "GitLab"), + ] + ), + ), + migrations.AlterField( + model_name="socialloginconnection", + name="medium", + field=models.CharField( + choices=[ + ("Google", "google"), + ("Github", "github"), + ("GitLab", "gitlab"), + ("Jira", "jira"), + ], + default=None, + max_length=20, + ), + ), + migrations.AddField( + model_name="issueview", + name="is_locked", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="issueview", + name="owned_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="views", + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), + migrations.RunPython(populate_views_owned_by), + migrations.AlterField( + model_name="issueview", + name="owned_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="views", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0070_apitoken_is_service_exporterhistory_filters_and_more.py b/apps/api/plane/db/migrations/0070_apitoken_is_service_exporterhistory_filters_and_more.py new file mode 100644 index 00000000..0a81c578 --- /dev/null +++ b/apps/api/plane/db/migrations/0070_apitoken_is_service_exporterhistory_filters_and_more.py @@ -0,0 +1,106 @@ +# Generated by Django 4.2.11 on 2024-07-10 13:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0069_alter_account_provider_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='apitoken', + name='is_service', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='exporterhistory', + name='filters', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='exporterhistory', + name='name', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Exporter Name'), + ), + migrations.AddField( + model_name='exporterhistory', + name='type', + field=models.CharField(choices=[('issue_exports', 'Issue Exports'), ('issue_worklogs', 'Issue Worklogs')], default='issue_exports', max_length=50), + ), + migrations.AddField( + model_name='project', + name='is_time_tracking_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='project', + name='start_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='target_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.CreateModel( + name='PageVersion', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('last_saved_at', models.DateTimeField(default=django.utils.timezone.now)), + ('description_binary', models.BinaryField(null=True)), + ('description_html', models.TextField(blank=True, default='

    ')), + ('description_stripped', models.TextField(blank=True, null=True)), + ('description_json', models.JSONField(blank=True, 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')), + ('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_versions', to=settings.AUTH_USER_MODEL)), + ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_versions', to='db.page')), + ('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='page_versions', to='db.workspace')), + ], + options={ + 'verbose_name': 'Page Version', + 'verbose_name_plural': 'Page Versions', + 'db_table': 'page_versions', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueType', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('logo_props', models.JSONField(default=dict)), + ('sort_order', models.FloatField(default=65535)), + ('is_default', models.BooleanField(default=True)), + ('weight', models.PositiveIntegerField(default=0)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('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': 'Issue Type', + 'verbose_name_plural': 'Issue Types', + 'db_table': 'issue_types', + 'ordering': ('sort_order',), + 'unique_together': {('project', 'name')}, + }, + ), + migrations.AddField( + model_name='issue', + name='type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_type', to='db.issuetype'), + ), + ] diff --git a/apps/api/plane/db/migrations/0071_rename_issueproperty_issueuserproperty_and_more.py b/apps/api/plane/db/migrations/0071_rename_issueproperty_issueuserproperty_and_more.py new file mode 100644 index 00000000..b3823724 --- /dev/null +++ b/apps/api/plane/db/migrations/0071_rename_issueproperty_issueuserproperty_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.11 on 2024-07-15 06:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0070_apitoken_is_service_exporterhistory_filters_and_more"), + ] + + operations = [ + migrations.RenameModel( + old_name="IssueProperty", + new_name="IssueUserProperty", + ), + migrations.AlterModelOptions( + name="issueuserproperty", + options={ + "ordering": ("-created_at",), + "verbose_name": "Issue User Property", + "verbose_name_plural": "Issue User Properties", + }, + ), + migrations.AlterModelTable( + name="issueuserproperty", + table="issue_user_properties", + ), + migrations.AddField( + model_name="issuetype", + name="is_active", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="project", + name="is_issue_type_enabled", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="issuetype", + name="is_default", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/api/plane/db/migrations/0072_issueattachment_external_id_and_more.py b/apps/api/plane/db/migrations/0072_issueattachment_external_id_and_more.py new file mode 100644 index 00000000..73d67aad --- /dev/null +++ b/apps/api/plane/db/migrations/0072_issueattachment_external_id_and_more.py @@ -0,0 +1,145 @@ +# Generated by Django 4.2.14 on 2024-07-22 13:22 +from django.db import migrations, models +from django.conf import settings +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0071_rename_issueproperty_issueuserproperty_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="issueattachment", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issueattachment", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.CreateModel( + name="UserRecentVisit", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("entity_identifier", models.UUIDField(null=True)), + ( + "entity_name", + models.CharField( + choices=[ + ("VIEW", "View"), + ("PAGE", "Page"), + ("ISSUE", "Issue"), + ("CYCLE", "Cycle"), + ("MODULE", "Module"), + ("PROJECT", "Project"), + ], + max_length=30, + ), + ), + ("visited_at", models.DateTimeField(auto_now=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", + ), + ), + ( + "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", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_recent_visit", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "User Recent Visit", + "verbose_name_plural": "User Recent Visits", + "db_table": "user_recent_visits", + "ordering": ("-created_at",), + }, + ), + migrations.RemoveField( + model_name="project", + name="start_date", + ), + migrations.RemoveField( + model_name="project", + name="target_date", + ), + migrations.AlterField( + model_name="issuesequence", + name="sequence", + field=models.PositiveBigIntegerField(db_index=True, default=1), + ), + migrations.AlterField( + model_name="project", + name="identifier", + field=models.CharField( + db_index=True, max_length=12, verbose_name="Project Identifier" + ), + ), + migrations.AlterField( + model_name="projectidentifier", + name="name", + field=models.CharField(db_index=True, max_length=12), + ), + ] diff --git a/apps/api/plane/db/migrations/0073_alter_commentreaction_unique_together_and_more.py b/apps/api/plane/db/migrations/0073_alter_commentreaction_unique_together_and_more.py new file mode 100644 index 00000000..5277553a --- /dev/null +++ b/apps/api/plane/db/migrations/0073_alter_commentreaction_unique_together_and_more.py @@ -0,0 +1,1130 @@ +# Generated by Django 4.2.11 on 2024-08-02 03:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0072_issueattachment_external_id_and_more"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="commentreaction", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="cycleuserproperties", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="dashboardwidget", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="deployboard", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="estimate", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="inbox", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="issueassignee", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="issuemention", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="issuereaction", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="issuerelation", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="issuesubscriber", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="issuetype", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="issueuserproperty", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="issuevote", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="label", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="module", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="moduleissue", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="modulemember", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="moduleuserproperties", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="project", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="projectidentifier", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="projectmember", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="projectpage", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="projectpublicmember", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="state", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="team", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="teammember", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="teampage", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="userfavorite", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="workspacemember", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="workspacememberinvite", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="workspacetheme", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="workspaceuserproperties", + unique_together=set(), + ), + migrations.AddField( + model_name="analyticview", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="apiactivitylog", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="apitoken", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="commentreaction", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="cycle", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="cyclefavorite", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="cycleissue", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="cycleuserproperties", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="dashboard", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="dashboardwidget", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="deployboard", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="emailnotificationlog", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="estimate", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="estimatepoint", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="exporterhistory", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="fileasset", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="githubcommentsync", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="githubissuesync", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="githubrepository", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="githubrepositorysync", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="globalview", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="importer", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="inbox", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="inboxissue", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="integration", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issue", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issueactivity", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issueassignee", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issueattachment", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issueblocker", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuecomment", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuelabel", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuelink", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuemention", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuereaction", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuerelation", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuesequence", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuesubscriber", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuetype", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issueuserproperty", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issueview", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issueviewfavorite", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuevote", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="label", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="module", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="modulefavorite", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="moduleissue", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="modulelink", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="modulemember", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="moduleuserproperties", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="notification", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="page", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="pageblock", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="pagefavorite", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="pagelabel", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="pagelog", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="pageversion", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="project", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="projectdeployboard", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="projectfavorite", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="projectidentifier", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="projectmember", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="projectmemberinvite", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="projectpage", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="projectpublicmember", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="slackprojectsync", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="socialloginconnection", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="state", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="team", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="teammember", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="teampage", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="userfavorite", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="usernotificationpreference", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="userrecentvisit", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="webhook", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="webhooklog", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="workspace", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="workspaceintegration", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="workspacemember", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="workspacememberinvite", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="workspacetheme", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="workspaceuserproperties", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AlterUniqueTogether( + name="commentreaction", + unique_together={("comment", "actor", "reaction", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="cycleuserproperties", + unique_together={("cycle", "user", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="dashboardwidget", + unique_together={("widget", "dashboard", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="deployboard", + unique_together={ + ("entity_name", "entity_identifier", "deleted_at") + }, + ), + migrations.AlterUniqueTogether( + name="estimate", + unique_together={("name", "project", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="inbox", + unique_together={("name", "project", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="issueassignee", + unique_together={("issue", "assignee", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="issuemention", + unique_together={("issue", "mention", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="issuereaction", + unique_together={("issue", "actor", "reaction", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="issuerelation", + unique_together={("issue", "related_issue", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="issuesubscriber", + unique_together={("issue", "subscriber", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="issuetype", + unique_together={("project", "name", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="issueuserproperty", + unique_together={("user", "project", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="issuevote", + unique_together={("issue", "actor", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="label", + unique_together={("name", "project", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="module", + unique_together={("name", "project", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="moduleissue", + unique_together={("issue", "module", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="modulemember", + unique_together={("module", "member", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="moduleuserproperties", + unique_together={("module", "user", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="project", + unique_together={ + ("identifier", "workspace", "deleted_at"), + ("name", "workspace", "deleted_at"), + }, + ), + migrations.AlterUniqueTogether( + name="projectidentifier", + unique_together={("name", "workspace", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="projectmember", + unique_together={("project", "member", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="projectpage", + unique_together={("project", "page", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="projectpublicmember", + unique_together={("project", "member", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="state", + unique_together={("name", "project", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="team", + unique_together={("name", "workspace", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="teammember", + unique_together={("team", "member", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="teampage", + unique_together={("team", "page", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="userfavorite", + unique_together={ + ("entity_type", "user", "entity_identifier", "deleted_at") + }, + ), + migrations.AlterUniqueTogether( + name="workspacemember", + unique_together={("workspace", "member", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="workspacememberinvite", + unique_together={("email", "workspace", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="workspacetheme", + unique_together={("workspace", "name", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="workspaceuserproperties", + unique_together={("workspace", "user", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="commentreaction", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("comment", "actor", "reaction"), + name="comment_reaction_unique_comment_actor_reaction_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="cycleuserproperties", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("cycle", "user"), + name="cycle_user_properties_unique_cycle_user_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="dashboardwidget", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("widget", "dashboard"), + name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="deployboard", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("entity_name", "entity_identifier"), + name="deploy_board_unique_entity_name_entity_identifier_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="estimate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name", "project"), + name="estimate_unique_name_project_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="inbox", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name", "project"), + name="inbox_unique_name_project_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="issueassignee", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("issue", "assignee"), + name="issue_assignee_unique_issue_assignee_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="issuemention", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("issue", "mention"), + name="issue_mention_unique_issue_mention_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="issuereaction", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("issue", "actor", "reaction"), + name="issue_reaction_unique_issue_actor_reaction_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="issuerelation", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("issue", "related_issue"), + name="issue_relation_unique_issue_related_issue_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="issuesubscriber", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("issue", "subscriber"), + name="issue_subscriber_unique_issue_subscriber_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="issuetype", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name", "project"), + name="issue_type_unique_name_project_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="issueuserproperty", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("user", "project"), + name="issue_user_property_unique_user_project_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="issuevote", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("issue", "actor"), + name="issue_vote_unique_issue_actor_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="label", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name", "project"), + name="label_unique_name_project_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="module", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name", "project"), + name="module_unique_name_project_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="moduleissue", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("issue", "module"), + name="module_issue_unique_issue_module_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="modulemember", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("module", "member"), + name="module_member_unique_module_member_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="moduleuserproperties", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("module", "user"), + name="module_user_properties_unique_module_user_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="project", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("identifier", "workspace"), + name="project_unique_identifier_workspace_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="project", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name", "workspace"), + name="project_unique_name_workspace_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="projectidentifier", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name", "workspace"), + name="unique_name_workspace_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="projectmember", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("project", "member"), + name="project_member_unique_project_member_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="projectpage", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("project", "page"), + name="project_page_unique_project_page_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="projectpublicmember", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("project", "member"), + name="project_public_member_unique_project_member_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="state", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name", "project"), + name="state_unique_name_project_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="team", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name", "workspace"), + name="team_unique_name_workspace_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="teammember", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("team", "member"), + name="team_member_unique_team_member_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="teampage", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("team", "page"), + name="team_page_unique_team_page_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="userfavorite", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("entity_type", "entity_identifier", "user"), + name="user_favorite_unique_entity_type_entity_identifier_user_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="workspacemember", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "member"), + name="workspace_member_unique_workspace_member_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="workspacememberinvite", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("email", "workspace"), + name="workspace_member_invite_unique_email_workspace_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="workspacetheme", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "name"), + name="workspace_theme_unique_workspace_name_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="workspaceuserproperties", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "user"), + name="workspace_user_properties_unique_workspace_user_when_deleted_at_null", + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0074_deploy_board_and_project_issues.py b/apps/api/plane/db/migrations/0074_deploy_board_and_project_issues.py new file mode 100644 index 00000000..5bbf7f47 --- /dev/null +++ b/apps/api/plane/db/migrations/0074_deploy_board_and_project_issues.py @@ -0,0 +1,203 @@ +# Generated by Django 4.2.11 on 2024-08-13 16:21 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0073_alter_commentreaction_unique_together_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="deployboard", + name="is_activity_enabled", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="fileasset", + name="is_archived", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="userfavorite", + name="sequence", + field=models.FloatField(default=65535), + ), + migrations.CreateModel( + name="ProjectIssueType", + 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, + ), + ), + ("level", models.PositiveIntegerField(default=0)), + ("is_default", models.BooleanField(default=False)), + ], + options={ + "verbose_name": "Project Issue Type", + "verbose_name_plural": "Project Issue Types", + "db_table": "project_issue_types", + "ordering": ("project", "issue_type"), + }, + ), + migrations.AlterModelOptions( + name="issuetype", + options={ + "verbose_name": "Issue Type", + "verbose_name_plural": "Issue Types", + }, + ), + migrations.RemoveConstraint( + model_name="issuetype", + name="issue_type_unique_name_project_when_deleted_at_null", + ), + migrations.AlterUniqueTogether( + name="issuetype", + unique_together=set(), + ), + migrations.AlterField( + model_name="issuetype", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_types", + to="db.workspace", + ), + ), + migrations.AlterUniqueTogether( + name="issuetype", + unique_together={("workspace", "name", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="issuetype", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name", "workspace"), + name="issue_type_unique_name_workspace_when_deleted_at_null", + ), + ), + migrations.AddField( + model_name="projectissuetype", + 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="projectissuetype", + name="issue_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issue_types", + to="db.issuetype", + ), + ), + migrations.AddField( + model_name="projectissuetype", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AddField( + model_name="projectissuetype", + 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="projectissuetype", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.RemoveField( + model_name="issuetype", + name="is_default", + ), + migrations.RemoveField( + model_name="issuetype", + name="project", + ), + migrations.RemoveField( + model_name="issuetype", + name="sort_order", + ), + migrations.RemoveField( + model_name="issuetype", + name="weight", + ), + migrations.AddConstraint( + model_name="projectissuetype", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("project", "issue_type"), + name="project_issue_type_unique_project_issue_type_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="projectissuetype", + unique_together={("project", "issue_type", "deleted_at")}, + ), + migrations.AddField( + model_name="issuetype", + name="is_default", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="issuetype", + name="level", + field=models.PositiveIntegerField(default=0), + ), + migrations.AlterUniqueTogether( + name="issuetype", + unique_together=set(), + ), + migrations.RemoveConstraint( + model_name="issuetype", + name="issue_type_unique_name_workspace_when_deleted_at_null", + ), + ] diff --git a/apps/api/plane/db/migrations/0075_alter_fileasset_asset.py b/apps/api/plane/db/migrations/0075_alter_fileasset_asset.py new file mode 100644 index 00000000..2295ddac --- /dev/null +++ b/apps/api/plane/db/migrations/0075_alter_fileasset_asset.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.11 on 2024-08-29 09:40 + +import django.core.validators +from django.db import migrations, models +import plane.db.models.asset + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0074_deploy_board_and_project_issues"), + ] + + operations = [ + migrations.AlterField( + model_name="fileasset", + name="asset", + field=models.FileField( + upload_to=plane.db.models.asset.get_upload_path, + validators=[ + django.core.validators.FileExtensionValidator( + allowed_extensions=["jpg", "jpeg", "png"] + ), + plane.db.models.asset.file_size, + ], + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0076_alter_projectmember_role_and_more.py b/apps/api/plane/db/migrations/0076_alter_projectmember_role_and_more.py new file mode 100644 index 00000000..ad051d56 --- /dev/null +++ b/apps/api/plane/db/migrations/0076_alter_projectmember_role_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.15 on 2024-08-30 07:34 + +from django.db import migrations, models + + +def update_workspace_project_member_role(apps, schema_editor): + WorkspaceMember = apps.get_model("db", "WorkspaceMember") + ProjectMember = apps.get_model("db", "ProjectMember") + + # update all existing members with role 10 to role 5 + WorkspaceMember.objects.filter(role=10).update(role=5) + ProjectMember.objects.filter(role=10).update(role=5) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0075_alter_fileasset_asset"), + ] + + operations = [ + migrations.AlterField( + model_name="projectmember", + name="role", + field=models.PositiveSmallIntegerField( + choices=[(20, "Admin"), (15, "Member"), (5, "Guest")], + default=5, + ), + ), + migrations.AlterField( + model_name="projectmemberinvite", + name="role", + field=models.PositiveSmallIntegerField( + choices=[(20, "Admin"), (15, "Member"), (5, "Guest")], + default=5, + ), + ), + migrations.AlterField( + model_name="workspacemember", + name="role", + field=models.PositiveSmallIntegerField( + choices=[(20, "Admin"), (15, "Member"), (5, "Guest")], + default=5, + ), + ), + migrations.AlterField( + model_name="workspacememberinvite", + name="role", + field=models.PositiveSmallIntegerField( + choices=[(20, "Admin"), (15, "Member"), (5, "Guest")], + default=5, + ), + ), + migrations.AddField( + model_name="project", + name="guest_view_all_features", + field=models.BooleanField(default=False), + ), + migrations.RunPython(update_workspace_project_member_role), + ] diff --git a/apps/api/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py b/apps/api/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py new file mode 100644 index 00000000..ee70f661 --- /dev/null +++ b/apps/api/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/apps/api/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py b/apps/api/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py new file mode 100644 index 00000000..3839f4e7 --- /dev/null +++ b/apps/api/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/apps/api/plane/db/migrations/0079_auto_20241009_0619.py b/apps/api/plane/db/migrations/0079_auto_20241009_0619.py new file mode 100644 index 00000000..e3fc904a --- /dev/null +++ b/apps/api/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/apps/api/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py b/apps/api/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py new file mode 100644 index 00000000..f5113019 --- /dev/null +++ b/apps/api/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/apps/api/plane/db/migrations/0081_remove_globalview_created_by_and_more.py b/apps/api/plane/db/migrations/0081_remove_globalview_created_by_and_more.py new file mode 100644 index 00000000..984f2544 --- /dev/null +++ b/apps/api/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/apps/api/plane/db/migrations/0082_alter_issue_managers_alter_cycleissue_issue_and_more.py b/apps/api/plane/db/migrations/0082_alter_issue_managers_alter_cycleissue_issue_and_more.py new file mode 100644 index 00000000..9d0279eb --- /dev/null +++ b/apps/api/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/apps/api/plane/db/migrations/0083_device_workspace_timezone_and_more.py b/apps/api/plane/db/migrations/0083_device_workspace_timezone_and_more.py new file mode 100644 index 00000000..587ee880 --- /dev/null +++ b/apps/api/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/apps/api/plane/db/migrations/0084_remove_label_label_unique_name_project_when_deleted_at_null_and_more.py b/apps/api/plane/db/migrations/0084_remove_label_label_unique_name_project_when_deleted_at_null_and_more.py new file mode 100644 index 00000000..25bfcb8f --- /dev/null +++ b/apps/api/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/apps/api/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py b/apps/api/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py new file mode 100644 index 00000000..16c4167c --- /dev/null +++ b/apps/api/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/apps/api/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py b/apps/api/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py new file mode 100644 index 00000000..d38f17c5 --- /dev/null +++ b/apps/api/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/apps/api/plane/db/migrations/0087_remove_issueversion_description_and_more.py b/apps/api/plane/db/migrations/0087_remove_issueversion_description_and_more.py new file mode 100644 index 00000000..086f5231 --- /dev/null +++ b/apps/api/plane/db/migrations/0087_remove_issueversion_description_and_more.py @@ -0,0 +1,117 @@ +# Generated by Django 4.2.17 on 2024-12-13 10:09 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import plane.db.models.user +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0086_issueversion_alter_teampage_unique_together_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='issueversion', + name='description', + ), + migrations.RemoveField( + model_name='issueversion', + name='description_binary', + ), + migrations.RemoveField( + model_name='issueversion', + name='description_html', + ), + migrations.RemoveField( + model_name='issueversion', + name='description_stripped', + ), + migrations.AddField( + model_name='issueversion', + name='activity', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='db.issueactivity'), + ), + migrations.AddField( + model_name='profile', + name='is_mobile_onboarded', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='profile', + name='mobile_onboarding_step', + field=models.JSONField(default=plane.db.models.user.get_mobile_default_onboarding), + ), + migrations.AddField( + model_name='profile', + name='mobile_timezone_auto_set', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='profile', + name='language', + field=models.CharField(default='en', max_length=255), + ), + migrations.AlterField( + model_name='issueversion', + name='owned_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_versions', to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='Sticky', + 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.TextField()), + ('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)), + ('logo_props', models.JSONField(default=dict)), + ('color', models.CharField(blank=True, max_length=255, null=True)), + ('background_color', models.CharField(blank=True, max_length=255, 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')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to=settings.AUTH_USER_MODEL)), + ('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='stickies', to='db.workspace')), + ], + options={ + 'verbose_name': 'Sticky', + 'verbose_name_plural': 'Stickies', + 'db_table': 'stickies', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueDescriptionVersion', + 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)), + ('description_binary', models.BinaryField(null=True)), + ('description_html', models.TextField(blank=True, default='

    ')), + ('description_stripped', models.TextField(blank=True, null=True)), + ('description_json', models.JSONField(blank=True, default=dict)), + ('last_saved_at', models.DateTimeField(default=django.utils.timezone.now)), + ('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='description_versions', to='db.issue')), + ('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_description_versions', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Description Version', + 'verbose_name_plural': 'Issue Description Versions', + 'db_table': 'issue_description_versions', + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py b/apps/api/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py new file mode 100644 index 00000000..1b312215 --- /dev/null +++ b/apps/api/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py @@ -0,0 +1,124 @@ +# Generated by Django 4.2.15 on 2024-12-24 14:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0087_remove_issueversion_description_and_more'), + ] + + operations = [ + migrations.AddField( + model_name="sticky", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.CreateModel( + name="WorkspaceUserLink", + 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, + ), + ), + ("title", models.CharField(blank=True, max_length=255, null=True)), + ("url", models.TextField()), + ("metadata", 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", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owner_workspace_user_link", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "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": "Workspace User Link", + "verbose_name_plural": "Workspace User Links", + "db_table": "workspace_user_links", + "ordering": ("-created_at",), + }, + ), + migrations.AlterField( + model_name="pagelog", + name="entity_name", + field=models.CharField(max_length=30, verbose_name="Transaction Type"), + ), + migrations.AlterUniqueTogether( + name="webhook", + unique_together={("workspace", "url", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="webhook", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "url"), + name="webhook_url_unique_url_when_deleted_at_null", + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0089_workspacehomepreference_and_more.py b/apps/api/plane/db/migrations/0089_workspacehomepreference_and_more.py new file mode 100644 index 00000000..b13f6507 --- /dev/null +++ b/apps/api/plane/db/migrations/0089_workspacehomepreference_and_more.py @@ -0,0 +1,120 @@ +# Generated by Django 4.2.17 on 2025-01-02 07:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0088_sticky_sort_order_workspaceuserlink"), + ] + + operations = [ + migrations.CreateModel( + name="WorkspaceHomePreference", + 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, + ), + ), + ("key", models.CharField(max_length=255)), + ("is_enabled", models.BooleanField(default=True)), + ("config", 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", + ), + ), + ( + "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="workspace_user_home_preferences", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_user_home_preferences", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace Home Preference", + "verbose_name_plural": "Workspace Home Preferences", + "db_table": "workspace_home_preferences", + "ordering": ("-created_at",), + }, + ), + migrations.AddConstraint( + model_name="workspacehomepreference", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "user", "key"), + name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="workspacehomepreference", + unique_together={("workspace", "user", "key", "deleted_at")}, + ), + migrations.AlterField( + model_name="page", + name="name", + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name="sticky", + name="name", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='workspacehomepreference', + name='sort_order', + field=models.PositiveIntegerField(default=65535), + ), + ] diff --git a/apps/api/plane/db/migrations/0090_rename_dashboard_deprecateddashboard_and_more.py b/apps/api/plane/db/migrations/0090_rename_dashboard_deprecateddashboard_and_more.py new file mode 100644 index 00000000..e0d16d5a --- /dev/null +++ b/apps/api/plane/db/migrations/0090_rename_dashboard_deprecateddashboard_and_more.py @@ -0,0 +1,87 @@ + # Generated by Django 4.2.17 on 2025-01-09 14:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0089_workspacehomepreference_and_more'), + ] + + operations = [ + migrations.RenameModel( + old_name='Dashboard', + new_name='DeprecatedDashboard', + ), + migrations.RenameModel( + old_name='DashboardWidget', + new_name='DeprecatedDashboardWidget', + ), + migrations.RenameModel( + old_name='Widget', + new_name='DeprecatedWidget', + ), + migrations.AlterModelOptions( + name='deprecateddashboard', + options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedDashboard', 'verbose_name_plural': 'DeprecatedDashboards'}, + ), + migrations.AlterModelOptions( + name='deprecateddashboardwidget', + options={'ordering': ('-created_at',), 'verbose_name': 'Deprecated Dashboard Widget', 'verbose_name_plural': 'Deprecated Dashboard Widgets'}, + ), + migrations.AlterModelOptions( + name='deprecatedwidget', + options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedWidget', 'verbose_name_plural': 'DeprecatedWidgets'}, + ), + migrations.AlterField( + model_name='workspacehomepreference', + name='sort_order', + field=models.FloatField(default=65535), + ), + migrations.AlterModelTable( + name='deprecateddashboard', + table='deprecated_dashboards', + ), + migrations.AlterModelTable( + name='deprecateddashboardwidget', + table='deprecated_dashboard_widgets', + ), + migrations.AlterModelTable( + name='deprecatedwidget', + table='deprecated_widgets', + ), + migrations.CreateModel( + name='WorkspaceUserPreference', + 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)), + ('key', models.CharField(max_length=255)), + ('is_pinned', models.BooleanField(default=False)), + ('sort_order', models.FloatField(default=65535)), + ('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='workspace_user_preferences', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_user_preferences', to='db.workspace')), + ], + options={ + 'verbose_name': 'Workspace User Preference', + 'verbose_name_plural': 'Workspace User Preferences', + 'db_table': 'workspace_user_preferences', + 'ordering': ('-created_at',), + }, + ), + migrations.AddConstraint( + model_name='workspaceuserpreference', + constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('workspace', 'user', 'key'), name='workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null'), + ), + migrations.AlterUniqueTogether( + name='workspaceuserpreference', + unique_together={('workspace', 'user', 'key', 'deleted_at')}, + ), + ] diff --git a/apps/api/plane/db/migrations/0091_issuecomment_edited_at_and_more.py b/apps/api/plane/db/migrations/0091_issuecomment_edited_at_and_more.py new file mode 100644 index 00000000..c6fb825a --- /dev/null +++ b/apps/api/plane/db/migrations/0091_issuecomment_edited_at_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.17 on 2025-01-30 16:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0090_rename_dashboard_deprecateddashboard_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='issuecomment', + name='edited_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='profile', + name='is_smooth_cursor_enabled', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='userrecentvisit', + name='entity_name', + field=models.CharField(max_length=30), + ), + migrations.AlterField( + model_name='webhooklog', + name='webhook', + field=models.UUIDField(), + ) + ] diff --git a/apps/api/plane/db/migrations/0092_alter_deprecateddashboardwidget_unique_together_and_more.py b/apps/api/plane/db/migrations/0092_alter_deprecateddashboardwidget_unique_together_and_more.py new file mode 100644 index 00000000..86369bbd --- /dev/null +++ b/apps/api/plane/db/migrations/0092_alter_deprecateddashboardwidget_unique_together_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.18 on 2025-02-25 15:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0091_issuecomment_edited_at_and_more"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="deprecateddashboardwidget", + unique_together=None, + ), + migrations.RemoveField( + model_name="deprecateddashboardwidget", + name="created_by", + ), + migrations.RemoveField( + model_name="deprecateddashboardwidget", + name="dashboard", + ), + migrations.RemoveField( + model_name="deprecateddashboardwidget", + name="updated_by", + ), + migrations.RemoveField( + model_name="deprecateddashboardwidget", + name="widget", + ), + migrations.DeleteModel( + name="DeprecatedDashboard", + ), + migrations.DeleteModel( + name="DeprecatedDashboardWidget", + ), + migrations.DeleteModel( + name="DeprecatedWidget", + ), + ] diff --git a/apps/api/plane/db/migrations/0093_page_moved_to_page_page_moved_to_project_and_more.py b/apps/api/plane/db/migrations/0093_page_moved_to_page_page_moved_to_project_and_more.py new file mode 100644 index 00000000..52b27d4e --- /dev/null +++ b/apps/api/plane/db/migrations/0093_page_moved_to_page_page_moved_to_project_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.17 on 2025-03-04 19:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0092_alter_deprecateddashboardwidget_unique_together_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="page", + name="moved_to_page", + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name="page", + name="moved_to_project", + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name="pageversion", + name="sub_pages_data", + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/apps/api/plane/db/migrations/0094_auto_20250425_0902.py b/apps/api/plane/db/migrations/0094_auto_20250425_0902.py new file mode 100644 index 00000000..54adb7e2 --- /dev/null +++ b/apps/api/plane/db/migrations/0094_auto_20250425_0902.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.17 on 2025-04-25 09:02 + +from django.db import migrations, models +from plane.db.models.intake import SourceType + +def set_default_source_type(apps, schema_editor): + IntakeIssue = apps.get_model("db", "IntakeIssue") + IntakeIssue.objects.filter(source__iexact="in-app").update(source=SourceType.IN_APP) + +class Migration(migrations.Migration): + dependencies = [ + ('db', '0093_page_moved_to_page_page_moved_to_project_and_more'), + ] + + operations = [ + migrations.RunPython( + set_default_source_type, + migrations.RunPython.noop, + ), + migrations.AddField( + model_name='profile', + name='start_of_the_week', + field=models.PositiveSmallIntegerField(choices=[(0, 'Sunday'), (1, 'Monday'), (2, 'Tuesday'), (3, 'Wednesday'), (4, 'Thursday'), (5, 'Friday'), (6, 'Saturday')], default=0), + ), + ] diff --git a/apps/api/plane/db/migrations/0095_page_external_id_page_external_source.py b/apps/api/plane/db/migrations/0095_page_external_id_page_external_source.py new file mode 100644 index 00000000..eed8acf8 --- /dev/null +++ b/apps/api/plane/db/migrations/0095_page_external_id_page_external_source.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.14 on 2025-05-09 11:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0094_auto_20250425_0902'), + ] + + operations = [ + migrations.AddField( + model_name='page', + name='external_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='page', + name='external_source', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0096_user_is_email_valid_user_masked_at.py b/apps/api/plane/db/migrations/0096_user_is_email_valid_user_masked_at.py new file mode 100644 index 00000000..66635d89 --- /dev/null +++ b/apps/api/plane/db/migrations/0096_user_is_email_valid_user_masked_at.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.20 on 2025-05-21 13:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0095_page_external_id_page_external_source"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_email_valid", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="user", + name="masked_at", + field=models.DateTimeField(null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0097_project_external_id_project_external_source.py b/apps/api/plane/db/migrations/0097_project_external_id_project_external_source.py new file mode 100644 index 00000000..5548f8af --- /dev/null +++ b/apps/api/plane/db/migrations/0097_project_external_id_project_external_source.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.21 on 2025-06-06 12:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0096_user_is_email_valid_user_masked_at'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='external_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='project', + name='external_source', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0098_profile_is_app_rail_docked_and_more.py b/apps/api/plane/db/migrations/0098_profile_is_app_rail_docked_and_more.py new file mode 100644 index 00000000..db648ad5 --- /dev/null +++ b/apps/api/plane/db/migrations/0098_profile_is_app_rail_docked_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.21 on 2025-07-14 10:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0097_project_external_id_project_external_source'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='is_app_rail_docked', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='commentreaction', + name='reaction', + field=models.TextField(), + ), + migrations.AlterField( + model_name='issuereaction', + name='reaction', + field=models.TextField(), + ), + ] diff --git a/apps/api/plane/db/migrations/0099_profile_background_color_profile_goals_and_more.py b/apps/api/plane/db/migrations/0099_profile_background_color_profile_goals_and_more.py new file mode 100644 index 00000000..cc64d3a3 --- /dev/null +++ b/apps/api/plane/db/migrations/0099_profile_background_color_profile_goals_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.22 on 2025-07-27 16:01 + + +from django.db import migrations, models +import plane.utils.color + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0098_profile_is_app_rail_docked_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="profile", + name="background_color", + field=models.CharField( + default=plane.utils.color.get_random_color, max_length=255 + ), + ), + migrations.AddField( + model_name="profile", + name="goals", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="workspace", + name="background_color", + field=models.CharField( + default=plane.utils.color.get_random_color, max_length=255 + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0100_profile_has_marketing_email_consent_and_more.py b/apps/api/plane/db/migrations/0100_profile_has_marketing_email_consent_and_more.py new file mode 100644 index 00000000..674ca455 --- /dev/null +++ b/apps/api/plane/db/migrations/0100_profile_has_marketing_email_consent_and_more.py @@ -0,0 +1,1826 @@ +# Generated by Django 4.2.22 on 2025-07-30 08:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0099_profile_background_color_profile_goals_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="profile", + name="has_marketing_email_consent", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + 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/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/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/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/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/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("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/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("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/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("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/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/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("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/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_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("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/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("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/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("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/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/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/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Colombo", "Asia/Colombo"), + ("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/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/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("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/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/Riyadh", "Asia/Riyadh"), + ("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/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("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/Faroe", "Atlantic/Faroe"), + ("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/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Sydney", "Australia/Sydney"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("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/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/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/Ulyanovsk", "Europe/Ulyanovsk"), + ("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/Zurich", "Europe/Zurich"), + ("GMT", "GMT"), + ("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"), + ("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/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/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/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("US/Alaska", "US/Alaska"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("UTC", "UTC"), + ], + default="UTC", + max_length=255, + ), + ), + migrations.AlterField( + 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/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/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/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/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/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("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/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("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/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("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/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/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("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/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_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("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/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("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/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("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/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/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/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Colombo", "Asia/Colombo"), + ("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/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/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("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/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/Riyadh", "Asia/Riyadh"), + ("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/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("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/Faroe", "Atlantic/Faroe"), + ("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/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Sydney", "Australia/Sydney"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("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/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/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/Ulyanovsk", "Europe/Ulyanovsk"), + ("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/Zurich", "Europe/Zurich"), + ("GMT", "GMT"), + ("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"), + ("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/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/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/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("US/Alaska", "US/Alaska"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("UTC", "UTC"), + ], + default="UTC", + max_length=255, + ), + ), + migrations.AlterField( + model_name="user", + name="user_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/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/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/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/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/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("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/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("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/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("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/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/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("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/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_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("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/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("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/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("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/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/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/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Colombo", "Asia/Colombo"), + ("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/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/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("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/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/Riyadh", "Asia/Riyadh"), + ("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/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("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/Faroe", "Atlantic/Faroe"), + ("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/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Sydney", "Australia/Sydney"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("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/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/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/Ulyanovsk", "Europe/Ulyanovsk"), + ("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/Zurich", "Europe/Zurich"), + ("GMT", "GMT"), + ("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"), + ("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/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/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/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("US/Alaska", "US/Alaska"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("UTC", "UTC"), + ], + default="UTC", + max_length=255, + ), + ), + migrations.AlterField( + 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/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/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/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/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/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("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/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("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/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("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/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/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("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/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_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("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/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("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/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("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/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/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/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Colombo", "Asia/Colombo"), + ("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/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/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("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/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/Riyadh", "Asia/Riyadh"), + ("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/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("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/Faroe", "Atlantic/Faroe"), + ("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/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Sydney", "Australia/Sydney"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("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/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/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/Ulyanovsk", "Europe/Ulyanovsk"), + ("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/Zurich", "Europe/Zurich"), + ("GMT", "GMT"), + ("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"), + ("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/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/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/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("US/Alaska", "US/Alaska"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("UTC", "UTC"), + ], + default="UTC", + max_length=255, + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0101_description_descriptionversion.py b/apps/api/plane/db/migrations/0101_description_descriptionversion.py new file mode 100644 index 00000000..fca305c3 --- /dev/null +++ b/apps/api/plane/db/migrations/0101_description_descriptionversion.py @@ -0,0 +1,182 @@ +# Generated by Django 4.2.21 on 2025-08-19 11:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0100_profile_has_marketing_email_consent_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Description", + 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, + ), + ), + ("description_json", models.JSONField(blank=True, default=dict)), + ("description_html", models.TextField(blank=True, default="

    ")), + ("description_binary", models.BinaryField(null=True)), + ("description_stripped", models.TextField(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", + ), + ), + ( + "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": "Description", + "verbose_name_plural": "Descriptions", + "db_table": "descriptions", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="DescriptionVersion", + 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, + ), + ), + ("description_json", models.JSONField(blank=True, default=dict)), + ("description_html", models.TextField(blank=True, default="

    ")), + ("description_binary", models.BinaryField(null=True)), + ("description_stripped", models.TextField(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", + ), + ), + ( + "description", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="versions", + to="db.description", + ), + ), + ( + "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": "Description Version", + "verbose_name_plural": "Description Versions", + "db_table": "description_versions", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0102_page_sort_order_pagelog_entity_type_and_more.py b/apps/api/plane/db/migrations/0102_page_sort_order_pagelog_entity_type_and_more.py new file mode 100644 index 00000000..59908a96 --- /dev/null +++ b/apps/api/plane/db/migrations/0102_page_sort_order_pagelog_entity_type_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.22 on 2025-08-29 11:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0101_description_descriptionversion"), + ] + + operations = [ + migrations.AddField( + model_name="page", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.AddField( + model_name="pagelog", + name="entity_type", + field=models.CharField( + blank=True, max_length=30, null=True, verbose_name="Entity Type" + ), + ), + migrations.AlterField( + model_name="pagelog", + name="entity_identifier", + field=models.UUIDField(blank=True, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0103_fileasset_asset_entity_type_idx_and_more.py b/apps/api/plane/db/migrations/0103_fileasset_asset_entity_type_idx_and_more.py new file mode 100644 index 00000000..82deba46 --- /dev/null +++ b/apps/api/plane/db/migrations/0103_fileasset_asset_entity_type_idx_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.22 on 2025-09-01 14:33 + +from django.db import migrations, models +from django.contrib.postgres.operations import AddIndexConcurrently + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ('db', '0102_page_sort_order_pagelog_entity_type_and_more'), + ] + + operations = [ + AddIndexConcurrently( + model_name='fileasset', + index=models.Index(fields=['entity_type'], name='asset_entity_type_idx'), + ), + AddIndexConcurrently( + model_name='fileasset', + index=models.Index(fields=['entity_identifier'], name='asset_entity_identifier_idx'), + ), + AddIndexConcurrently( + model_name='fileasset', + index=models.Index(fields=['entity_type', 'entity_identifier'], name='asset_entity_idx'), + ), + AddIndexConcurrently( + model_name='notification', + index=models.Index(fields=['entity_identifier'], name='notif_entity_identifier_idx'), + ), + AddIndexConcurrently( + model_name='notification', + index=models.Index(fields=['entity_name'], name='notif_entity_name_idx'), + ), + AddIndexConcurrently( + model_name='notification', + index=models.Index(fields=['read_at'], name='notif_read_at_idx'), + ), + AddIndexConcurrently( + model_name='notification', + index=models.Index(fields=['receiver', 'read_at'], name='notif_entity_idx'), + ), + AddIndexConcurrently( + model_name='pagelog', + index=models.Index(fields=['entity_type'], name='pagelog_entity_type_idx'), + ), + AddIndexConcurrently( + model_name='pagelog', + index=models.Index(fields=['entity_identifier'], name='pagelog_entity_id_idx'), + ), + AddIndexConcurrently( + model_name='pagelog', + index=models.Index(fields=['entity_name'], name='pagelog_entity_name_idx'), + ), + AddIndexConcurrently( + model_name='pagelog', + index=models.Index(fields=['entity_type', 'entity_identifier'], name='pagelog_type_id_idx'), + ), + AddIndexConcurrently( + model_name='pagelog', + index=models.Index(fields=['entity_name', 'entity_identifier'], name='pagelog_name_id_idx'), + ), + AddIndexConcurrently( + model_name='userfavorite', + index=models.Index(fields=['entity_type'], name='fav_entity_type_idx'), + ), + AddIndexConcurrently( + model_name='userfavorite', + index=models.Index(fields=['entity_identifier'], name='fav_entity_identifier_idx'), + ), + AddIndexConcurrently( + model_name='userfavorite', + index=models.Index(fields=['entity_type', 'entity_identifier'], name='fav_entity_idx'), + ), + ] diff --git a/apps/api/plane/db/migrations/0104_cycleuserproperties_rich_filters_and_more.py b/apps/api/plane/db/migrations/0104_cycleuserproperties_rich_filters_and_more.py new file mode 100644 index 00000000..6344e316 --- /dev/null +++ b/apps/api/plane/db/migrations/0104_cycleuserproperties_rich_filters_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.22 on 2025-09-03 05:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0103_fileasset_asset_entity_type_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='cycleuserproperties', + name='rich_filters', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='exporterhistory', + name='rich_filters', + field=models.JSONField(blank=True, default=dict, null=True), + ), + migrations.AddField( + model_name='issueuserproperty', + name='rich_filters', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='issueview', + name='rich_filters', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='moduleuserproperties', + name='rich_filters', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='workspaceuserproperties', + name='rich_filters', + field=models.JSONField(default=dict), + ), + ] diff --git a/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py b/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py new file mode 100644 index 00000000..ef477fbc --- /dev/null +++ b/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.22 on 2025-09-10 09:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0104_cycleuserproperties_rich_filters_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="project", + name="cycle_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="project", + name="issue_views_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="project", + name="module_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="session", + name="user_id", + field=models.CharField(db_index=True, max_length=50, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0106_auto_20250912_0845.py b/apps/api/plane/db/migrations/0106_auto_20250912_0845.py new file mode 100644 index 00000000..8a0813fc --- /dev/null +++ b/apps/api/plane/db/migrations/0106_auto_20250912_0845.py @@ -0,0 +1,152 @@ +# Generated by Django 4.2.22 on 2025-09-12 08:45 +import uuid +import django +from django.conf import settings +from django.db import migrations, models + + +def set_page_sort_order(apps, schema_editor): + Page = apps.get_model("db", "Page") + + batch_size = 3000 + sort_order = 100 + + # Get page IDs ordered by name using the historical model + # This should include all pages regardless of soft-delete status + page_ids = list(Page.objects.all().order_by("name").values_list("id", flat=True)) + + updated_pages = [] + for page_id in page_ids: + # Create page instance with minimal data + updated_pages.append(Page(id=page_id, sort_order=sort_order)) + sort_order += 100 + + # Bulk update when batch is full + if len(updated_pages) >= batch_size: + Page.objects.bulk_update( + updated_pages, ["sort_order"], batch_size=batch_size + ) + updated_pages = [] + + # Update remaining pages + if updated_pages: + Page.objects.bulk_update(updated_pages, ["sort_order"], batch_size=batch_size) + + +def reverse_set_page_sort_order(apps, schema_editor): + Page = apps.get_model("db", "Page") + Page.objects.update(sort_order=Page.DEFAULT_SORT_ORDER) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0105_alter_project_cycle_view_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectWebhook", + 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", + ), + ), + ( + "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", + ), + ), + ( + "webhook", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_webhooks", + to="db.webhook", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Webhook", + "verbose_name_plural": "Project Webhooks", + "db_table": "project_webhooks", + "ordering": ("-created_at",), + }, + ), + migrations.AddConstraint( + model_name="projectwebhook", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("project", "webhook"), + name="project_webhook_unique_project_webhook_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="projectwebhook", + unique_together={("project", "webhook", "deleted_at")}, + ), + migrations.AlterField( + model_name="issuerelation", + name="relation_type", + field=models.CharField( + default="blocked_by", max_length=20, verbose_name="Issue Relation Type" + ), + ), + migrations.RunPython( + set_page_sort_order, reverse_code=reverse_set_page_sort_order + ), + ] diff --git a/apps/api/plane/db/migrations/0107_migrate_filters_to_rich_filters.py b/apps/api/plane/db/migrations/0107_migrate_filters_to_rich_filters.py new file mode 100644 index 00000000..3048dd86 --- /dev/null +++ b/apps/api/plane/db/migrations/0107_migrate_filters_to_rich_filters.py @@ -0,0 +1,74 @@ +from django.db import migrations + +from plane.utils.filters import LegacyToRichFiltersConverter +from plane.utils.filters.filter_migrations import ( + migrate_models_filters_to_rich_filters, + clear_models_rich_filters, +) + + +# Define all models that need migration in one place +MODEL_NAMES = [ + "IssueView", + "WorkspaceUserProperties", + "ModuleUserProperties", + "IssueUserProperty", + "CycleUserProperties", +] + + +def migrate_filters_to_rich_filters(apps, schema_editor): + """ + Migrate legacy filters to rich_filters format for all models that have both fields. + """ + # Get the model classes from model names + models_to_migrate = {} + + for model_name in MODEL_NAMES: + try: + model_class = apps.get_model("db", model_name) + models_to_migrate[model_name] = model_class + except Exception as e: + # Log error but continue with other models + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to get model {model_name}: {str(e)}") + + converter = LegacyToRichFiltersConverter() + # Migrate all models + migrate_models_filters_to_rich_filters(models_to_migrate, converter) + + +def reverse_migrate_rich_filters_to_filters(apps, schema_editor): + """ + Reverse migration to clear rich_filters field for all models. + """ + # Get the model classes from model names + models_to_clear = {} + + for model_name in MODEL_NAMES: + try: + model_class = apps.get_model("db", model_name) + models_to_clear[model_name] = model_class + except Exception as e: + # Log error but continue with other models + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to get model {model_name}: {str(e)}") + + # Clear rich_filters for all models + clear_models_rich_filters(models_to_clear) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0106_auto_20250912_0845'), + ] + + operations = [ + migrations.RunPython( + migrate_filters_to_rich_filters, + reverse_code=reverse_migrate_rich_filters_to_filters, + ), + ] \ No newline at end of file diff --git a/apps/api/plane/db/migrations/__init__.py b/apps/api/plane/db/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/db/mixins.py b/apps/api/plane/db/mixins.py new file mode 100644 index 00000000..ca3c9a2d --- /dev/null +++ b/apps/api/plane/db/mixins.py @@ -0,0 +1,82 @@ +# Django imports +from django.db import models +from django.utils import timezone + +# Module imports +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") + + class Meta: + abstract = True + + +class UserAuditModel(models.Model): + """To path when the record was created and last modified""" + + created_by = models.ForeignKey( + "db.User", + on_delete=models.SET_NULL, + related_name="%(class)s_created_by", + verbose_name="Created By", + null=True, + ) + updated_by = models.ForeignKey( + "db.User", + on_delete=models.SET_NULL, + related_name="%(class)s_updated_by", + verbose_name="Last Modified By", + null=True, + ) + + class Meta: + abstract = True + + +class 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 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) + + objects = SoftDeletionManager() + all_objects = models.Manager() + + class Meta: + abstract = True + + def delete(self, using=None, soft=True, *args, **kwargs): + if soft: + # Soft delete the current instance + self.deleted_at = timezone.now() + self.save(using=using) + + soft_delete_related_objects.delay(self._meta.app_label, self._meta.model_name, self.pk, using=using) + + else: + # Perform hard delete if soft deletion is not enabled + return super().delete(using=using, *args, **kwargs) + + +class AuditModel(TimeAuditModel, UserAuditModel, SoftDeleteModel): + """To path when the record was created and last modified""" + + class Meta: + abstract = True diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py new file mode 100644 index 00000000..fcf77b93 --- /dev/null +++ b/apps/api/plane/db/models/__init__.py @@ -0,0 +1,87 @@ +from .analytic import AnalyticView +from .api import APIActivityLog, APIToken +from .asset import FileAsset +from .base import BaseModel +from .cycle import Cycle, CycleIssue, CycleUserProperties +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 .intake import Intake, IntakeIssue +from .integration import ( + GithubCommentSync, + GithubIssueSync, + GithubRepository, + GithubRepositorySync, + Integration, + SlackProjectSync, + WorkspaceIntegration, +) +from .issue import ( + CommentReaction, + Issue, + IssueActivity, + IssueAssignee, + IssueBlocker, + IssueComment, + IssueLabel, + IssueLink, + IssueMention, + IssueUserProperty, + IssueReaction, + IssueRelation, + IssueSequence, + IssueSubscriber, + IssueVote, + IssueVersion, + IssueDescriptionVersion, +) +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, + ProjectIdentifier, + ProjectMember, + ProjectMemberInvite, + ProjectPublicMember, +) +from .session import Session +from .social_connection import SocialLoginConnection +from .state import State +from .user import Account, Profile, User +from .view import IssueView +from .webhook import Webhook, WebhookLog +from .workspace import ( + Workspace, + WorkspaceBaseModel, + WorkspaceMember, + WorkspaceMemberInvite, + WorkspaceTheme, + WorkspaceUserProperties, + WorkspaceUserLink, + WorkspaceHomePreference, + WorkspaceUserPreference, +) + +from .favorite import UserFavorite + +from .issue_type import IssueType + +from .recent_visit import UserRecentVisit + +from .label import Label + +from .device import Device, DeviceSession + +from .sticky import Sticky + +from .description import Description, DescriptionVersion diff --git a/apps/api/plane/db/models/analytic.py b/apps/api/plane/db/models/analytic.py new file mode 100644 index 00000000..0efcb957 --- /dev/null +++ b/apps/api/plane/db/models/analytic.py @@ -0,0 +1,22 @@ +# Django models +from django.db import models + +from .base import BaseModel + + +class AnalyticView(BaseModel): + workspace = models.ForeignKey("db.Workspace", related_name="analytics", on_delete=models.CASCADE) + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + query = models.JSONField() + query_dict = models.JSONField(default=dict) + + class Meta: + verbose_name = "Analytic" + verbose_name_plural = "Analytics" + db_table = "analytic_views" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the analytic view""" + return f"{self.name} <{self.workspace.name}>" diff --git a/apps/api/plane/db/models/api.py b/apps/api/plane/db/models/api.py new file mode 100644 index 00000000..7d040ebc --- /dev/null +++ b/apps/api/plane/db/models/api.py @@ -0,0 +1,71 @@ +# Python imports +from uuid import uuid4 + +# Django imports +from django.db import models +from django.conf import settings + +from .base import BaseModel + + +def generate_label_token(): + return uuid4().hex + + +def generate_token(): + return "plane_api_" + uuid4().hex + + +class APIToken(BaseModel): + # Meta information + label = models.CharField(max_length=255, default=generate_label_token) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + last_used = models.DateTimeField(null=True) + + # Token + token = models.CharField(max_length=255, unique=True, default=generate_token, db_index=True) + + # User Information + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bot_tokens") + user_type = models.PositiveSmallIntegerField(choices=((0, "Human"), (1, "Bot")), default=0) + workspace = models.ForeignKey("db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True) + expired_at = models.DateTimeField(blank=True, null=True) + is_service = models.BooleanField(default=False) + + class Meta: + verbose_name = "API Token" + verbose_name_plural = "API Tokems" + db_table = "api_tokens" + ordering = ("-created_at",) + + def __str__(self): + return str(self.user.id) + + +class APIActivityLog(BaseModel): + token_identifier = models.CharField(max_length=255) + + # Request Info + path = models.CharField(max_length=255) + method = models.CharField(max_length=10) + query_params = models.TextField(null=True, blank=True) + headers = models.TextField(null=True, blank=True) + body = models.TextField(null=True, blank=True) + + # Response info + response_code = models.PositiveIntegerField() + response_body = models.TextField(null=True, blank=True) + + # Meta information + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.CharField(max_length=512, null=True, blank=True) + + class Meta: + verbose_name = "API Activity Log" + verbose_name_plural = "API Activity Logs" + db_table = "api_activity_logs" + ordering = ("-created_at",) + + def __str__(self): + return str(self.token_identifier) diff --git a/apps/api/plane/db/models/asset.py b/apps/api/plane/db/models/asset.py new file mode 100644 index 00000000..1de0f18b --- /dev/null +++ b/apps/api/plane/db/models/asset.py @@ -0,0 +1,95 @@ +# Python imports +from uuid import uuid4 + +# Django import +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models + +# Module import +from .base import BaseModel + + +def get_upload_path(instance, filename): + if instance.workspace_id is not None: + return f"{instance.workspace.id}/{uuid4().hex}-{filename}" + return f"user-{uuid4().hex}-{filename}" + + +def file_size(value): + if value.size > settings.FILE_SIZE_LIMIT: + raise ValidationError("File too large. Size should not exceed 5 MB.") + + +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, max_length=800) + user = models.ForeignKey("db.User", on_delete=models.CASCADE, null=True, related_name="assets") + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, 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" + verbose_name_plural = "File Assets" + db_table = "file_assets" + ordering = ("-created_at",) + indexes = [ + models.Index(fields=["entity_type"], name="asset_entity_type_idx"), + models.Index(fields=["entity_identifier"], name="asset_entity_identifier_idx"), + models.Index(fields=["entity_type", "entity_identifier"], name="asset_entity_idx"), + ] + + 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}/" # noqa: E501 + + 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/apps/api/plane/db/models/base.py b/apps/api/plane/db/models/base.py new file mode 100644 index 00000000..468af826 --- /dev/null +++ b/apps/api/plane/db/models/base.py @@ -0,0 +1,43 @@ +import uuid + +# Django imports +from django.db import models + +# Third party imports +from crum import get_current_user + +# Module imports +from ..mixins import AuditModel + + +class BaseModel(AuditModel): + id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True) + + class Meta: + abstract = True + + def save(self, *args, created_by_id=None, disable_auto_set_user=False, **kwargs): + if not disable_auto_set_user: + # Check if created_by_id is provided + if created_by_id: + self.created_by_id = created_by_id + else: + user = get_current_user() + + if user is None or user.is_anonymous: + self.created_by = None + self.updated_by = None + else: + # Check if the model is being created or updated + if self._state.adding: + # If creating, set created_by and leave updated_by as None + self.created_by = user + self.updated_by = None + else: + # If updating, set updated_by only + self.updated_by = user + + super(BaseModel, self).save(*args, **kwargs) + + def __str__(self): + return str(self.id) diff --git a/apps/api/plane/db/models/cycle.py b/apps/api/plane/db/models/cycle.py new file mode 100644 index 00000000..bdffd283 --- /dev/null +++ b/apps/api/plane/db/models/cycle.py @@ -0,0 +1,153 @@ +# Python imports +import pytz + +# Django imports +from django.conf import settings +from django.db import models + +# Module imports +from .project import ProjectBaseModel + + +def get_default_filters(): + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } + + +def get_default_display_filters(): + return { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + } + + +def get_default_display_properties(): + return { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + } + + +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.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, + related_name="owned_by_cycle", + ) + view_props = models.JSONField(default=dict) + 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) + progress_snapshot = models.JSONField(default=dict) + archived_at = models.DateTimeField(null=True) + logo_props = models.JSONField(default=dict) + # timezone + TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones)) + timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) + version = models.IntegerField(default=1) + + class Meta: + verbose_name = "Cycle" + verbose_name_plural = "Cycles" + db_table = "cycles" + ordering = ("-created_at",) + + 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"] + + if smallest_sort_order is not None: + self.sort_order = smallest_sort_order - 10000 + + super(Cycle, self).save(*args, **kwargs) + + def __str__(self): + """Return name of the cycle""" + return f"{self.name} <{self.project.name}>" + + +class CycleIssue(ProjectBaseModel): + """ + Cycle Issues + """ + + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_cycle") + cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="issue_cycle") + + 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" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.cycle}" + + +class CycleUserProperties(ProjectBaseModel): + cycle = models.ForeignKey("db.Cycle", on_delete=models.CASCADE, related_name="cycle_user_properties") + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="cycle_user_properties", + ) + 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) + rich_filters = models.JSONField(default=dict) + + class Meta: + unique_together = ["cycle", "user", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["cycle", "user"], + condition=models.Q(deleted_at__isnull=True), + name="cycle_user_properties_unique_cycle_user_when_deleted_at_null", + ) + ] + verbose_name = "Cycle User Property" + verbose_name_plural = "Cycle User Properties" + db_table = "cycle_user_properties" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.cycle.name} {self.user.email}" diff --git a/apps/api/plane/db/models/deploy_board.py b/apps/api/plane/db/models/deploy_board.py new file mode 100644 index 00000000..da9c0d69 --- /dev/null +++ b/apps/api/plane/db/models/deploy_board.py @@ -0,0 +1,53 @@ +# Python imports +from uuid import uuid4 + +# Django imports +from django.db import models + +# Module imports +from .workspace import WorkspaceBaseModel + + +def get_anchor(): + return uuid4().hex + + +class DeployBoard(WorkspaceBaseModel): + TYPE_CHOICES = ( + ("project", "Project"), + ("issue", "Issue"), + ("module", "Module"), + ("cycle", "Task"), + ("page", "Page"), + ("view", "View"), + ("intake", "Intake"), + ) + + entity_identifier = models.UUIDField(null=True) + 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) + 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""" + return f"{self.entity_identifier} <{self.entity_name}>" + + class Meta: + unique_together = ["entity_name", "entity_identifier", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["entity_name", "entity_identifier"], + condition=models.Q(deleted_at__isnull=True), + name="deploy_board_unique_entity_name_entity_identifier_when_deleted_at_null", + ) + ] + verbose_name = "Deploy Board" + verbose_name_plural = "Deploy Boards" + db_table = "deploy_boards" + ordering = ("-created_at",) diff --git a/apps/api/plane/db/models/description.py b/apps/api/plane/db/models/description.py new file mode 100644 index 00000000..6c298546 --- /dev/null +++ b/apps/api/plane/db/models/description.py @@ -0,0 +1,52 @@ +from django.db import models +from django.utils.html import strip_tags +from .workspace import WorkspaceBaseModel + + +class Description(WorkspaceBaseModel): + description_json = models.JSONField(default=dict, blank=True) + description_html = models.TextField(blank=True, default="

    ") + description_binary = models.BinaryField(null=True) + description_stripped = models.TextField(blank=True, null=True) + + class Meta: + verbose_name = "Description" + verbose_name_plural = "Descriptions" + db_table = "descriptions" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + # 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(Description, self).save(*args, **kwargs) + + +class DescriptionVersion(WorkspaceBaseModel): + """ + DescriptionVersion is a model used to store historical versions of a Description. + """ + + description = models.ForeignKey("db.Description", on_delete=models.CASCADE, related_name="versions") + description_json = models.JSONField(default=dict, blank=True) + description_html = models.TextField(blank=True, default="

    ") + description_binary = models.BinaryField(null=True) + description_stripped = models.TextField(blank=True, null=True) + + class Meta: + verbose_name = "Description Version" + verbose_name_plural = "Description Versions" + db_table = "description_versions" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + # 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(DescriptionVersion, self).save(*args, **kwargs) diff --git a/apps/api/plane/db/models/device.py b/apps/api/plane/db/models/device.py new file mode 100644 index 00000000..adcf7974 --- /dev/null +++ b/apps/api/plane/db/models/device.py @@ -0,0 +1,38 @@ +# 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/apps/api/plane/db/models/draft.py b/apps/api/plane/db/models/draft.py new file mode 100644 index 00000000..55dbb61d --- /dev/null +++ b/apps/api/plane/db/models/draft.py @@ -0,0 +1,220 @@ +# 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/apps/api/plane/db/models/estimate.py b/apps/api/plane/db/models/estimate.py new file mode 100644 index 00000000..9373fb32 --- /dev/null +++ b/apps/api/plane/db/models/estimate.py @@ -0,0 +1,49 @@ +# Django imports +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import Q + +# Module imports +from .project import ProjectBaseModel + + +class Estimate(ProjectBaseModel): + name = models.CharField(max_length=255) + description = models.TextField(verbose_name="Estimate Description", blank=True) + type = models.CharField(max_length=255, default="categories") + last_used = models.BooleanField(default=False) + + def __str__(self): + """Return name of the estimate""" + return f"{self.name} <{self.project.name}>" + + class Meta: + unique_together = ["name", "project", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["name", "project"], + condition=Q(deleted_at__isnull=True), + name="estimate_unique_name_project_when_deleted_at_null", + ) + ] + verbose_name = "Estimate" + verbose_name_plural = "Estimates" + db_table = "estimates" + ordering = ("name",) + + +class EstimatePoint(ProjectBaseModel): + estimate = models.ForeignKey("db.Estimate", on_delete=models.CASCADE, related_name="points") + key = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]) + description = models.TextField(blank=True) + value = models.CharField(max_length=255) + + def __str__(self): + """Return name of the estimate""" + return f"{self.estimate.name} <{self.key}> <{self.value}>" + + class Meta: + verbose_name = "Estimate Point" + verbose_name_plural = "Estimate Points" + db_table = "estimate_points" + ordering = ("value",) diff --git a/apps/api/plane/db/models/exporter.py b/apps/api/plane/db/models/exporter.py new file mode 100644 index 00000000..8ad9daad --- /dev/null +++ b/apps/api/plane/db/models/exporter.py @@ -0,0 +1,63 @@ +import uuid + +# Python imports +from uuid import uuid4 + +from django.conf import settings +from django.contrib.postgres.fields import ArrayField + +# Django imports +from django.db import models + +# Module imports +from .base import BaseModel + + +def generate_token(): + return uuid4().hex + + +class ExporterHistory(BaseModel): + name = models.CharField(max_length=255, verbose_name="Exporter Name", null=True, blank=True) + type = models.CharField( + max_length=50, + default="issue_exports", + choices=( + ("issue_exports", "Issue Exports"), + ("issue_worklogs", "Issue Worklogs"), + ), + ) + workspace = models.ForeignKey("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"))) + status = models.CharField( + max_length=50, + choices=( + ("queued", "Queued"), + ("processing", "Processing"), + ("completed", "Completed"), + ("failed", "Failed"), + ), + default="queued", + ) + 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) + initiated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workspace_exporters", + ) + filters = models.JSONField(blank=True, null=True) + rich_filters = models.JSONField(default=dict, blank=True, null=True) + + class Meta: + verbose_name = "Exporter" + verbose_name_plural = "Exporters" + db_table = "exporters" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the service""" + return f"{self.provider} <{self.workspace.name}>" diff --git a/apps/api/plane/db/models/favorite.py b/apps/api/plane/db/models/favorite.py new file mode 100644 index 00000000..de2b101a --- /dev/null +++ b/apps/api/plane/db/models/favorite.py @@ -0,0 +1,65 @@ +from django.conf import settings + +# Django imports +from django.db import models + +# Module imports +from .workspace import WorkspaceBaseModel + + +class UserFavorite(WorkspaceBaseModel): + """_summary_ + UserFavorite (model): To store all the favorites of the user + """ + + user = models.ForeignKey(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) + name = models.CharField(max_length=255, blank=True, null=True) + is_folder = models.BooleanField(default=False) + sequence = models.FloatField(default=65535) + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="parent_folder", + ) + + class Meta: + unique_together = ["entity_type", "user", "entity_identifier", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["entity_type", "entity_identifier", "user"], + condition=models.Q(deleted_at__isnull=True), + name="user_favorite_unique_entity_type_entity_identifier_user_when_deleted_at_null", + ) + ] + verbose_name = "User Favorite" + verbose_name_plural = "User Favorites" + db_table = "user_favorites" + ordering = ("-created_at",) + indexes = [ + models.Index(fields=["entity_type"], name="fav_entity_type_idx"), + models.Index(fields=["entity_identifier"], name="fav_entity_identifier_idx"), + models.Index(fields=["entity_type", "entity_identifier"], name="fav_entity_idx"), + ] + + def save(self, *args, **kwargs): + if self._state.adding: + if self.project: + largest_sequence = UserFavorite.objects.filter(workspace=self.project.workspace).aggregate( + largest=models.Max("sequence") + )["largest"] + else: + largest_sequence = UserFavorite.objects.filter(workspace=self.workspace).aggregate( + largest=models.Max("sequence") + )["largest"] + if largest_sequence is not None: + self.sequence = largest_sequence + 10000 + + super(UserFavorite, self).save(*args, **kwargs) + + def __str__(self): + """Return user and the entity type""" + return f"{self.user.email} <{self.entity_type}>" diff --git a/apps/api/plane/db/models/importer.py b/apps/api/plane/db/models/importer.py new file mode 100644 index 00000000..9bcea8cf --- /dev/null +++ b/apps/api/plane/db/models/importer.py @@ -0,0 +1,36 @@ +# Django imports +from django.conf import settings +from django.db import models + +# Module imports +from .project import ProjectBaseModel + + +class Importer(ProjectBaseModel): + service = models.CharField(max_length=50, choices=(("github", "GitHub"), ("jira", "Jira"))) + status = models.CharField( + max_length=50, + choices=( + ("queued", "Queued"), + ("processing", "Processing"), + ("completed", "Completed"), + ("failed", "Failed"), + ), + default="queued", + ) + initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="imports") + metadata = models.JSONField(default=dict) + config = models.JSONField(default=dict) + data = models.JSONField(default=dict) + token = models.ForeignKey("db.APIToken", on_delete=models.CASCADE, related_name="importer") + imported_data = models.JSONField(null=True) + + class Meta: + verbose_name = "Importer" + verbose_name_plural = "Importers" + db_table = "importers" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the service""" + return f"{self.service} <{self.project.name}>" diff --git a/apps/api/plane/db/models/intake.py b/apps/api/plane/db/models/intake.py new file mode 100644 index 00000000..c3369ae1 --- /dev/null +++ b/apps/api/plane/db/models/intake.py @@ -0,0 +1,80 @@ +# Django imports +from django.db import models + +# Module imports +from plane.db.models.project import ProjectBaseModel + + +class Intake(ProjectBaseModel): + name = models.CharField(max_length=255) + 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 intake""" + return f"{self.name} <{self.project.name}>" + + class Meta: + unique_together = ["name", "project", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["name", "project"], + condition=models.Q(deleted_at__isnull=True), + name="intake_unique_name_project_when_deleted_at_null", + ) + ] + verbose_name = "Intake" + verbose_name_plural = "Intakes" + db_table = "intakes" + ordering = ("name",) + + +class SourceType(models.TextChoices): + IN_APP = "IN_APP" + + +class IntakeIssueStatus(models.IntegerChoices): + PENDING = -2 + REJECTED = -1 + SNOOZED = 0 + ACCEPTED = 1 + DUPLICATE = 2 + + +class IntakeIssue(ProjectBaseModel): + intake = models.ForeignKey("db.Intake", related_name="issue_intake", on_delete=models.CASCADE) + issue = models.ForeignKey("db.Issue", related_name="issue_intake", on_delete=models.CASCADE) + status = models.IntegerField( + choices=( + (-2, "Pending"), + (-1, "Rejected"), + (0, "Snoozed"), + (1, "Accepted"), + (2, "Duplicate"), + ), + default=-2, + ) + snoozed_till = models.DateTimeField(null=True) + duplicate_to = models.ForeignKey( + "db.Issue", + related_name="intake_duplicate", + on_delete=models.SET_NULL, + 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 = "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.intake.name}>" diff --git a/apps/api/plane/db/models/integration/__init__.py b/apps/api/plane/db/models/integration/__init__.py new file mode 100644 index 00000000..34b40e57 --- /dev/null +++ b/apps/api/plane/db/models/integration/__init__.py @@ -0,0 +1,8 @@ +from .base import Integration, WorkspaceIntegration +from .github import ( + GithubRepository, + GithubRepositorySync, + GithubIssueSync, + GithubCommentSync, +) +from .slack import SlackProjectSync diff --git a/apps/api/plane/db/models/integration/base.py b/apps/api/plane/db/models/integration/base.py new file mode 100644 index 00000000..296c3cf6 --- /dev/null +++ b/apps/api/plane/db/models/integration/base.py @@ -0,0 +1,56 @@ +# Python imports +import uuid + +# Django imports +from django.db import models + +# Module imports +from plane.db.models import BaseModel +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) + title = models.CharField(max_length=400) + provider = models.CharField(max_length=400, unique=True) + network = models.PositiveIntegerField(default=1, choices=((1, "Private"), (2, "Public"))) + description = models.JSONField(default=dict) + author = models.CharField(max_length=400, blank=True) + webhook_url = models.TextField(blank=True) + webhook_secret = models.TextField(blank=True) + redirect_url = models.TextField(blank=True) + metadata = models.JSONField(default=dict) + verified = models.BooleanField(default=False) + avatar_url = models.TextField(blank=True, null=True) + + def __str__(self): + """Return provider of the integration""" + return f"{self.provider}" + + class Meta: + verbose_name = "Integration" + verbose_name_plural = "Integrations" + db_table = "integrations" + ordering = ("-created_at",) + + +class WorkspaceIntegration(BaseModel): + workspace = models.ForeignKey("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) + api_token = models.ForeignKey("db.APIToken", related_name="integrations", on_delete=models.CASCADE) + metadata = models.JSONField(default=dict) + + config = models.JSONField(default=dict) + + def __str__(self): + """Return name of the integration and workspace""" + return f"{self.workspace.name} <{self.integration.provider}>" + + class Meta: + unique_together = ["workspace", "integration"] + verbose_name = "Workspace Integration" + verbose_name_plural = "Workspace Integrations" + db_table = "workspace_integrations" + ordering = ("-created_at",) diff --git a/apps/api/plane/db/models/integration/github.py b/apps/api/plane/db/models/integration/github.py new file mode 100644 index 00000000..ba278497 --- /dev/null +++ b/apps/api/plane/db/models/integration/github.py @@ -0,0 +1,83 @@ +# Python imports + +# Django imports +from django.db import models + +# Module imports +from plane.db.models.project import ProjectBaseModel + + +class GithubRepository(ProjectBaseModel): + name = models.CharField(max_length=500) + url = models.URLField(null=True) + config = models.JSONField(default=dict) + repository_id = models.BigIntegerField() + owner = models.CharField(max_length=500) + + def __str__(self): + """Return the repo name""" + return f"{self.name}" + + class Meta: + verbose_name = "Repository" + verbose_name_plural = "Repositories" + db_table = "github_repositories" + ordering = ("-created_at",) + + +class GithubRepositorySync(ProjectBaseModel): + repository = models.OneToOneField("db.GithubRepository", on_delete=models.CASCADE, related_name="syncs") + credentials = models.JSONField(default=dict) + # Bot user + actor = models.ForeignKey("db.User", related_name="user_syncs", on_delete=models.CASCADE) + workspace_integration = models.ForeignKey( + "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") + + def __str__(self): + """Return the repo sync""" + return f"{self.repository.name} <{self.project.name}>" + + class Meta: + unique_together = ["project", "repository"] + verbose_name = "Github Repository Sync" + verbose_name_plural = "Github Repository Syncs" + db_table = "github_repository_syncs" + ordering = ("-created_at",) + + +class GithubIssueSync(ProjectBaseModel): + repo_issue_id = models.BigIntegerField() + github_issue_id = models.BigIntegerField() + issue_url = models.URLField(blank=False) + issue = models.ForeignKey("db.Issue", related_name="github_syncs", on_delete=models.CASCADE) + repository_sync = models.ForeignKey("db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE) + + def __str__(self): + """Return the github issue sync""" + return f"{self.repository.name}-{self.project.name}-{self.issue.name}" + + class Meta: + unique_together = ["repository_sync", "issue"] + verbose_name = "Github Issue Sync" + verbose_name_plural = "Github Issue Syncs" + db_table = "github_issue_syncs" + ordering = ("-created_at",) + + +class GithubCommentSync(ProjectBaseModel): + repo_comment_id = models.BigIntegerField() + comment = models.ForeignKey("db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE) + issue_sync = models.ForeignKey("db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE) + + def __str__(self): + """Return the github issue sync""" + return f"{self.comment.id}" + + class Meta: + unique_together = ["issue_sync", "comment"] + verbose_name = "Github Comment Sync" + verbose_name_plural = "Github Comment Syncs" + db_table = "github_comment_syncs" + ordering = ("-created_at",) diff --git a/apps/api/plane/db/models/integration/slack.py b/apps/api/plane/db/models/integration/slack.py new file mode 100644 index 00000000..1e8ea469 --- /dev/null +++ b/apps/api/plane/db/models/integration/slack.py @@ -0,0 +1,31 @@ +# Python imports + +# Django imports +from django.db import models + +# Module imports +from plane.db.models.project import ProjectBaseModel + + +class SlackProjectSync(ProjectBaseModel): + access_token = models.CharField(max_length=300) + scopes = models.TextField() + bot_user_id = models.CharField(max_length=50) + webhook_url = models.URLField(max_length=1000) + data = models.JSONField(default=dict) + 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 + ) + + def __str__(self): + """Return the repo name""" + return f"{self.project.name}" + + class Meta: + unique_together = ["team_id", "project"] + verbose_name = "Slack Project Sync" + verbose_name_plural = "Slack Project Syncs" + db_table = "slack_project_syncs" + ordering = ("-created_at",) diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py new file mode 100644 index 00000000..c495fdb5 --- /dev/null +++ b/apps/api/plane/db/models/issue.py @@ -0,0 +1,791 @@ +# Python import +from uuid import uuid4 + +# Django imports +from django.conf import settings +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models, transaction, connection +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 +from plane.utils.uuid import convert_uuid_to_integer + + +def get_default_properties(): + return { + "assignee": True, + "start_date": True, + "due_date": True, + "labels": True, + "key": True, + "priority": True, + "state": True, + "sub_issue_count": True, + "link": True, + "attachment_count": True, + "estimate": True, + "created_on": True, + "updated_on": True, + } + + +def get_default_filters(): + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } + + +def get_default_display_filters(): + return { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + } + + +def get_default_display_properties(): + return { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + } + + +# TODO: Handle identifiers for Bulk Inserts - nk +class IssueManager(SoftDeletionManager): + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + 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) + .exclude(archived_at__isnull=False) + .exclude(project__archived_at__isnull=False) + .exclude(is_draft=True) + ) + + +class Issue(ProjectBaseModel): + PRIORITY_CHOICES = ( + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ) + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="parent_issue", + ) + state = models.ForeignKey( + "db.State", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="state_issue", + ) + point = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=True) + estimate_point = models.ForeignKey( + "db.EstimatePoint", + on_delete=models.SET_NULL, + related_name="issue_estimates", + null=True, + blank=True, + ) + name = models.CharField(max_length=255, verbose_name="Issue Name") + description = models.JSONField(blank=True, default=dict) + description_html = models.TextField(blank=True, default="

    ") + 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="assignee", + through="IssueAssignee", + through_fields=("issue", "assignee"), + ) + sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") + labels = models.ManyToManyField("db.Label", blank=True, related_name="labels", through="IssueLabel") + 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.ForeignKey( + "db.IssueType", + on_delete=models.SET_NULL, + related_name="issue_type", + null=True, + blank=True, + ) + + issue_objects = IssueManager() + + class Meta: + verbose_name = "Issue" + verbose_name_plural = "Issues" + db_table = "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: + with transaction.atomic(): + # Create a lock for this specific project using an advisory lock + # This ensures only one transaction per project can execute this code at a time + lock_key = convert_uuid_to_integer(self.project.id) + + with connection.cursor() as cursor: + # Get an exclusive lock using the project ID as the lock key + cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key]) + + try: + # Get the last sequence for the project + last_sequence = IssueSequence.objects.filter(project=self.project).aggregate( + largest=models.Max("sequence") + )["largest"] + self.sequence_id = last_sequence + 1 if last_sequence else 1 + # 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 = Issue.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(Issue, self).save(*args, **kwargs) + + IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project) + finally: + # Release the lock + with connection.cursor() as cursor: + cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key]) + 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(Issue, self).save(*args, **kwargs) + + def __str__(self): + """Return name of the issue""" + return f"{self.name} <{self.project.name}>" + + +class IssueBlocker(ProjectBaseModel): + block = models.ForeignKey(Issue, related_name="blocker_issues", on_delete=models.CASCADE) + blocked_by = models.ForeignKey(Issue, related_name="blocked_issues", on_delete=models.CASCADE) + + class Meta: + verbose_name = "Issue Blocker" + verbose_name_plural = "Issue Blockers" + db_table = "issue_blockers" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.block.name} {self.blocked_by.name}" + + +class IssueRelationChoices(models.TextChoices): + DUPLICATE = "duplicate", "Duplicate" + RELATES_TO = "relates_to", "Relates To" + BLOCKED_BY = "blocked_by", "Blocked By" + START_BEFORE = "start_before", "Start Before" + FINISH_BEFORE = "finish_before", "Finish Before" + IMPLEMENTED_BY = "implemented_by", "Implemented By" + + +# Bidirectional relation pairs: (forward, reverse) +# Defined after class to avoid enum metaclass conflicts +IssueRelationChoices._RELATION_PAIRS = ( + ("blocked_by", "blocking"), + ("relates_to", "relates_to"), # symmetric + ("duplicate", "duplicate"), # symmetric + ("start_before", "start_after"), + ("finish_before", "finish_after"), + ("implemented_by", "implements"), +) + +# Generate reverse mapping from pairs +IssueRelationChoices._REVERSE_MAPPING = {forward: reverse for forward, reverse in IssueRelationChoices._RELATION_PAIRS} + + +class IssueRelation(ProjectBaseModel): + issue = models.ForeignKey(Issue, related_name="issue_relation", on_delete=models.CASCADE) + related_issue = models.ForeignKey(Issue, related_name="issue_related", on_delete=models.CASCADE) + relation_type = models.CharField( + max_length=20, + verbose_name="Issue Relation Type", + default=IssueRelationChoices.BLOCKED_BY, + ) + + class Meta: + unique_together = ["issue", "related_issue", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "related_issue"], + condition=Q(deleted_at__isnull=True), + name="issue_relation_unique_issue_related_issue_when_deleted_at_null", + ) + ] + verbose_name = "Issue Relation" + verbose_name_plural = "Issue Relations" + db_table = "issue_relations" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.related_issue.name}" + + +class IssueMention(ProjectBaseModel): + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_mention") + mention = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="issue_mention") + + class Meta: + unique_together = ["issue", "mention", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "mention"], + condition=Q(deleted_at__isnull=True), + name="issue_mention_unique_issue_mention_when_deleted_at_null", + ) + ] + verbose_name = "Issue Mention" + verbose_name_plural = "Issue Mentions" + db_table = "issue_mentions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.mention.email}" + + +class IssueAssignee(ProjectBaseModel): + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_assignee") + assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_assignee", + ) + + class Meta: + unique_together = ["issue", "assignee", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "assignee"], + condition=Q(deleted_at__isnull=True), + name="issue_assignee_unique_issue_assignee_when_deleted_at_null", + ) + ] + verbose_name = "Issue Assignee" + verbose_name_plural = "Issue Assignees" + db_table = "issue_assignees" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.assignee.email}" + + +class IssueLink(ProjectBaseModel): + title = models.CharField(max_length=255, null=True, blank=True) + url = models.TextField() + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_link") + metadata = models.JSONField(default=dict) + + class Meta: + verbose_name = "Issue Link" + verbose_name_plural = "Issue Links" + db_table = "issue_links" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.url}" + + +def get_upload_path(instance, filename): + return f"{instance.workspace.id}/{uuid4().hex}-{filename}" + + +def file_size(value): + # File limit check is only for cloud hosted + if value.size > settings.FILE_SIZE_LIMIT: + raise ValidationError("File too large. Size should not exceed 5 MB.") + + +class IssueAttachment(ProjectBaseModel): + attributes = models.JSONField(default=dict) + asset = models.FileField(upload_to=get_upload_path, validators=[file_size]) + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_attachment") + 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 Attachment" + verbose_name_plural = "Issue Attachments" + db_table = "issue_attachments" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.asset}" + + +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") + 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) + + comment = models.TextField(verbose_name="Comment", blank=True) + attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + issue_comment = models.ForeignKey( + "db.IssueComment", + on_delete=models.SET_NULL, + related_name="issue_comment", + null=True, + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="issue_activities", + ) + old_identifier = models.UUIDField(null=True) + new_identifier = models.UUIDField(null=True) + epoch = models.FloatField(null=True) + + class Meta: + verbose_name = "Issue Activity" + verbose_name_plural = "Issue Activities" + db_table = "issue_activities" + ordering = ("-created_at",) + + def __str__(self): + """Return issue of the comment""" + return str(self.issue) + + +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) + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments") + # System can also create comment + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="comments", + null=True, + ) + access = models.CharField( + choices=(("INTERNAL", "INTERNAL"), ("EXTERNAL", "EXTERNAL")), + default="INTERNAL", + max_length=100, + ) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) + edited_at = models.DateTimeField(null=True, blank=True) + + def save(self, *args, **kwargs): + self.comment_stripped = strip_tags(self.comment_html) if self.comment_html != "" else "" + return super(IssueComment, self).save(*args, **kwargs) + + class Meta: + verbose_name = "Issue Comment" + verbose_name_plural = "Issue Comments" + db_table = "issue_comments" + ordering = ("-created_at",) + + def __str__(self): + """Return issue of the comment""" + return str(self.issue) + + +class IssueUserProperty(ProjectBaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_property_user", + ) + 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) + rich_filters = models.JSONField(default=dict) + + class Meta: + verbose_name = "Issue User Property" + verbose_name_plural = "Issue User Properties" + db_table = "issue_user_properties" + ordering = ("-created_at",) + unique_together = ["user", "project", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["user", "project"], + condition=Q(deleted_at__isnull=True), + name="issue_user_property_unique_user_project_when_deleted_at_null", + ) + ] + + def __str__(self): + """Return properties status of the issue""" + return str(self.user) + + +class IssueLabel(ProjectBaseModel): + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="label_issue") + label = models.ForeignKey("db.Label", on_delete=models.CASCADE, related_name="label_issue") + + class Meta: + verbose_name = "Issue Label" + verbose_name_plural = "Issue Labels" + db_table = "issue_labels" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.label.name}" + + +class IssueSequence(ProjectBaseModel): + issue = models.ForeignKey( + Issue, + on_delete=models.SET_NULL, + related_name="issue_sequence", + null=True, # This is set to null because we want to keep the sequence even if the issue is deleted + ) + sequence = models.PositiveBigIntegerField(default=1, db_index=True) + deleted = models.BooleanField(default=False) + + class Meta: + verbose_name = "Issue Sequence" + verbose_name_plural = "Issue Sequences" + db_table = "issue_sequences" + ordering = ("-created_at",) + + +class IssueSubscriber(ProjectBaseModel): + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_subscribers") + subscriber = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_subscribers", + ) + + class Meta: + unique_together = ["issue", "subscriber", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "subscriber"], + condition=models.Q(deleted_at__isnull=True), + name="issue_subscriber_unique_issue_subscriber_when_deleted_at_null", + ) + ] + verbose_name = "Issue Subscriber" + verbose_name_plural = "Issue Subscribers" + db_table = "issue_subscribers" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.subscriber.email}" + + +class IssueReaction(ProjectBaseModel): + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_reactions", + ) + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_reactions") + reaction = models.TextField() + + class Meta: + unique_together = ["issue", "actor", "reaction", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "actor", "reaction"], + condition=models.Q(deleted_at__isnull=True), + name="issue_reaction_unique_issue_actor_reaction_when_deleted_at_null", + ) + ] + verbose_name = "Issue Reaction" + verbose_name_plural = "Issue Reactions" + db_table = "issue_reactions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.actor.email}" + + +class CommentReaction(ProjectBaseModel): + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="comment_reactions", + ) + comment = models.ForeignKey(IssueComment, on_delete=models.CASCADE, related_name="comment_reactions") + reaction = models.TextField() + + class Meta: + unique_together = ["comment", "actor", "reaction", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["comment", "actor", "reaction"], + condition=models.Q(deleted_at__isnull=True), + name="comment_reaction_unique_comment_actor_reaction_when_deleted_at_null", + ) + ] + verbose_name = "Comment Reaction" + verbose_name_plural = "Comment Reactions" + db_table = "comment_reactions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.actor.email}" + + +class IssueVote(ProjectBaseModel): + 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) + + class Meta: + unique_together = ["issue", "actor", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "actor"], + condition=models.Q(deleted_at__isnull=True), + name="issue_vote_unique_issue_actor_when_deleted_at_null", + ) + ] + verbose_name = "Issue Vote" + verbose_name_plural = "Issue Votes" + db_table = "issue_votes" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.actor.email}" + + +class IssueVersion(ProjectBaseModel): + 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") + 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 = ArrayField(models.UUIDField(), blank=True, default=list) + sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") + labels = ArrayField(models.UUIDField(), blank=True, default=list) + 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) + cycle = models.UUIDField(null=True, blank=True) + modules = ArrayField(models.UUIDField(), blank=True, default=list) + properties = models.JSONField(default=dict) # issue properties + meta = models.JSONField(default=dict) # issue meta + last_saved_at = models.DateTimeField(default=timezone.now) + + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="versions") + activity = models.ForeignKey( + "db.IssueActivity", + on_delete=models.SET_NULL, + null=True, + related_name="versions", + ) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_versions", + ) + + 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") + IssueAssignee = apps.get_model("db.IssueAssignee") + IssueLabel = apps.get_model("db.IssueLabel") + + cycle_issue = CycleIssue.objects.filter(issue=issue).first() + + cls.objects.create( + issue=issue, + parent=issue.parent_id, + state=issue.state_id, + estimate_point=issue.estimate_point_id, + name=issue.name, + priority=issue.priority, + start_date=issue.start_date, + target_date=issue.target_date, + assignees=list(IssueAssignee.objects.filter(issue=issue).values_list("assignee_id", flat=True)), + sequence_id=issue.sequence_id, + labels=list(IssueLabel.objects.filter(issue=issue).values_list("label_id", flat=True)), + 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_id, + cycle=cycle_issue.cycle_id if cycle_issue else None, + modules=list(Module.objects.filter(issue=issue).values_list("id", flat=True)), + properties={}, + meta={}, + last_saved_at=timezone.now(), + owned_by=user, + ) + return True + except Exception as e: + log_exception(e) + return False + + +class IssueDescriptionVersion(ProjectBaseModel): + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="description_versions") + description_binary = models.BinaryField(null=True) + description_html = models.TextField(blank=True, default="

    ") + description_stripped = models.TextField(blank=True, null=True) + description_json = models.JSONField(default=dict, blank=True) + last_saved_at = models.DateTimeField(default=timezone.now) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_description_versions", + ) + + class Meta: + verbose_name = "Issue Description Version" + verbose_name_plural = "Issue Description Versions" + db_table = "issue_description_versions" + + @classmethod + def log_issue_description_version(cls, issue, user): + try: + """ + Log the issue description version + """ + cls.objects.create( + workspace_id=issue.workspace_id, + project_id=issue.project_id, + created_by_id=issue.created_by_id, + updated_by_id=issue.updated_by_id, + owned_by_id=user, + last_saved_at=timezone.now(), + issue_id=issue.id, + description_binary=issue.description_binary, + description_html=issue.description_html, + description_stripped=issue.description_stripped, + description_json=issue.description, + ) + return True + except Exception as e: + log_exception(e) + return False diff --git a/apps/api/plane/db/models/issue_type.py b/apps/api/plane/db/models/issue_type.py new file mode 100644 index 00000000..4f3dc08d --- /dev/null +++ b/apps/api/plane/db/models/issue_type.py @@ -0,0 +1,51 @@ +# Django imports +from django.db import models +from django.db.models import Q + +# Module imports +from .project import ProjectBaseModel +from .base import BaseModel + + +class IssueType(BaseModel): + workspace = models.ForeignKey("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.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" + verbose_name_plural = "Issue Types" + db_table = "issue_types" + + def __str__(self): + return self.name + + +class ProjectIssueType(ProjectBaseModel): + issue_type = models.ForeignKey("db.IssueType", related_name="project_issue_types", on_delete=models.CASCADE) + level = models.PositiveIntegerField(default=0) + is_default = models.BooleanField(default=False) + + class Meta: + unique_together = ["project", "issue_type", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["project", "issue_type"], + condition=Q(deleted_at__isnull=True), + name="project_issue_type_unique_project_issue_type_when_deleted_at_null", + ) + ] + verbose_name = "Project Issue Type" + verbose_name_plural = "Project Issue Types" + db_table = "project_issue_types" + ordering = ("project", "issue_type") + + def __str__(self): + return f"{self.project} - {self.issue_type}" diff --git a/apps/api/plane/db/models/label.py b/apps/api/plane/db/models/label.py new file mode 100644 index 00000000..76ecf10e --- /dev/null +++ b/apps/api/plane/db/models/label.py @@ -0,0 +1,53 @@ +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/apps/api/plane/db/models/module.py b/apps/api/plane/db/models/module.py new file mode 100644 index 00000000..ab62f2df --- /dev/null +++ b/apps/api/plane/db/models/module.py @@ -0,0 +1,213 @@ +# Django imports +from django.conf import settings +from django.db import models +from django.db.models import Q + +# Module imports +from .project import ProjectBaseModel + + +def get_default_filters(): + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } + + +def get_default_display_filters(): + return { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + } + + +def get_default_display_properties(): + return { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + } + + +class ModuleStatus(models.TextChoices): + BACKLOG = "backlog" + PLANNED = "planned" + IN_PROGRESS = "in-progress" + PAUSED = "paused" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class Module(ProjectBaseModel): + name = models.CharField(max_length=255, verbose_name="Module Name") + description = models.TextField(verbose_name="Module Description", blank=True) + description_text = models.JSONField(verbose_name="Module Description RT", blank=True, null=True) + description_html = models.JSONField(verbose_name="Module Description HTML", blank=True, null=True) + start_date = models.DateField(null=True) + target_date = models.DateField(null=True) + status = models.CharField( + choices=( + ("backlog", "Backlog"), + ("planned", "Planned"), + ("in-progress", "In Progress"), + ("paused", "Paused"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ), + default="planned", + max_length=20, + ) + lead = models.ForeignKey("db.User", on_delete=models.SET_NULL, related_name="module_leads", null=True) + members = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="module_members", + through="ModuleMember", + through_fields=("module", "member"), + ) + view_props = models.JSONField(default=dict) + 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) + archived_at = models.DateTimeField(null=True) + logo_props = models.JSONField(default=dict) + + class Meta: + unique_together = ["name", "project", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["name", "project"], + condition=Q(deleted_at__isnull=True), + name="module_unique_name_project_when_deleted_at_null", + ) + ] + verbose_name = "Module" + verbose_name_plural = "Modules" + db_table = "modules" + ordering = ("-created_at",) + + 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"] + + if smallest_sort_order is not None: + self.sort_order = smallest_sort_order - 10000 + + super(Module, self).save(*args, **kwargs) + + def __str__(self): + return f"{self.name} {self.start_date} {self.target_date}" + + +class ModuleMember(ProjectBaseModel): + module = models.ForeignKey("db.Module", on_delete=models.CASCADE) + member = models.ForeignKey("db.User", on_delete=models.CASCADE) + + class Meta: + unique_together = ["module", "member", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["module", "member"], + condition=models.Q(deleted_at__isnull=True), + name="module_member_unique_module_member_when_deleted_at_null", + ) + ] + verbose_name = "Module Member" + verbose_name_plural = "Module Members" + db_table = "module_members" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.member}" + + +class ModuleIssue(ProjectBaseModel): + module = models.ForeignKey("db.Module", on_delete=models.CASCADE, related_name="issue_module") + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_module") + + class Meta: + unique_together = ["issue", "module", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "module"], + condition=models.Q(deleted_at__isnull=True), + name="module_issue_unique_issue_module_when_deleted_at_null", + ) + ] + verbose_name = "Module Issue" + verbose_name_plural = "Module Issues" + db_table = "module_issues" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.issue.name}" + + +class ModuleLink(ProjectBaseModel): + title = models.CharField(max_length=255, blank=True, null=True) + url = models.URLField() + module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name="link_module") + metadata = models.JSONField(default=dict) + + class Meta: + verbose_name = "Module Link" + verbose_name_plural = "Module Links" + db_table = "module_links" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.url}" + + +class ModuleUserProperties(ProjectBaseModel): + module = models.ForeignKey("db.Module", on_delete=models.CASCADE, related_name="module_user_properties") + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="module_user_properties", + ) + 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) + rich_filters = models.JSONField(default=dict) + + class Meta: + unique_together = ["module", "user", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["module", "user"], + condition=models.Q(deleted_at__isnull=True), + name="module_user_properties_unique_module_user_when_deleted_at_null", + ) + ] + verbose_name = "Module User Property" + verbose_name_plural = "Module User Property" + db_table = "module_user_properties" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.user.email}" diff --git a/apps/api/plane/db/models/notification.py b/apps/api/plane/db/models/notification.py new file mode 100644 index 00000000..aa58bc30 --- /dev/null +++ b/apps/api/plane/db/models/notification.py @@ -0,0 +1,125 @@ +# Django imports +from django.conf import settings +from django.db import models + +# Module imports +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) + data = models.JSONField(null=True) + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField(max_length=255) + title = models.TextField() + message = models.JSONField(null=True) + message_html = models.TextField(blank=True, default="

    ") + message_stripped = models.TextField(blank=True, null=True) + sender = models.CharField(max_length=255) + triggered_by = models.ForeignKey( + "db.User", + related_name="triggered_notifications", + on_delete=models.SET_NULL, + null=True, + ) + receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE) + read_at = models.DateTimeField(null=True) + snoozed_till = models.DateTimeField(null=True) + archived_at = models.DateTimeField(null=True) + + class Meta: + verbose_name = "Notification" + verbose_name_plural = "Notifications" + db_table = "notifications" + ordering = ("-created_at",) + indexes = [ + models.Index(fields=["entity_identifier"], name="notif_entity_identifier_idx"), + models.Index(fields=["entity_name"], name="notif_entity_name_idx"), + models.Index(fields=["read_at"], name="notif_read_at_idx"), + models.Index(fields=["receiver", "read_at"], name="notif_entity_idx"), + ] + + def __str__(self): + """Return name of the notifications""" + return f"{self.receiver.email} <{self.workspace.name}>" + + +def get_default_preference(): + return { + "property_change": {"email": True}, + "state": {"email": True}, + "comment": {"email": True}, + "mentions": {"email": True}, + } + + +class UserNotificationPreference(BaseModel): + # user it is related to + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="notification_preferences", + ) + # workspace if it is applicable + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_notification_preferences", + null=True, + ) + # project + project = models.ForeignKey( + "db.Project", + on_delete=models.CASCADE, + related_name="project_notification_preferences", + null=True, + ) + + # preference fields + property_change = models.BooleanField(default=True) + state_change = models.BooleanField(default=True) + comment = models.BooleanField(default=True) + mention = models.BooleanField(default=True) + issue_completed = models.BooleanField(default=True) + + class Meta: + verbose_name = "UserNotificationPreference" + verbose_name_plural = "UserNotificationPreferences" + db_table = "user_notification_preferences" + ordering = ("-created_at",) + + def __str__(self): + """Return the user""" + return f"<{self.user}>" + + +class EmailNotificationLog(BaseModel): + # receiver + receiver = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="email_notifications", + ) + triggered_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="triggered_emails", + ) + # entity - can be issues, pages, etc. + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField(max_length=255) + # data + data = models.JSONField(null=True) + # sent at + processed_at = models.DateTimeField(null=True) + sent_at = models.DateTimeField(null=True) + entity = models.CharField(max_length=200) + old_value = models.CharField(max_length=300, blank=True, null=True) + new_value = models.CharField(max_length=300, blank=True, null=True) + + class Meta: + verbose_name = "Email Notification Log" + verbose_name_plural = "Email Notification Logs" + db_table = "email_notification_logs" + ordering = ("-created_at",) diff --git a/apps/api/plane/db/models/page.py b/apps/api/plane/db/models/page.py new file mode 100644 index 00000000..213954d1 --- /dev/null +++ b/apps/api/plane/db/models/page.py @@ -0,0 +1,178 @@ +import uuid + +from django.conf import settings +from django.utils import timezone + +# Django imports +from django.db import models + +# Module imports +from plane.utils.html_processor import strip_tags + +from .base import BaseModel + + +def get_view_props(): + return {"full_width": False} + + +class Page(BaseModel): + PRIVATE_ACCESS = 1 + PUBLIC_ACCESS = 0 + DEFAULT_SORT_ORDER = 65535 + + ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public")) + + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="pages") + name = models.TextField(blank=True) + description = models.JSONField(default=dict, blank=True) + description_binary = models.BinaryField(null=True) + 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") + access = models.PositiveSmallIntegerField(choices=((0, "Public"), (1, "Private")), default=0) + color = models.CharField(max_length=255, blank=True) + labels = models.ManyToManyField("db.Label", blank=True, related_name="pages", through="db.PageLabel") + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="child_page", + ) + archived_at = models.DateField(null=True) + is_locked = models.BooleanField(default=False) + view_props = models.JSONField(default=get_view_props) + logo_props = models.JSONField(default=dict) + is_global = models.BooleanField(default=False) + projects = models.ManyToManyField("db.Project", related_name="pages", through="db.ProjectPage") + moved_to_page = models.UUIDField(null=True, blank=True) + moved_to_project = models.UUIDField(null=True, blank=True) + sort_order = models.FloatField(default=DEFAULT_SORT_ORDER) + + external_id = models.CharField(max_length=255, null=True, blank=True) + external_source = models.CharField(max_length=255, null=True, blank=True) + + class Meta: + verbose_name = "Page" + verbose_name_plural = "Pages" + db_table = "pages" + ordering = ("-created_at",) + + def __str__(self): + """Return owner email and page name""" + return f"{self.owned_by.email} <{self.name}>" + + def save(self, *args, **kwargs): + # 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(Page, self).save(*args, **kwargs) + + +class PageLog(BaseModel): + TYPE_CHOICES = ( + ("to_do", "To Do"), + ("issue", "issue"), + ("image", "Image"), + ("video", "Video"), + ("file", "File"), + ("link", "Link"), + ("cycle", "Cycle"), + ("module", "Module"), + ("back_link", "Back Link"), + ("forward_link", "Forward Link"), + ("page_mention", "Page Mention"), + ("user_mention", "User Mention"), + ) + transaction = models.UUIDField(default=uuid.uuid4) + page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE) + entity_identifier = models.UUIDField(null=True, blank=True) + entity_name = models.CharField(max_length=30, verbose_name="Transaction Type") + entity_type = models.CharField(max_length=30, verbose_name="Entity Type", null=True, blank=True) + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log") + + class Meta: + unique_together = ["page", "transaction"] + verbose_name = "Page Log" + verbose_name_plural = "Page Logs" + db_table = "page_logs" + ordering = ("-created_at",) + indexes = [ + models.Index(fields=["entity_type"], name="pagelog_entity_type_idx"), + models.Index(fields=["entity_identifier"], name="pagelog_entity_id_idx"), + models.Index(fields=["entity_name"], name="pagelog_entity_name_idx"), + models.Index(fields=["entity_type", "entity_identifier"], name="pagelog_type_id_idx"), + models.Index(fields=["entity_name", "entity_identifier"], name="pagelog_name_id_idx"), + ] + + def __str__(self): + return f"{self.page.name} {self.entity_name}" + + +class PageLabel(BaseModel): + label = models.ForeignKey("db.Label", on_delete=models.CASCADE, related_name="page_labels") + page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="page_labels") + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_label") + + class Meta: + verbose_name = "Page Label" + verbose_name_plural = "Page Labels" + db_table = "page_labels" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.page.name} {self.label.name}" + + +class ProjectPage(BaseModel): + project = models.ForeignKey("db.Project", on_delete=models.CASCADE, related_name="project_pages") + page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="project_pages") + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="project_pages") + + class Meta: + unique_together = ["project", "page", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["project", "page"], + condition=models.Q(deleted_at__isnull=True), + name="project_page_unique_project_page_when_deleted_at_null", + ) + ] + verbose_name = "Project Page" + verbose_name_plural = "Project Pages" + db_table = "project_pages" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.project.name} {self.page.name}" + + +class PageVersion(BaseModel): + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="page_versions") + page = models.ForeignKey("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") + description_binary = models.BinaryField(null=True) + description_html = models.TextField(blank=True, default="

    ") + description_stripped = models.TextField(blank=True, null=True) + description_json = models.JSONField(default=dict, blank=True) + sub_pages_data = models.JSONField(default=dict, blank=True) + + class Meta: + verbose_name = "Page Version" + verbose_name_plural = "Page Versions" + db_table = "page_versions" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + # 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(PageVersion, self).save(*args, **kwargs) diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py new file mode 100644 index 00000000..ed5a0877 --- /dev/null +++ b/apps/api/plane/db/models/project.py @@ -0,0 +1,315 @@ +# Python imports +import pytz +from uuid import uuid4 +from enum import Enum + +# Django imports +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import Q + +# Module imports +from plane.db.mixins import AuditModel + +# Module imports +from .base import BaseModel + +ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest")) + + +class ROLE(Enum): + ADMIN = 20 + MEMBER = 15 + GUEST = 5 + + +class ProjectNetwork(Enum): + SECRET = 0 + PUBLIC = 2 + + @classmethod + def choices(cls): + return [(0, "Secret"), (2, "Public")] + + +def get_default_props(): + return { + "filters": { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + }, + "display_filters": { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + }, + } + + +def get_default_preferences(): + return {"pages": {"block_display": True}} + + +class Project(BaseModel): + NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) + name = models.CharField(max_length=255, verbose_name="Project Name") + description = models.TextField(verbose_name="Project Description", blank=True) + description_text = models.JSONField(verbose_name="Project Description RT", blank=True, null=True) + description_html = models.JSONField(verbose_name="Project Description HTML", blank=True, null=True) + network = models.PositiveSmallIntegerField(default=2, choices=NETWORK_CHOICES) + workspace = models.ForeignKey("db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project") + identifier = models.CharField(max_length=12, verbose_name="Project Identifier", db_index=True) + default_assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="default_assignee", + null=True, + blank=True, + ) + project_lead = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="project_lead", + null=True, + blank=True, + ) + emoji = models.CharField(max_length=255, null=True, blank=True) + icon_prop = models.JSONField(null=True) + module_view = models.BooleanField(default=False) + cycle_view = models.BooleanField(default=False) + issue_views_view = models.BooleanField(default=False) + page_view = models.BooleanField(default=True) + 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.TextField(blank=True, null=True) + cover_image_asset = models.ForeignKey( + "db.FileAsset", + on_delete=models.SET_NULL, + 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)]) + close_in = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]) + logo_props = models.JSONField(default=dict) + default_state = models.ForeignKey("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.common_timezones, pytz.common_timezones)) + timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) + # external_id for imports + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) + + @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""" + return f"{self.name} <{self.workspace.name}>" + + class Meta: + unique_together = [ + ["identifier", "workspace", "deleted_at"], + ["name", "workspace", "deleted_at"], + ] + constraints = [ + models.UniqueConstraint( + fields=["identifier", "workspace"], + condition=Q(deleted_at__isnull=True), + name="project_unique_identifier_workspace_when_deleted_at_null", + ), + models.UniqueConstraint( + fields=["name", "workspace"], + condition=Q(deleted_at__isnull=True), + name="project_unique_name_workspace_when_deleted_at_null", + ), + ] + verbose_name = "Project" + verbose_name_plural = "Projects" + db_table = "projects" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + self.identifier = self.identifier.strip().upper() + return super().save(*args, **kwargs) + + +class ProjectBaseModel(BaseModel): + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="project_%(class)s") + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_%(class)s") + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + self.workspace = self.project.workspace + super(ProjectBaseModel, self).save(*args, **kwargs) + + +class ProjectMemberInvite(ProjectBaseModel): + email = models.CharField(max_length=255) + accepted = models.BooleanField(default=False) + token = models.CharField(max_length=255) + message = models.TextField(null=True) + responded_at = models.DateTimeField(null=True) + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5) + + class Meta: + verbose_name = "Project Member Invite" + verbose_name_plural = "Project Member Invites" + db_table = "project_member_invites" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.project.name} {self.email} {self.accepted}" + + +class ProjectMember(ProjectBaseModel): + member = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="member_project", + ) + comment = models.TextField(blank=True, null=True) + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5) + view_props = models.JSONField(default=get_default_props) + default_props = models.JSONField(default=get_default_props) + preferences = models.JSONField(default=get_default_preferences) + sort_order = models.FloatField(default=65535) + is_active = models.BooleanField(default=True) + + def save(self, *args, **kwargs): + if self._state.adding: + smallest_sort_order = ProjectMember.objects.filter( + workspace_id=self.project.workspace_id, member=self.member + ).aggregate(smallest=models.Min("sort_order"))["smallest"] + + # Project ordering + if smallest_sort_order is not None: + self.sort_order = smallest_sort_order - 10000 + + super(ProjectMember, self).save(*args, **kwargs) + + class Meta: + unique_together = ["project", "member", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["project", "member"], + condition=Q(deleted_at__isnull=True), + name="project_member_unique_project_member_when_deleted_at_null", + ) + ] + verbose_name = "Project Member" + verbose_name_plural = "Project Members" + db_table = "project_members" + ordering = ("-created_at",) + + def __str__(self): + """Return members of the project""" + return f"{self.member.email} <{self.project.name}>" + + +# TODO: Remove workspace relation later +class ProjectIdentifier(AuditModel): + workspace = models.ForeignKey("db.Workspace", models.CASCADE, related_name="project_identifiers", null=True) + project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="project_identifier") + name = models.CharField(max_length=12, db_index=True) + + class Meta: + unique_together = ["name", "workspace", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["name", "workspace"], + condition=Q(deleted_at__isnull=True), + name="unique_name_workspace_when_deleted_at_null", + ) + ] + verbose_name = "Project Identifier" + verbose_name_plural = "Project Identifiers" + db_table = "project_identifiers" + ordering = ("-created_at",) + + +def get_anchor(): + return uuid4().hex + + +def get_default_views(): + return { + "list": True, + "kanban": True, + "calendar": True, + "gantt": True, + "spreadsheet": True, + } + + +# DEPRECATED TODO: +# used to get the old anchors for the project deploy boards +class ProjectDeployBoard(ProjectBaseModel): + anchor = models.CharField(max_length=255, default=get_anchor, unique=True, db_index=True) + comments = models.BooleanField(default=False) + reactions = models.BooleanField(default=False) + 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) + + class Meta: + unique_together = ["project", "anchor"] + verbose_name = "Project Deploy Board" + verbose_name_plural = "Project Deploy Boards" + db_table = "project_deploy_boards" + ordering = ("-created_at",) + + def __str__(self): + """Return project and anchor""" + return f"{self.anchor} <{self.project.name}>" + + +class ProjectPublicMember(ProjectBaseModel): + member = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="public_project_members", + ) + + class Meta: + unique_together = ["project", "member", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["project", "member"], + condition=models.Q(deleted_at__isnull=True), + name="project_public_member_unique_project_member_when_deleted_at_null", + ) + ] + verbose_name = "Project Public Member" + verbose_name_plural = "Project Public Members" + db_table = "project_public_members" + ordering = ("-created_at",) diff --git a/apps/api/plane/db/models/recent_visit.py b/apps/api/plane/db/models/recent_visit.py new file mode 100644 index 00000000..42855081 --- /dev/null +++ b/apps/api/plane/db/models/recent_visit.py @@ -0,0 +1,35 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from .workspace import WorkspaceBaseModel + + +class EntityNameEnum(models.TextChoices): + VIEW = "VIEW", "View" + PAGE = "PAGE", "Page" + ISSUE = "ISSUE", "Issue" + CYCLE = "CYCLE", "Cycle" + MODULE = "MODULE", "Module" + PROJECT = "PROJECT", "Project" + + +class UserRecentVisit(WorkspaceBaseModel): + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField(max_length=30) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="user_recent_visit", + ) + visited_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "User Recent Visit" + verbose_name_plural = "User Recent Visits" + db_table = "user_recent_visits" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.entity_name} {self.user.email}" diff --git a/apps/api/plane/db/models/session.py b/apps/api/plane/db/models/session.py new file mode 100644 index 00000000..e884498b --- /dev/null +++ b/apps/api/plane/db/models/session.py @@ -0,0 +1,52 @@ +# Python imports +import string + +# Django imports +from django.contrib.sessions.backends.db import SessionStore as DBSessionStore +from django.contrib.sessions.base_session import AbstractBaseSession +from django.db import models +from django.utils.crypto import get_random_string + +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, db_index=True) + + @classmethod + def get_session_store_class(cls): + return SessionStore + + class Meta(AbstractBaseSession.Meta): + db_table = "sessions" + + +class SessionStore(DBSessionStore): + @classmethod + def get_model_class(cls): + return Session + + def _get_new_session_key(self): + """ + Return a new session key that is not present in the current backend. + Override this method to use a custom session key generation mechanism. + """ + while True: + session_key = get_random_string(128, VALID_KEY_CHARS) + if not self.exists(session_key): + return session_key + + def create_model_instance(self, data): + obj = super().create_model_instance(data) + try: + user_id = data.get("_auth_user_id") + except (ValueError, TypeError): + user_id = None + obj.user_id = user_id + + # Save the device info + device_info = data.get("device_info") + obj.device_info = device_info if isinstance(device_info, dict) else None + return obj diff --git a/apps/api/plane/db/models/social_connection.py b/apps/api/plane/db/models/social_connection.py new file mode 100644 index 00000000..9a85a320 --- /dev/null +++ b/apps/api/plane/db/models/social_connection.py @@ -0,0 +1,39 @@ +# Django imports +from django.conf import settings +from django.db import models +from django.utils import timezone + +# Module import +from .base import BaseModel + + +class SocialLoginConnection(BaseModel): + medium = models.CharField( + max_length=20, + choices=( + ("Google", "google"), + ("Github", "github"), + ("GitLab", "gitlab"), + ("Jira", "jira"), + ), + default=None, + ) + last_login_at = models.DateTimeField(default=timezone.now, null=True) + last_received_at = models.DateTimeField(default=timezone.now, null=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="user_login_connections", + ) + token_data = models.JSONField(null=True) + extra_data = models.JSONField(null=True) + + class Meta: + verbose_name = "Social Login Connection" + verbose_name_plural = "Social Login Connections" + db_table = "social_login_connections" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the user and medium""" + return f"{self.medium} <{self.user.email}>" diff --git a/apps/api/plane/db/models/state.py b/apps/api/plane/db/models/state.py new file mode 100644 index 00000000..e9d56acf --- /dev/null +++ b/apps/api/plane/db/models/state.py @@ -0,0 +1,60 @@ +# Django imports +from django.db import models +from django.template.defaultfilters import slugify +from django.db.models import Q + +# Module imports +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) + color = models.CharField(max_length=255, verbose_name="State Color") + slug = models.SlugField(max_length=100, blank=True) + sequence = models.FloatField(default=65535) + group = models.CharField( + choices=( + ("backlog", "Backlog"), + ("unstarted", "Unstarted"), + ("started", "Started"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ("triage", "Triage"), + ), + default="backlog", + max_length=20, + ) + is_triage = models.BooleanField(default=False) + default = 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) + + def __str__(self): + """Return name of the state""" + return f"{self.name} <{self.project.name}>" + + class Meta: + unique_together = ["name", "project", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["name", "project"], + condition=Q(deleted_at__isnull=True), + name="state_unique_name_project_when_deleted_at_null", + ) + ] + verbose_name = "State" + verbose_name_plural = "States" + db_table = "states" + ordering = ("sequence",) + + def save(self, *args, **kwargs): + self.slug = slugify(self.name) + if self._state.adding: + # Get the maximum sequence value from the database + last_id = State.objects.filter(project=self.project).aggregate(largest=models.Max("sequence"))["largest"] + # if last_id is not None + if last_id is not None: + self.sequence = last_id + 15000 + + return super().save(*args, **kwargs) diff --git a/apps/api/plane/db/models/sticky.py b/apps/api/plane/db/models/sticky.py new file mode 100644 index 00000000..157077eb --- /dev/null +++ b/apps/api/plane/db/models/sticky.py @@ -0,0 +1,53 @@ +# Django imports +from django.conf import settings +from django.db import models + +# Module imports +from .base import BaseModel + +# Third party imports +from plane.utils.html_processor import strip_tags + + +class Sticky(BaseModel): + name = models.TextField(null=True, blank=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) + + logo_props = models.JSONField(default=dict) + color = models.CharField(max_length=255, blank=True, null=True) + background_color = models.CharField(max_length=255, blank=True, null=True) + + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="stickies") + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies") + sort_order = models.FloatField(default=65535) + + class Meta: + verbose_name = "Sticky" + verbose_name_plural = "Stickies" + db_table = "stickies" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + # 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._state.adding: + # Get the maximum sequence value from the database + last_id = Sticky.objects.filter(workspace=self.workspace).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(Sticky, self).save(*args, **kwargs) + + def __str__(self): + return str(self.name) diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py new file mode 100644 index 00000000..c9f0df9b --- /dev/null +++ b/apps/api/plane/db/models/user.py @@ -0,0 +1,268 @@ +# Python imports +import random +import string +import uuid + +import pytz +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, UserManager + +# Django imports +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils import timezone + +# Module imports +from plane.db.models import FileAsset +from ..mixins import TimeAuditModel +from plane.utils.color import get_random_color + + +def get_default_onboarding(): + return { + "profile_complete": False, + "workspace_create": False, + "workspace_invite": False, + "workspace_join": False, + } + + +def get_mobile_default_onboarding(): + return { + "profile_complete": False, + "workspace_create": False, + "workspace_join": False, + } + + +class User(AbstractBaseUser, PermissionsMixin): + id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True) + username = models.CharField(max_length=128, unique=True) + # user fields + mobile_number = models.CharField(max_length=255, blank=True, null=True) + email = models.CharField(max_length=255, null=True, blank=True, unique=True) + + # 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") + last_location = models.CharField(max_length=255, blank=True) + created_location = models.CharField(max_length=255, blank=True) + + # the is' es + is_superuser = models.BooleanField(default=False) + is_managed = models.BooleanField(default=False) + is_password_expired = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + is_email_verified = models.BooleanField(default=False) + is_password_autoset = models.BooleanField(default=False) + + # random token generated + token = models.CharField(max_length=64, blank=True) + + last_active = models.DateTimeField(default=timezone.now, null=True) + last_login_time = models.DateTimeField(null=True) + last_logout_time = models.DateTimeField(null=True) + last_login_ip = models.CharField(max_length=255, blank=True) + last_logout_ip = models.CharField(max_length=255, blank=True) + last_login_medium = models.CharField(max_length=20, default="email") + last_login_uagent = models.TextField(blank=True) + token_updated_at = models.DateTimeField(null=True) + # 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.common_timezones, pytz.common_timezones)) + user_timezone = models.CharField(max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES) + + # email validation + is_email_valid = models.BooleanField(default=False) + + # masking + masked_at = models.DateTimeField(null=True) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["username"] + + objects = UserManager() + + class Meta: + verbose_name = "User" + verbose_name_plural = "Users" + db_table = "users" + ordering = ("-created_at",) + + 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 + + if self.token_updated_at is not None: + self.token = uuid.uuid4().hex + uuid.uuid4().hex + self.token_updated_at = timezone.now() + + if not self.display_name: + self.display_name = ( + self.email.split("@")[0] + if len(self.email.split("@")) + else "".join(random.choice(string.ascii_letters) for _ in range(6)) + ) + + if self.is_superuser: + self.is_staff = True + + super(User, self).save(*args, **kwargs) + + +class Profile(TimeAuditModel): + SUNDAY = 0 + MONDAY = 1 + TUESDAY = 2 + WEDNESDAY = 3 + THURSDAY = 4 + FRIDAY = 5 + SATURDAY = 6 + + START_OF_THE_WEEK_CHOICES = ( + (SUNDAY, "Sunday"), + (MONDAY, "Monday"), + (TUESDAY, "Tuesday"), + (WEDNESDAY, "Wednesday"), + (THURSDAY, "Thursday"), + (FRIDAY, "Friday"), + (SATURDAY, "Saturday"), + ) + + id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True) + # User + user = models.OneToOneField("db.User", on_delete=models.CASCADE, related_name="profile") + # General + theme = models.JSONField(default=dict) + is_app_rail_docked = models.BooleanField(default=True) + # Onboarding + is_tour_completed = models.BooleanField(default=False) + onboarding_step = models.JSONField(default=get_default_onboarding) + use_case = models.TextField(blank=True, null=True) + role = models.CharField(max_length=300, null=True, blank=True) # job role + is_onboarded = models.BooleanField(default=False) + # Last visited workspace + last_workspace_id = models.UUIDField(null=True) + # address data + billing_address_country = models.CharField(max_length=255, default="INDIA") + billing_address = models.JSONField(null=True) + has_billing_address = models.BooleanField(default=False) + company_name = models.CharField(max_length=255, blank=True) + + is_smooth_cursor_enabled = models.BooleanField(default=False) + # mobile + is_mobile_onboarded = models.BooleanField(default=False) + mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding) + mobile_timezone_auto_set = models.BooleanField(default=False) + # language + language = models.CharField(max_length=255, default="en") + start_of_the_week = models.PositiveSmallIntegerField(choices=START_OF_THE_WEEK_CHOICES, default=SUNDAY) + goals = models.JSONField(default=dict) + background_color = models.CharField(max_length=255, default=get_random_color) + + # marketing + has_marketing_email_consent = models.BooleanField(default=False) + + class Meta: + verbose_name = "Profile" + verbose_name_plural = "Profiles" + db_table = "profiles" + ordering = ("-created_at",) + + +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) + user = models.ForeignKey("db.User", on_delete=models.CASCADE, related_name="accounts") + provider_account_id = models.CharField(max_length=255) + 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) + refresh_token_expired_at = models.DateTimeField(null=True) + last_connected_at = models.DateTimeField(default=timezone.now) + id_token = models.TextField(blank=True) + metadata = models.JSONField(default=dict) + + class Meta: + unique_together = ["provider", "provider_account_id"] + verbose_name = "Account" + verbose_name_plural = "Accounts" + db_table = "accounts" + ordering = ("-created_at",) + + +@receiver(post_save, sender=User) +def create_user_notification(sender, instance, created, **kwargs): + # create preferences + if created and not instance.is_bot: + # Module imports + from plane.db.models import UserNotificationPreference + + UserNotificationPreference.objects.create( + user=instance, + property_change=True, + state_change=True, + comment=True, + mention=True, + issue_completed=True, + ) diff --git a/apps/api/plane/db/models/view.py b/apps/api/plane/db/models/view.py new file mode 100644 index 00000000..d430cd5f --- /dev/null +++ b/apps/api/plane/db/models/view.py @@ -0,0 +1,94 @@ +# Django imports +from django.conf import settings +from django.db import models + +# Module import +from .workspace import WorkspaceBaseModel +from plane.utils.issue_filters import issue_filters + + +def get_default_filters(): + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } + + +def get_default_display_filters(): + return { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + } + + +def get_default_display_properties(): + return { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + } + + +class IssueView(WorkspaceBaseModel): + 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") + filters = models.JSONField(default=dict) + display_filters = models.JSONField(default=get_default_display_filters) + display_properties = models.JSONField(default=get_default_display_properties) + rich_filters = models.JSONField(default=dict) + 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") + is_locked = models.BooleanField(default=False) + + class Meta: + verbose_name = "Issue View" + verbose_name_plural = "Issue Views" + db_table = "issue_views" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + query_params = self.filters + self.query = issue_filters(query_params, "POST") if query_params else {} + + if self._state.adding: + if self.project: + largest_sort_order = IssueView.objects.filter(project=self.project).aggregate( + largest=models.Max("sort_order") + )["largest"] + else: + largest_sort_order = IssueView.objects.filter(workspace=self.workspace, project__isnull=True).aggregate( + largest=models.Max("sort_order") + )["largest"] + if largest_sort_order is not None: + self.sort_order = largest_sort_order + 10000 + + super(IssueView, self).save(*args, **kwargs) + + def __str__(self): + """Return name of the View""" + return f"{self.name} <{self.project.name}>" diff --git a/apps/api/plane/db/models/webhook.py b/apps/api/plane/db/models/webhook.py new file mode 100644 index 00000000..8872d0bb --- /dev/null +++ b/apps/api/plane/db/models/webhook.py @@ -0,0 +1,104 @@ +# Python imports +from uuid import uuid4 +from urllib.parse import urlparse + +# Django imports +from django.db import models +from django.core.exceptions import ValidationError + +# Module imports +from plane.db.models import BaseModel, ProjectBaseModel + + +def generate_token(): + return "plane_wh_" + uuid4().hex + + +def validate_schema(value): + parsed_url = urlparse(value) + if parsed_url.scheme not in ["http", "https"]: + raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.") + + +def validate_domain(value): + parsed_url = urlparse(value) + domain = parsed_url.netloc + if domain in ["localhost", "127.0.0.1"]: + raise ValidationError("Local URLs are not allowed.") + + +class Webhook(BaseModel): + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks") + url = models.URLField(validators=[validate_schema, validate_domain], max_length=1024) + is_active = models.BooleanField(default=True) + secret_key = models.CharField(max_length=255, default=generate_token) + project = models.BooleanField(default=False) + issue = models.BooleanField(default=False) + module = models.BooleanField(default=False) + cycle = models.BooleanField(default=False) + issue_comment = models.BooleanField(default=False) + is_internal = models.BooleanField(default=False) + + def __str__(self): + return f"{self.workspace.slug} {self.url}" + + class Meta: + unique_together = ["workspace", "url", "deleted_at"] + verbose_name = "Webhook" + verbose_name_plural = "Webhooks" + db_table = "webhooks" + ordering = ("-created_at",) + constraints = [ + models.UniqueConstraint( + fields=["workspace", "url"], + condition=models.Q(deleted_at__isnull=True), + name="webhook_url_unique_url_when_deleted_at_null", + ) + ] + + +class WebhookLog(BaseModel): + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs") + # Associated webhook + webhook = models.UUIDField() + + # Basic request details + event_type = models.CharField(max_length=255, blank=True, null=True) + request_method = models.CharField(max_length=10, blank=True, null=True) + request_headers = models.TextField(blank=True, null=True) + request_body = models.TextField(blank=True, null=True) + + # Response details + response_status = models.TextField(blank=True, null=True) + response_headers = models.TextField(blank=True, null=True) + response_body = models.TextField(blank=True, null=True) + + # Retry Count + retry_count = models.PositiveSmallIntegerField(default=0) + + class Meta: + verbose_name = "Webhook Log" + verbose_name_plural = "Webhook Logs" + db_table = "webhook_logs" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.event_type} {str(self.webhook)}" + + +class ProjectWebhook(ProjectBaseModel): + webhook = models.ForeignKey("db.Webhook", on_delete=models.CASCADE, related_name="project_webhooks") + + class Meta: + unique_together = ["project", "webhook", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["project", "webhook"], + condition=models.Q(deleted_at__isnull=True), + name="project_webhook_unique_project_webhook_when_deleted_at_null", + ) + ] + verbose_name = "Project Webhook" + verbose_name_plural = "Project Webhooks" + db_table = "project_webhooks" + ordering = ("-created_at",) diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py new file mode 100644 index 00000000..0f5c0976 --- /dev/null +++ b/apps/api/plane/db/models/workspace.py @@ -0,0 +1,437 @@ +# Python imports +import pytz +from typing import Optional, Any + +# Django imports +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models + +# Module imports +from .base import BaseModel +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +from plane.utils.color import get_random_color + +ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest")) + + +def get_default_props(): + return { + "filters": { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + }, + "display_filters": { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + }, + "display_properties": { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + }, + } + + +def get_default_filters(): + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } + + +def get_default_display_filters(): + return { + "display_filters": { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + } + } + + +def get_default_display_properties(): + return { + "display_properties": { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + } + } + + +def get_issue_props(): + return {"subscribed": True, "assigned": True, "created": True, "all_issues": True} + + +def slug_validator(value): + if value in RESTRICTED_WORKSPACE_SLUGS: + raise ValidationError("Slug is not valid") + + +class Workspace(BaseModel): + TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones)) + + name = models.CharField(max_length=80, verbose_name="Workspace Name") + 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]) + organization_size = models.CharField(max_length=20, blank=True, null=True) + timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) + background_color = models.CharField(max_length=255, default=get_random_color) + + 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 + + def delete(self, using: Optional[str] = None, soft: bool = True, *args: Any, **kwargs: Any): + """ + Override the delete method to append epoch timestamp to the slug when soft deleting. + + Args: + using: The database alias to use for the deletion. + soft: Whether to perform a soft delete (True) or hard delete (False). + *args: Additional positional arguments. + **kwargs: Additional keyword arguments. + """ + # Call the parent class's delete method first + result = super().delete(using=using, soft=soft, *args, **kwargs) + + # If it's a soft delete and the model still exists (not hard deleted) + if soft and hasattr(self, "deleted_at") and self.deleted_at: + # Use the deleted_at timestamp to update the slug + deletion_timestamp: int = int(self.deleted_at.timestamp()) + self.slug = f"{self.slug}__{deletion_timestamp}" + self.save(update_fields=["slug"]) + + return result + + class Meta: + verbose_name = "Workspace" + verbose_name_plural = "Workspaces" + db_table = "workspaces" + ordering = ("-created_at",) + + +class WorkspaceBaseModel(BaseModel): + workspace = models.ForeignKey("db.Workspace", models.CASCADE, related_name="workspace_%(class)s") + project = models.ForeignKey("db.Project", models.CASCADE, related_name="project_%(class)s", null=True) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + if self.project: + self.workspace = self.project.workspace + super(WorkspaceBaseModel, self).save(*args, **kwargs) + + +class WorkspaceMember(BaseModel): + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_member") + member = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="member_workspace", + ) + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5) + company_role = models.TextField(null=True, blank=True) + view_props = models.JSONField(default=get_default_props) + default_props = models.JSONField(default=get_default_props) + issue_props = models.JSONField(default=get_issue_props) + is_active = models.BooleanField(default=True) + + class Meta: + unique_together = ["workspace", "member", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["workspace", "member"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_member_unique_workspace_member_when_deleted_at_null", + ) + ] + verbose_name = "Workspace Member" + verbose_name_plural = "Workspace Members" + db_table = "workspace_members" + ordering = ("-created_at",) + + def __str__(self): + """Return members of the workspace""" + return f"{self.member.email} <{self.workspace.name}>" + + +class WorkspaceMemberInvite(BaseModel): + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite") + email = models.CharField(max_length=255) + accepted = models.BooleanField(default=False) + token = models.CharField(max_length=255) + message = models.TextField(null=True) + responded_at = models.DateTimeField(null=True) + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5) + + class Meta: + unique_together = ["email", "workspace", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["email", "workspace"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_member_invite_unique_email_workspace_when_deleted_at_null", + ) + ] + verbose_name = "Workspace Member Invite" + verbose_name_plural = "Workspace Member Invites" + db_table = "workspace_member_invites" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.name} {self.email} {self.accepted}" + + +class Team(BaseModel): + name = models.CharField(max_length=255, verbose_name="Team Name") + description = models.TextField(verbose_name="Team Description", blank=True) + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="workspace_team") + logo_props = models.JSONField(default=dict) + + def __str__(self): + """Return name of the team""" + return f"{self.name} <{self.workspace.name}>" + + class Meta: + unique_together = ["name", "workspace", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["name", "workspace"], + condition=models.Q(deleted_at__isnull=True), + name="team_unique_name_workspace_when_deleted_at_null", + ) + ] + verbose_name = "Team" + verbose_name_plural = "Teams" + db_table = "teams" + ordering = ("-created_at",) + + +class WorkspaceTheme(BaseModel): + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="themes") + name = models.CharField(max_length=300) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes") + colors = models.JSONField(default=dict) + + def __str__(self): + return str(self.name) + str(self.actor.email) + + class Meta: + unique_together = ["workspace", "name", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["workspace", "name"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_theme_unique_workspace_name_when_deleted_at_null", + ) + ] + verbose_name = "Workspace Theme" + verbose_name_plural = "Workspace Themes" + db_table = "workspace_themes" + ordering = ("-created_at",) + + +class WorkspaceUserProperties(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_user_properties", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workspace_user_properties", + ) + 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) + rich_filters = models.JSONField(default=dict) + + class Meta: + unique_together = ["workspace", "user", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["workspace", "user"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_user_properties_unique_workspace_user_when_deleted_at_null", + ) + ] + verbose_name = "Workspace User Property" + verbose_name_plural = "Workspace User Property" + db_table = "workspace_user_properties" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.name} {self.user.email}" + + +class WorkspaceUserLink(WorkspaceBaseModel): + title = models.CharField(max_length=255, null=True, blank=True) + url = models.TextField() + metadata = models.JSONField(default=dict) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="owner_workspace_user_link", + ) + + class Meta: + verbose_name = "Workspace User Link" + verbose_name_plural = "Workspace User Links" + db_table = "workspace_user_links" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.id} {self.url}" + + +class WorkspaceHomePreference(BaseModel): + """Preference for the home page of a workspace for a user""" + + class HomeWidgetKeys(models.TextChoices): + QUICK_LINKS = "quick_links", "Quick Links" + RECENTS = "recents", "Recents" + MY_STICKIES = "my_stickies", "My Stickies" + NEW_AT_PLANE = "new_at_plane", "New at Plane" + QUICK_TUTORIAL = "quick_tutorial", "Quick Tutorial" + + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_user_home_preferences", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workspace_user_home_preferences", + ) + key = models.CharField(max_length=255) + is_enabled = models.BooleanField(default=True) + config = models.JSONField(default=dict) + sort_order = models.FloatField(default=65535) + + class Meta: + unique_together = ["workspace", "user", "key", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["workspace", "user", "key"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null", + ) + ] + verbose_name = "Workspace Home Preference" + verbose_name_plural = "Workspace Home Preferences" + db_table = "workspace_home_preferences" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.name} {self.user.email} {self.key}" + + +class WorkspaceUserPreference(BaseModel): + """Preference for the workspace for a user""" + + class UserPreferenceKeys(models.TextChoices): + VIEWS = "views", "Views" + ACTIVE_CYCLES = "active_cycles", "Active Cycles" + ANALYTICS = "analytics", "Analytics" + DRAFTS = "drafts", "Drafts" + YOUR_WORK = "your_work", "Your Work" + ARCHIVES = "archives", "Archives" + + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_user_preferences", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workspace_user_preferences", + ) + key = models.CharField(max_length=255) + is_pinned = models.BooleanField(default=False) + sort_order = models.FloatField(default=65535) + + class Meta: + unique_together = ["workspace", "user", "key", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["workspace", "user", "key"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null", + ) + ] + verbose_name = "Workspace User Preference" + verbose_name_plural = "Workspace User Preferences" + db_table = "workspace_user_preferences" + ordering = ("-created_at",) diff --git a/apps/api/plane/license/__init__.py b/apps/api/plane/license/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/license/api/__init__.py b/apps/api/plane/license/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/license/api/permissions/__init__.py b/apps/api/plane/license/api/permissions/__init__.py new file mode 100644 index 00000000..d5bedc4c --- /dev/null +++ b/apps/api/plane/license/api/permissions/__init__.py @@ -0,0 +1 @@ +from .instance import InstanceAdminPermission diff --git a/apps/api/plane/license/api/permissions/instance.py b/apps/api/plane/license/api/permissions/instance.py new file mode 100644 index 00000000..a430b688 --- /dev/null +++ b/apps/api/plane/license/api/permissions/instance.py @@ -0,0 +1,14 @@ +# Third party imports +from rest_framework.permissions import BasePermission + +# Module imports +from plane.license.models import Instance, InstanceAdmin + + +class InstanceAdminPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + instance = Instance.objects.first() + return InstanceAdmin.objects.filter(role__gte=15, instance=instance, user=request.user).exists() diff --git a/apps/api/plane/license/api/serializers/__init__.py b/apps/api/plane/license/api/serializers/__init__.py new file mode 100644 index 00000000..6e0a5941 --- /dev/null +++ b/apps/api/plane/license/api/serializers/__init__.py @@ -0,0 +1,5 @@ +from .instance import InstanceSerializer + +from .configuration import InstanceConfigurationSerializer +from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer +from .workspace import WorkspaceSerializer diff --git a/apps/api/plane/license/api/serializers/admin.py b/apps/api/plane/license/api/serializers/admin.py new file mode 100644 index 00000000..4df6901c --- /dev/null +++ b/apps/api/plane/license/api/serializers/admin.py @@ -0,0 +1,38 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import User +from plane.app.serializers import UserAdminLiteSerializer +from plane.license.models import InstanceAdmin + + +class InstanceAdminMeSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "avatar", + "avatar_url", + "cover_image", + "date_joined", + "display_name", + "email", + "first_name", + "last_name", + "is_active", + "is_bot", + "is_email_verified", + "user_timezone", + "username", + "is_password_autoset", + "is_email_verified", + ] + read_only_fields = fields + + +class InstanceAdminSerializer(BaseSerializer): + user_detail = UserAdminLiteSerializer(source="user", read_only=True) + + class Meta: + model = InstanceAdmin + fields = "__all__" + read_only_fields = ["id", "instance", "user"] diff --git a/apps/api/plane/license/api/serializers/base.py b/apps/api/plane/license/api/serializers/base.py new file mode 100644 index 00000000..0c6bba46 --- /dev/null +++ b/apps/api/plane/license/api/serializers/base.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + + +class BaseSerializer(serializers.ModelSerializer): + id = serializers.PrimaryKeyRelatedField(read_only=True) diff --git a/apps/api/plane/license/api/serializers/configuration.py b/apps/api/plane/license/api/serializers/configuration.py new file mode 100644 index 00000000..1766f211 --- /dev/null +++ b/apps/api/plane/license/api/serializers/configuration.py @@ -0,0 +1,17 @@ +from .base import BaseSerializer +from plane.license.models import InstanceConfiguration +from plane.license.utils.encryption import decrypt_data + + +class InstanceConfigurationSerializer(BaseSerializer): + class Meta: + model = InstanceConfiguration + fields = "__all__" + + def to_representation(self, instance): + data = super().to_representation(instance) + # Decrypt secrets value + if instance.is_encrypted and instance.value is not None: + data["value"] = decrypt_data(instance.value) + + return data diff --git a/apps/api/plane/license/api/serializers/instance.py b/apps/api/plane/license/api/serializers/instance.py new file mode 100644 index 00000000..c75c62e5 --- /dev/null +++ b/apps/api/plane/license/api/serializers/instance.py @@ -0,0 +1,13 @@ +# Module imports +from plane.license.models import Instance +from plane.app.serializers import BaseSerializer +from plane.app.serializers import UserAdminLiteSerializer + + +class InstanceSerializer(BaseSerializer): + primary_owner_details = UserAdminLiteSerializer(source="primary_owner", read_only=True) + + class Meta: + model = Instance + fields = "__all__" + read_only_fields = ["id", "email", "last_checked_at", "is_setup_done"] diff --git a/apps/api/plane/license/api/serializers/user.py b/apps/api/plane/license/api/serializers/user.py new file mode 100644 index 00000000..c53b4a48 --- /dev/null +++ b/apps/api/plane/license/api/serializers/user.py @@ -0,0 +1,8 @@ +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/apps/api/plane/license/api/serializers/workspace.py b/apps/api/plane/license/api/serializers/workspace.py new file mode 100644 index 00000000..75dd938e --- /dev/null +++ b/apps/api/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/apps/api/plane/license/api/views/__init__.py b/apps/api/plane/license/api/views/__init__.py new file mode 100644 index 00000000..7f30d53f --- /dev/null +++ b/apps/api/plane/license/api/views/__init__.py @@ -0,0 +1,24 @@ +from .instance import InstanceEndpoint, SignUpScreenVisitedEndpoint + + +from .configuration import ( + EmailCredentialCheckEndpoint, + InstanceConfigurationEndpoint, + DisableEmailFeatureEndpoint, +) + + +from .admin import ( + InstanceAdminEndpoint, + InstanceAdminSignInEndpoint, + InstanceAdminSignUpEndpoint, + InstanceAdminUserMeEndpoint, + InstanceAdminSignOutEndpoint, + InstanceAdminUserSessionEndpoint, +) + + +from .workspace import ( + InstanceWorkSpaceAvailabilityCheckEndpoint, + InstanceWorkSpaceEndpoint, +) diff --git a/apps/api/plane/license/api/views/admin.py b/apps/api/plane/license/api/views/admin.py new file mode 100644 index 00000000..72c97611 --- /dev/null +++ b/apps/api/plane/license/api/views/admin.py @@ -0,0 +1,391 @@ +# Python imports +from urllib.parse import urlencode, urljoin +import uuid +from zxcvbn import zxcvbn + +# Django imports +from django.http import HttpResponseRedirect +from django.views import View +from django.core.validators import validate_email +from django.core.exceptions import ValidationError +from django.utils import timezone +from django.contrib.auth.hashers import make_password +from django.contrib.auth import logout + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny + +# Module imports +from .base import BaseAPIView +from plane.license.api.permissions import InstanceAdminPermission +from plane.license.api.serializers import ( + InstanceAdminMeSerializer, + InstanceAdminSerializer, +) +from plane.license.models import Instance, InstanceAdmin +from plane.db.models import User, Profile +from plane.utils.cache import cache_response, invalidate_cache +from plane.authentication.utils.login import user_login +from plane.authentication.utils.host import base_host, user_ip +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +from plane.utils.ip_address import get_client_ip +from plane.utils.path_validator import get_safe_redirect_url + + +class InstanceAdminEndpoint(BaseAPIView): + permission_classes = [InstanceAdminPermission] + + @invalidate_cache(path="/api/instances/", user=False) + # Create an instance admin + def post(self, request): + email = request.data.get("email", False) + role = request.data.get("role", 20) + + if not email: + return Response({"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST) + + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not registered yet"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Fetch the user + user = User.objects.get(email=email) + + instance_admin = InstanceAdmin.objects.create(instance=instance, user=user, role=role) + serializer = InstanceAdminSerializer(instance_admin) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @cache_response(60 * 60 * 2, user=False) + def get(self, request): + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not registered yet"}, + status=status.HTTP_403_FORBIDDEN, + ) + instance_admins = InstanceAdmin.objects.filter(instance=instance) + serializer = InstanceAdminSerializer(instance_admins, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @invalidate_cache(path="/api/instances/", user=False) + def delete(self, request, pk): + instance = Instance.objects.first() + InstanceAdmin.objects.filter(instance=instance, pk=pk).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class InstanceAdminSignUpEndpoint(View): + permission_classes = [AllowAny] + + @invalidate_cache(path="/api/instances/", user=False) + def post(self, request): + # Check instance first + instance = Instance.objects.first() + if instance is None: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # check if the instance has already an admin registered + if InstanceAdmin.objects.first(): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_ALREADY_EXIST"], + error_message="ADMIN_ALREADY_EXIST", + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Get the email and password from all the user + email = request.POST.get("email", False) + password = request.POST.get("password", False) + first_name = request.POST.get("first_name", False) + last_name = request.POST.get("last_name", "") + company_name = request.POST.get("company_name", "") + is_telemetry_enabled = request.POST.get("is_telemetry_enabled", True) + + # return error if the email and password is not present + if not email or not password or not first_name: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME"], + error_message="REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME", + payload={ + "email": email, + "first_name": first_name, + "last_name": last_name, + "company_name": company_name, + "is_telemetry_enabled": is_telemetry_enabled, + }, + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Validate the email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"], + error_message="INVALID_ADMIN_EMAIL", + payload={ + "email": email, + "first_name": first_name, + "last_name": last_name, + "company_name": company_name, + "is_telemetry_enabled": is_telemetry_enabled, + }, + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Check if already a user exists or not + # Existing user + if User.objects.filter(email=email).exists(): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_ALREADY_EXIST"], + error_message="ADMIN_USER_ALREADY_EXIST", + payload={ + "email": email, + "first_name": first_name, + "last_name": last_name, + "company_name": company_name, + "is_telemetry_enabled": is_telemetry_enabled, + }, + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + else: + results = zxcvbn(password) + if results["score"] < 3: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_PASSWORD"], + error_message="INVALID_ADMIN_PASSWORD", + payload={ + "email": email, + "first_name": first_name, + "last_name": last_name, + "company_name": company_name, + "is_telemetry_enabled": is_telemetry_enabled, + }, + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + user = User.objects.create( + first_name=first_name, + last_name=last_name, + email=email, + username=uuid.uuid4().hex, + password=make_password(password), + is_password_autoset=False, + ) + _ = Profile.objects.create(user=user, company_name=company_name) + # settings last active for the user + user.is_active = True + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = get_client_ip(request=request) + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + # Register the user as an instance admin + _ = InstanceAdmin.objects.create(user=user, instance=instance) + # Make the setup flag True + instance.is_setup_done = True + instance.instance_name = company_name + instance.is_telemetry_enabled = is_telemetry_enabled + instance.save() + + # get tokens for user + user_login(request=request, user=user, is_admin=True) + url = urljoin(base_host(request=request, is_admin=True), "general") + return HttpResponseRedirect(url) + + +class InstanceAdminSignInEndpoint(View): + permission_classes = [AllowAny] + + @invalidate_cache(path="/api/instances/", user=False) + def post(self, request): + # Check instance first + instance = Instance.objects.first() + if instance is None: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Get email and password + email = request.POST.get("email", False) + password = request.POST.get("password", False) + + # 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_message="REQUIRED_ADMIN_EMAIL_PASSWORD", + payload={"email": email}, + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Validate the email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"], + error_message="INVALID_ADMIN_EMAIL", + payload={"email": email}, + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Fetch the user + user = User.objects.filter(email=email).first() + + # Error out if the user is not present + if not user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"], + error_message="ADMIN_USER_DOES_NOT_EXIST", + payload={"email": email}, + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # is_active + if not user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"], + error_message="ADMIN_USER_DEACTIVATED", + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Check password of the user + if not user.check_password(password): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_AUTHENTICATION_FAILED"], + error_message="ADMIN_AUTHENTICATION_FAILED", + payload={"email": email}, + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # 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_message="ADMIN_AUTHENTICATION_FAILED", + payload={"email": email}, + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + # settings last active for the user + user.is_active = True + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = get_client_ip(request=request) + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + # get tokens for user + user_login(request=request, user=user, is_admin=True) + url = urljoin(base_host(request=request, is_admin=True), "general") + return HttpResponseRedirect(url) + + +class InstanceAdminUserMeEndpoint(BaseAPIView): + permission_classes = [InstanceAdminPermission] + + def get(self, request): + serializer = InstanceAdminMeSerializer(request.user) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class InstanceAdminUserSessionEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def get(self, request): + if request.user.is_authenticated and InstanceAdmin.objects.filter(user=request.user).exists(): + serializer = InstanceAdminMeSerializer(request.user) + data = {"is_authenticated": True} + data["user"] = serializer.data + return Response(data, status=status.HTTP_200_OK) + else: + return Response({"is_authenticated": False}, status=status.HTTP_200_OK) + + +class InstanceAdminSignOutEndpoint(View): + permission_classes = [InstanceAdminPermission] + + def post(self, request): + # Get user + try: + user = User.objects.get(pk=request.user.id) + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save() + # Log the user out + logout(request) + url = get_safe_redirect_url(base_url=base_host(request=request, is_admin=True), next_path="") + return HttpResponseRedirect(url) + except Exception: + url = get_safe_redirect_url(base_url=base_host(request=request, is_admin=True), next_path="") + return HttpResponseRedirect(url) diff --git a/apps/api/plane/license/api/views/base.py b/apps/api/plane/license/api/views/base.py new file mode 100644 index 00000000..d209bd6b --- /dev/null +++ b/apps/api/plane/license/api/views/base.py @@ -0,0 +1,115 @@ +# Python imports +import zoneinfo +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import IntegrityError + +# Django imports +from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend + +# Third part imports +from rest_framework import status +from rest_framework.filters import SearchFilter +from rest_framework.response import Response +from rest_framework.views import APIView + +# Module imports +from plane.license.api.permissions import InstanceAdminPermission +from plane.authentication.session import BaseSessionAuthentication +from plane.utils.exception_logger import log_exception +from plane.utils.paginator import BasePaginator + + +class TimezoneMixin: + """ + This enables timezone conversion according + to the user set timezone + """ + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if request.user.is_authenticated: + timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone)) + else: + timezone.deactivate() + + +class BaseAPIView(TimezoneMixin, APIView, BasePaginator): + permission_classes = [InstanceAdminPermission] + + filter_backends = (DjangoFilterBackend, SearchFilter) + + authentication_classes = [BaseSessionAuthentication] + + filterset_fields = [] + + search_fields = [] + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + return Response( + {"error": "The required key does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + log_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") + return response + + except Exception as exc: + response = self.handle_exception(exc) + return exc + + @property + def fields(self): + fields = [field for field in self.request.GET.get("fields", "").split(",") if field] + return fields if fields else None + + @property + def expand(self): + expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] + return expand if expand else None diff --git a/apps/api/plane/license/api/views/configuration.py b/apps/api/plane/license/api/views/configuration.py new file mode 100644 index 00000000..8bb95356 --- /dev/null +++ b/apps/api/plane/license/api/views/configuration.py @@ -0,0 +1,166 @@ +# Python imports +from smtplib import ( + SMTPAuthenticationError, + SMTPConnectError, + SMTPRecipientsRefused, + SMTPSenderRefused, + SMTPServerDisconnected, +) + +# Django imports +from django.core.mail import BadHeaderError, EmailMultiAlternatives, get_connection +from django.db.models import Q, Case, When, Value + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.license.api.permissions import InstanceAdminPermission +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 + + +class InstanceConfigurationEndpoint(BaseAPIView): + 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) + return Response(serializer.data, status=status.HTTP_200_OK) + + @invalidate_cache(path="/api/instances/configurations/", user=False) + @invalidate_cache(path="/api/instances/", user=False) + def patch(self, request): + configurations = InstanceConfiguration.objects.filter(key__in=request.data.keys()) + + bulk_configurations = [] + for configuration in configurations: + value = request.data.get(configuration.key, configuration.value) + if configuration.is_encrypted: + configuration.value = encrypt_data(value) + else: + configuration.value = value + bulk_configurations.append(configuration) + + InstanceConfiguration.objects.bulk_update(bulk_configurations, ["value"], batch_size=100) + + serializer = InstanceConfigurationSerializer(configurations, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class DisableEmailFeatureEndpoint(BaseAPIView): + permission_classes = [InstanceAdminPermission] + + @invalidate_cache(path="/api/instances/", user=False) + def delete(self, request): + try: + InstanceConfiguration.objects.filter( + Q( + key__in=[ + "EMAIL_HOST", + "EMAIL_HOST_USER", + "EMAIL_HOST_PASSWORD", + "ENABLE_SMTP", + "EMAIL_PORT", + "EMAIL_FROM", + ] + ) + ).update(value=Case(When(key="ENABLE_SMTP", then=Value("0")), default=Value(""))) + return Response(status=status.HTTP_200_OK) + except Exception: + return Response( + {"error": "Failed to disable email configuration"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class EmailCredentialCheckEndpoint(BaseAPIView): + def post(self, request): + receiver_email = request.data.get("receiver_email", False) + if not receiver_email: + return Response( + {"error": "Receiver email is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + # Configure all the connections + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + # Prepare email details + subject = "Email Notification from Plane" + message = "This is a sample email notification sent from Plane application." + # Send the email + try: + msg = EmailMultiAlternatives( + subject=subject, + body=message, + from_email=EMAIL_FROM, + to=[receiver_email], + connection=connection, + ) + msg.send(fail_silently=False) + return Response({"message": "Email successfully sent."}, status=status.HTTP_200_OK) + except BadHeaderError: + return Response({"error": "Invalid email header."}, status=status.HTTP_400_BAD_REQUEST) + except SMTPAuthenticationError: + return Response( + {"error": "Invalid credentials provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except SMTPConnectError: + return Response( + {"error": "Could not connect with the SMTP server."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except SMTPSenderRefused: + return Response( + {"error": "From address is invalid."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except SMTPServerDisconnected: + return Response( + {"error": "SMTP server disconnected unexpectedly."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except SMTPRecipientsRefused: + return Response( + {"error": "All recipient addresses were refused."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except TimeoutError: + return Response( + {"error": "Timeout error while trying to connect to the SMTP server."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except ConnectionError: + return Response( + {"error": "Network connection error. Please check your internet connection."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception: + return Response( + {"error": "Could not send email. Please check your configuration"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apps/api/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py new file mode 100644 index 00000000..c598acfe --- /dev/null +++ b/apps/api/plane/license/api/views/instance.py @@ -0,0 +1,203 @@ +# Python imports +import os + +# Django imports +from django.conf import settings + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +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.models import Instance +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 + + +class InstanceEndpoint(BaseAPIView): + def get_permissions(self): + if self.request.method == "PATCH": + return [InstanceAdminPermission()] + return [AllowAny()] + + @cache_response(60 * 60 * 2, user=False) + @method_decorator(cache_control(private=True, max_age=12)) + def get(self, request): + instance = Instance.objects.first() + + # get the instance + if instance is None: + return Response( + {"is_activated": False, "is_setup_done": False}, + status=status.HTTP_200_OK, + ) + # Return instance + serializer = InstanceSerializer(instance) + data = serializer.data + data["is_activated"] = True + # Get all the configuration + ( + ENABLE_SIGNUP, + DISABLE_WORKSPACE_CREATION, + IS_GOOGLE_ENABLED, + IS_GITHUB_ENABLED, + GITHUB_APP_NAME, + IS_GITLAB_ENABLED, + EMAIL_HOST, + ENABLE_MAGIC_LINK_LOGIN, + ENABLE_EMAIL_PASSWORD, + SLACK_CLIENT_ID, + POSTHOG_API_KEY, + POSTHOG_HOST, + UNSPLASH_ACCESS_KEY, + LLM_API_KEY, + IS_INTERCOM_ENABLED, + INTERCOM_APP_ID, + ) = get_configuration_value( + [ + { + "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"), + }, + { + "key": "IS_GITHUB_ENABLED", + "default": os.environ.get("IS_GITHUB_ENABLED", "0"), + }, + { + "key": "GITHUB_APP_NAME", + "default": os.environ.get("GITHUB_APP_NAME", ""), + }, + { + "key": "IS_GITLAB_ENABLED", + "default": os.environ.get("IS_GITLAB_ENABLED", "0"), + }, + {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")}, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), + }, + { + "key": "ENABLE_EMAIL_PASSWORD", + "default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), + }, + { + "key": "SLACK_CLIENT_ID", + "default": os.environ.get("SLACK_CLIENT_ID", None), + }, + { + "key": "POSTHOG_API_KEY", + "default": os.environ.get("POSTHOG_API_KEY", None), + }, + { + "key": "POSTHOG_HOST", + "default": os.environ.get("POSTHOG_HOST", None), + }, + { + "key": "UNSPLASH_ACCESS_KEY", + "default": os.environ.get("UNSPLASH_ACCESS_KEY", ""), + }, + { + "key": "LLM_API_KEY", + "default": os.environ.get("LLM_API_KEY", ""), + }, + # Intercom settings + { + "key": "IS_INTERCOM_ENABLED", + "default": os.environ.get("IS_INTERCOM_ENABLED", "1"), + }, + { + "key": "INTERCOM_APP_ID", + "default": os.environ.get("INTERCOM_APP_ID", ""), + }, + ] + ) + + 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" + data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1" + data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1" + + # Github app name + data["github_app_name"] = str(GITHUB_APP_NAME) + + # Slack client + data["slack_client_id"] = SLACK_CLIENT_ID + + # Posthog + data["posthog_api_key"] = POSTHOG_API_KEY + data["posthog_host"] = POSTHOG_HOST + + # Unsplash + data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY) + + # Open AI settings + data["has_llm_configured"] = bool(LLM_API_KEY) + + # File size settings + data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + + # is smtp configured + data["is_smtp_configured"] = bool(EMAIL_HOST) + + # Intercom settings + data["is_intercom_enabled"] = IS_INTERCOM_ENABLED == "1" + data["intercom_app_id"] = INTERCOM_APP_ID + + # Base URL + data["admin_base_url"] = settings.ADMIN_BASE_URL + 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 + + response_data = {"config": data, "instance": instance_data} + return Response(response_data, status=status.HTTP_200_OK) + + @invalidate_cache(path="/api/instances/", user=False) + def patch(self, request): + # Get the instance + instance = Instance.objects.first() + serializer = InstanceSerializer(instance, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class SignUpScreenVisitedEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + @invalidate_cache(path="/api/instances/", user=False) + def post(self, request): + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + instance.is_signup_screen_visited = True + instance.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/license/api/views/workspace.py b/apps/api/plane/license/api/views/workspace.py new file mode 100644 index 00000000..5d1a2f24 --- /dev/null +++ b/apps/api/plane/license/api/views/workspace.py @@ -0,0 +1,106 @@ +# 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_409_CONFLICT, + ) diff --git a/apps/api/plane/license/apps.py b/apps/api/plane/license/apps.py new file mode 100644 index 00000000..400e9815 --- /dev/null +++ b/apps/api/plane/license/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class LicenseConfig(AppConfig): + name = "plane.license" diff --git a/apps/api/plane/license/bgtasks/__init__.py b/apps/api/plane/license/bgtasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/license/bgtasks/tracer.py b/apps/api/plane/license/bgtasks/tracer.py new file mode 100644 index 00000000..055c45d6 --- /dev/null +++ b/apps/api/plane/license/bgtasks/tracer.py @@ -0,0 +1,101 @@ +# Third party imports +from celery import shared_task +from opentelemetry import trace + +# Module imports +from plane.license.models import Instance +from plane.db.models import ( + User, + Workspace, + Project, + Issue, + Module, + Cycle, + CycleIssue, + ModuleIssue, + Page, + WorkspaceMember, +) +from plane.utils.telemetry import init_tracer, shutdown_tracer + + +@shared_task +def instance_traces(): + try: + init_tracer() + # Check if the instance is registered + instance = Instance.objects.first() + + # 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() + + # 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("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) + 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: + 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/apps/api/plane/license/management/__init__.py b/apps/api/plane/license/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/license/management/commands/__init__.py b/apps/api/plane/license/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/license/management/commands/configure_instance.py b/apps/api/plane/license/management/commands/configure_instance.py new file mode 100644 index 00000000..5611eec5 --- /dev/null +++ b/apps/api/plane/license/management/commands/configure_instance.py @@ -0,0 +1,287 @@ +# Python imports +import os + +# Django imports +from django.core.management.base import BaseCommand, CommandError + +# Module imports +from plane.license.models import InstanceConfiguration + + +class Command(BaseCommand): + help = "Configure instance variables" + + def handle(self, *args, **options): + from plane.license.utils.encryption import encrypt_data + from plane.license.utils.instance_value import get_configuration_value + + mandatory_keys = ["SECRET_KEY"] + + for item in mandatory_keys: + if not os.environ.get(item): + raise CommandError(f"{item} env variable is required.") + + config_keys = [ + # Authentication Settings + { + "key": "ENABLE_SIGNUP", + "value": os.environ.get("ENABLE_SIGNUP", "1"), + "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"), + "category": "AUTHENTICATION", + "is_encrypted": False, + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"), + "category": "AUTHENTICATION", + "is_encrypted": False, + }, + { + "key": "GOOGLE_CLIENT_ID", + "value": os.environ.get("GOOGLE_CLIENT_ID"), + "category": "GOOGLE", + "is_encrypted": False, + }, + { + "key": "GOOGLE_CLIENT_SECRET", + "value": os.environ.get("GOOGLE_CLIENT_SECRET"), + "category": "GOOGLE", + "is_encrypted": True, + }, + { + "key": "GITHUB_CLIENT_ID", + "value": os.environ.get("GITHUB_CLIENT_ID"), + "category": "GITHUB", + "is_encrypted": False, + }, + { + "key": "GITHUB_CLIENT_SECRET", + "value": os.environ.get("GITHUB_CLIENT_SECRET"), + "category": "GITHUB", + "is_encrypted": True, + }, + { + "key": "GITHUB_ORGANIZATION_ID", + "value": os.environ.get("GITHUB_ORGANIZATION_ID"), + "category": "GITHUB", + "is_encrypted": False, + }, + { + "key": "GITLAB_HOST", + "value": os.environ.get("GITLAB_HOST"), + "category": "GITLAB", + "is_encrypted": False, + }, + { + "key": "GITLAB_CLIENT_ID", + "value": os.environ.get("GITLAB_CLIENT_ID"), + "category": "GITLAB", + "is_encrypted": False, + }, + { + "key": "ENABLE_SMTP", + "value": os.environ.get("ENABLE_SMTP", "0"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "GITLAB_CLIENT_SECRET", + "value": os.environ.get("GITLAB_CLIENT_SECRET"), + "category": "GITLAB", + "is_encrypted": True, + }, + { + "key": "EMAIL_HOST", + "value": os.environ.get("EMAIL_HOST", ""), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_HOST_USER", + "value": os.environ.get("EMAIL_HOST_USER", ""), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_HOST_PASSWORD", + "value": os.environ.get("EMAIL_HOST_PASSWORD", ""), + "category": "SMTP", + "is_encrypted": True, + }, + { + "key": "EMAIL_PORT", + "value": os.environ.get("EMAIL_PORT", "587"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_FROM", + "value": os.environ.get("EMAIL_FROM", ""), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_USE_TLS", + "value": os.environ.get("EMAIL_USE_TLS", "1"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_USE_SSL", + "value": os.environ.get("EMAIL_USE_SSL", "0"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "LLM_API_KEY", + "value": os.environ.get("LLM_API_KEY"), + "category": "AI", + "is_encrypted": True, + }, + { + "key": "LLM_PROVIDER", + "value": os.environ.get("LLM_PROVIDER", "openai"), + "category": "AI", + "is_encrypted": False, + }, + { + "key": "LLM_MODEL", + "value": os.environ.get("LLM_MODEL", "gpt-4o-mini"), + "category": "AI", + "is_encrypted": False, + }, + # Deprecated, use LLM_MODEL + { + "key": "GPT_ENGINE", + "value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "UNSPLASH_ACCESS_KEY", + "value": os.environ.get("UNSPLASH_ACCESS_KEY", ""), + "category": "UNSPLASH", + "is_encrypted": True, + }, + # intercom settings + { + "key": "IS_INTERCOM_ENABLED", + "value": os.environ.get("IS_INTERCOM_ENABLED", "1"), + "category": "INTERCOM", + "is_encrypted": False, + }, + { + "key": "INTERCOM_APP_ID", + "value": os.environ.get("INTERCOM_APP_ID", ""), + "category": "INTERCOM", + "is_encrypted": False, + }, + ] + + for item in config_keys: + obj, created = InstanceConfiguration.objects.get_or_create(key=item.get("key")) + if created: + obj.category = item.get("category") + obj.is_encrypted = item.get("is_encrypted", False) + if item.get("is_encrypted", False): + obj.value = encrypt_data(item.get("value")) + else: + obj.value = item.get("value") + obj.save() + self.stdout.write(self.style.SUCCESS(f"{obj.key} loaded with value from environment variable.")) + else: + self.stdout.write(self.style.WARNING(f"{obj.key} configuration already exists")) + + 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"), + }, + ] + ) + if bool(GOOGLE_CLIENT_ID) and bool(GOOGLE_CLIENT_SECRET): + value = "1" + else: + value = "0" + InstanceConfiguration.objects.create( + key=key, + value=value, + category="AUTHENTICATION", + is_encrypted=False, + ) + self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) + 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"), + }, + ] + ) + if bool(GITHUB_CLIENT_ID) and bool(GITHUB_CLIENT_SECRET): + value = "1" + else: + value = "0" + InstanceConfiguration.objects.create( + key="IS_GITHUB_ENABLED", + value=value, + category="AUTHENTICATION", + is_encrypted=False, + ) + self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) + if key == "IS_GITLAB_ENABLED": + GITLAB_HOST, GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = get_configuration_value( + [ + { + "key": "GITLAB_HOST", + "default": os.environ.get("GITLAB_HOST", "https://gitlab.com"), + }, + { + "key": "GITLAB_CLIENT_ID", + "default": os.environ.get("GITLAB_CLIENT_ID", ""), + }, + { + "key": "GITLAB_CLIENT_SECRET", + "default": os.environ.get("GITLAB_CLIENT_SECRET", ""), + }, + ] + ) + if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET): + value = "1" + else: + value = "0" + InstanceConfiguration.objects.create( + key="IS_GITLAB_ENABLED", + value=value, + category="AUTHENTICATION", + is_encrypted=False, + ) + self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) + else: + for key in keys: + self.stdout.write(self.style.WARNING(f"{key} configuration already exists")) diff --git a/apps/api/plane/license/management/commands/register_instance.py b/apps/api/plane/license/management/commands/register_instance.py new file mode 100644 index 00000000..6717cafd --- /dev/null +++ b/apps/api/plane/license/management/commands/register_instance.py @@ -0,0 +1,88 @@ +# Python imports +import json +import secrets +import os +import requests + +# Django imports +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + + +# Module imports +from plane.license.models import Instance, InstanceEdition +from plane.license.bgtasks.tracer import instance_traces + + +class Command(BaseCommand): + help = "Check if instance in registered else register" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("machine_signature", type=str, help="Machine signature") + + def check_for_current_version(self): + if os.environ.get("APP_VERSION", False): + return os.environ.get("APP_VERSION") + + try: + with open("package.json", "r") as file: + data = json.load(file) + return data.get("version", "v0.1.0") + except Exception: + self.stdout.write("Error checking for current version") + return "v0.1.0" + + def check_for_latest_version(self, fallback_version): + try: + response = requests.get( + "https://api.github.com/repos/makeplane/plane/releases/latest", + timeout=10, + ) + response.raise_for_status() + data = response.json() + return data.get("tag_name", fallback_version) + except Exception: + self.stdout.write("Error checking for latest version") + return fallback_version + + def handle(self, *args, **options): + # Check if the instance is registered + instance = Instance.objects.first() + + current_version = self.check_for_current_version() + latest_version = self.check_for_latest_version(current_version) + + # If instance is None then register this instance + if instance is None: + machine_signature = options.get("machine_signature", "machine-signature") + + if not machine_signature: + raise CommandError("Machine signature is required") + + instance = Instance.objects.create( + instance_name="Plane Community Edition", + instance_id=secrets.token_hex(12), + current_version=current_version, + latest_version=latest_version, + last_checked_at=timezone.now(), + 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")) + + # Update the instance details + instance.last_checked_at = timezone.now() + instance.current_version = current_version + instance.latest_version = latest_version + instance.is_test = os.environ.get("IS_TEST", "0") == "1" + instance.edition = InstanceEdition.PLANE_COMMUNITY.value + instance.save() + + # Call the instance traces task + instance_traces.delay() + + return diff --git a/apps/api/plane/license/migrations/0001_initial.py b/apps/api/plane/license/migrations/0001_initial.py new file mode 100644 index 00000000..4eed3adf --- /dev/null +++ b/apps/api/plane/license/migrations/0001_initial.py @@ -0,0 +1,234 @@ +# Generated by Django 4.2.7 on 2023-12-06 06:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Instance", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("instance_name", models.CharField(max_length=255)), + ("whitelist_emails", models.TextField(blank=True, null=True)), + ("instance_id", models.CharField(max_length=25, unique=True)), + ( + "license_key", + models.CharField(blank=True, max_length=256, null=True), + ), + ("api_key", models.CharField(max_length=16)), + ("version", models.CharField(max_length=10)), + ("last_checked_at", models.DateTimeField()), + ( + "namespace", + models.CharField(blank=True, max_length=50, null=True), + ), + ("is_telemetry_enabled", models.BooleanField(default=True)), + ("is_support_required", models.BooleanField(default=True)), + ("is_setup_done", models.BooleanField(default=False)), + ( + "is_signup_screen_visited", + models.BooleanField(default=False), + ), + ("user_count", models.PositiveBigIntegerField(default=0)), + ("is_verified", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Instance", + "verbose_name_plural": "Instances", + "db_table": "instances", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="InstanceConfiguration", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("key", models.CharField(max_length=100, unique=True)), + ( + "value", + models.TextField(blank=True, default=None, null=True), + ), + ("category", models.TextField()), + ("is_encrypted", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Instance Configuration", + "verbose_name_plural": "Instance Configurations", + "db_table": "instance_configurations", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="InstanceAdmin", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "role", + models.PositiveIntegerField( + choices=[(20, "Admin")], default=20 + ), + ), + ("is_verified", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="admins", + to="license.instance", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="instance_owner", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Instance Admin", + "verbose_name_plural": "Instance Admins", + "db_table": "instance_admins", + "ordering": ("-created_at",), + "unique_together": {("instance", "user")}, + }, + ), + ] diff --git a/apps/api/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py b/apps/api/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py new file mode 100644 index 00000000..3cdea790 --- /dev/null +++ b/apps/api/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py @@ -0,0 +1,106 @@ +# Generated by Django 4.2.11 on 2024-05-31 10:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("license", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="instance", + name="instance_id", + field=models.CharField(max_length=255, unique=True), + ), + migrations.RenameField( + model_name="instance", + old_name="version", + new_name="current_version", + ), + migrations.RemoveField( + model_name="instance", + name="api_key", + ), + migrations.AddField( + model_name="instance", + name="domain", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="instance", + name="latest_version", + field=models.CharField(blank=True, max_length=10, null=True), + ), + migrations.AddField( + model_name="instance", + name="product", + field=models.CharField(default="plane-ce", max_length=50), + ), + migrations.CreateModel( + name="ChangeLog", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(max_length=100)), + ("description", models.TextField(blank=True)), + ("version", models.CharField(max_length=100)), + ("tags", models.JSONField(default=list)), + ("release_date", models.DateTimeField(null=True)), + ("is_release_candidate", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Change Log", + "verbose_name_plural": "Change Logs", + "db_table": "changelogs", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apps/api/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py b/apps/api/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py new file mode 100644 index 00000000..8d7b9a40 --- /dev/null +++ b/apps/api/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.11 on 2024-06-05 13:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("license", "0002_rename_version_instance_current_version_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="changelog", + name="title", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="changelog", + name="version", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="instance", + name="current_version", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="instance", + name="latest_version", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="instance", + name="namespace", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="instance", + name="product", + field=models.CharField(default="plane-ce", max_length=255), + ), + ] diff --git a/apps/api/plane/license/migrations/0004_changelog_deleted_at_instance_deleted_at_and_more.py b/apps/api/plane/license/migrations/0004_changelog_deleted_at_instance_deleted_at_and_more.py new file mode 100644 index 00000000..4e238877 --- /dev/null +++ b/apps/api/plane/license/migrations/0004_changelog_deleted_at_instance_deleted_at_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.11 on 2024-07-26 11:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('license', '0003_alter_changelog_title_alter_changelog_version_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='changelog', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'), + ), + migrations.AddField( + model_name='instance', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'), + ), + migrations.AddField( + model_name='instanceadmin', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'), + ), + migrations.AddField( + model_name='instanceconfiguration', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'), + ), + ] diff --git a/apps/api/plane/license/migrations/0005_rename_product_instance_edition_and_more.py b/apps/api/plane/license/migrations/0005_rename_product_instance_edition_and_more.py new file mode 100644 index 00000000..6746d4e6 --- /dev/null +++ b/apps/api/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/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py b/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py new file mode 100644 index 00000000..f8c2c30b --- /dev/null +++ b/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.22 on 2025-09-11 08:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("license", "0005_rename_product_instance_edition_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="instance", + name="is_current_version_deprecated", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/api/plane/license/migrations/__init__.py b/apps/api/plane/license/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/license/models/__init__.py b/apps/api/plane/license/models/__init__.py new file mode 100644 index 00000000..d4952402 --- /dev/null +++ b/apps/api/plane/license/models/__init__.py @@ -0,0 +1 @@ +from .instance import Instance, InstanceAdmin, InstanceConfiguration, InstanceEdition diff --git a/apps/api/plane/license/models/instance.py b/apps/api/plane/license/models/instance.py new file mode 100644 index 00000000..1767d8c2 --- /dev/null +++ b/apps/api/plane/license/models/instance.py @@ -0,0 +1,96 @@ +# Python imports +from enum import Enum + +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from plane.db.models import BaseModel + +ROLE_CHOICES = ((20, "Admin"),) + + +class InstanceEdition(Enum): + PLANE_COMMUNITY = "PLANE_COMMUNITY" + + +class Instance(BaseModel): + # General information + instance_name = models.CharField(max_length=255) + whitelist_emails = models.TextField(blank=True, null=True) + instance_id = models.CharField(max_length=255, unique=True) + current_version = models.CharField(max_length=255) + latest_version = models.CharField(max_length=255, null=True, blank=True) + edition = models.CharField(max_length=255, default=InstanceEdition.PLANE_COMMUNITY.value) + domain = models.TextField(blank=True) + # Instance specifics + last_checked_at = models.DateTimeField() + namespace = models.CharField(max_length=255, blank=True, null=True) + # telemetry and support + is_telemetry_enabled = models.BooleanField(default=True) + is_support_required = models.BooleanField(default=True) + # is setup done + is_setup_done = models.BooleanField(default=False) + # signup screen + is_signup_screen_visited = models.BooleanField(default=False) + is_verified = models.BooleanField(default=False) + is_test = models.BooleanField(default=False) + # field for validating if the current version is deprecated + is_current_version_deprecated = models.BooleanField(default=False) + + class Meta: + verbose_name = "Instance" + verbose_name_plural = "Instances" + db_table = "instances" + ordering = ("-created_at",) + + +class InstanceAdmin(BaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="instance_owner", + ) + instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins") + role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=20) + is_verified = models.BooleanField(default=False) + + class Meta: + unique_together = ["instance", "user"] + verbose_name = "Instance Admin" + verbose_name_plural = "Instance Admins" + db_table = "instance_admins" + ordering = ("-created_at",) + + +class InstanceConfiguration(BaseModel): + # The instance configuration variables + key = models.CharField(max_length=100, unique=True) + value = models.TextField(null=True, blank=True, default=None) + category = models.TextField() + is_encrypted = models.BooleanField(default=False) + + class Meta: + verbose_name = "Instance Configuration" + verbose_name_plural = "Instance Configurations" + db_table = "instance_configurations" + ordering = ("-created_at",) + + +class ChangeLog(BaseModel): + """Change Log model to store the release changelogs made in the application.""" + + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + version = models.CharField(max_length=255) + tags = models.JSONField(default=list) + release_date = models.DateTimeField(null=True) + is_release_candidate = models.BooleanField(default=False) + + class Meta: + verbose_name = "Change Log" + verbose_name_plural = "Change Logs" + db_table = "changelogs" + ordering = ("-created_at",) diff --git a/apps/api/plane/license/urls.py b/apps/api/plane/license/urls.py new file mode 100644 index 00000000..4d306924 --- /dev/null +++ b/apps/api/plane/license/urls.py @@ -0,0 +1,70 @@ +from django.urls import path + +from plane.license.api.views import ( + EmailCredentialCheckEndpoint, + InstanceAdminEndpoint, + InstanceAdminSignInEndpoint, + InstanceAdminSignUpEndpoint, + InstanceConfigurationEndpoint, + DisableEmailFeatureEndpoint, + InstanceEndpoint, + SignUpScreenVisitedEndpoint, + InstanceAdminUserMeEndpoint, + InstanceAdminSignOutEndpoint, + InstanceAdminUserSessionEndpoint, + 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( + "admins/session/", + InstanceAdminUserSessionEndpoint.as_view(), + name="instance-admin-session", + ), + path( + "admins/sign-out/", + InstanceAdminSignOutEndpoint.as_view(), + name="instance-admins", + ), + path("admins//", InstanceAdminEndpoint.as_view(), name="instance-admins"), + path( + "configurations/", + InstanceConfigurationEndpoint.as_view(), + name="instance-configuration", + ), + path( + "configurations/disable-email-feature/", + DisableEmailFeatureEndpoint.as_view(), + name="disable-email-configuration", + ), + path( + "admins/sign-in/", + InstanceAdminSignInEndpoint.as_view(), + name="instance-admin-sign-in", + ), + path( + "admins/sign-up/", + InstanceAdminSignUpEndpoint.as_view(), + name="instance-admin-sign-in", + ), + path( + "admins/sign-up-screen-visited/", + SignUpScreenVisitedEndpoint.as_view(), + name="instance-sign-up", + ), + path( + "email-credentials-check/", + 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/apps/api/plane/license/utils/__init__.py b/apps/api/plane/license/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/license/utils/encryption.py b/apps/api/plane/license/utils/encryption.py new file mode 100644 index 00000000..d56766d1 --- /dev/null +++ b/apps/api/plane/license/utils/encryption.py @@ -0,0 +1,40 @@ +import base64 +import hashlib +from django.conf import settings +from cryptography.fernet import Fernet + +from plane.utils.exception_logger import log_exception + + +def derive_key(secret_key): + # Use a key derivation function to get a suitable encryption key + dk = hashlib.pbkdf2_hmac("sha256", secret_key.encode(), b"salt", 100000) + return base64.urlsafe_b64encode(dk) + + +# Encrypt data +def encrypt_data(data): + try: + if data: + cipher_suite = Fernet(derive_key(settings.SECRET_KEY)) + encrypted_data = cipher_suite.encrypt(data.encode()) + return encrypted_data.decode() # Convert bytes to string + else: + return "" + except Exception as e: + log_exception(e) + return "" + + +# Decrypt data +def decrypt_data(encrypted_data): + try: + if encrypted_data: + cipher_suite = Fernet(derive_key(settings.SECRET_KEY)) + decrypted_data = cipher_suite.decrypt(encrypted_data.encode()) # Convert string back to bytes + return decrypted_data.decode() + else: + return "" + except Exception as e: + log_exception(e) + return "" diff --git a/apps/api/plane/license/utils/instance_value.py b/apps/api/plane/license/utils/instance_value.py new file mode 100644 index 00000000..8901bc81 --- /dev/null +++ b/apps/api/plane/license/utils/instance_value.py @@ -0,0 +1,55 @@ +# Python imports +import os + +# Django imports +from django.conf import settings + +# Module imports +from plane.license.models import InstanceConfiguration +from plane.license.utils.encryption import decrypt_data + + +# Helper function to return value from the passed key +def get_configuration_value(keys): + environment_list = [] + if settings.SKIP_ENV_VAR: + # Get the configurations + instance_configuration = InstanceConfiguration.objects.values("key", "value", "is_encrypted") + + for key in keys: + for item in instance_configuration: + if key.get("key") == item.get("key"): + if item.get("is_encrypted", False): + environment_list.append(decrypt_data(item.get("value"))) + else: + environment_list.append(item.get("value")) + + break + else: + environment_list.append(key.get("default")) + else: + # Get the configuration from os + for key in keys: + environment_list.append(os.environ.get(key.get("key"), key.get("default"))) + + return tuple(environment_list) + + +def get_email_configuration(): + return get_configuration_value( + [ + {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST")}, + {"key": "EMAIL_HOST_USER", "default": os.environ.get("EMAIL_HOST_USER")}, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD"), + }, + {"key": "EMAIL_PORT", "default": os.environ.get("EMAIL_PORT", 587)}, + {"key": "EMAIL_USE_TLS", "default": os.environ.get("EMAIL_USE_TLS", "1")}, + {"key": "EMAIL_USE_SSL", "default": os.environ.get("EMAIL_USE_SSL", "0")}, + { + "key": "EMAIL_FROM", + "default": os.environ.get("EMAIL_FROM", "Team Plane "), + }, + ] + ) diff --git a/apps/api/plane/middleware/__init__.py b/apps/api/plane/middleware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/middleware/apps.py b/apps/api/plane/middleware/apps.py new file mode 100644 index 00000000..9deac809 --- /dev/null +++ b/apps/api/plane/middleware/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class Middleware(AppConfig): + name = "plane.middleware" diff --git a/apps/api/plane/middleware/db_routing.py b/apps/api/plane/middleware/db_routing.py new file mode 100644 index 00000000..68b5c449 --- /dev/null +++ b/apps/api/plane/middleware/db_routing.py @@ -0,0 +1,160 @@ +""" +Database routing middleware for read replica selection. +This middleware determines whether database queries should be routed to +read replicas or the primary database based on HTTP method and view configuration. +""" + +import logging +from typing import Callable, Optional + +from django.http import HttpRequest, HttpResponse + +from plane.utils.core import ( + set_use_read_replica, + clear_read_replica_context, +) + +logger = logging.getLogger("plane.api") + + +class ReadReplicaRoutingMiddleware: + """ + Middleware for intelligent database routing to read replicas. + Routing Logic: + • Non-GET requests (POST, PUT, DELETE, PATCH) ➜ Primary database + • GET requests: + - View has use_read_replica=False ➜ Primary database + - View has use_read_replica=True ➜ Read replica + - View has no use_read_replica attribute ➜ Primary database (safe default) + The middleware supports both Django CBVs and DRF APIViews/ViewSets. + Context is properly isolated per request to prevent data leakage. + """ + + # HTTP methods that are considered read-only by default + READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"} + + def __init__(self, get_response): + """ + Initialize the middleware with the next middleware/view in the chain. + Args: + get_response: The next middleware or view function + """ + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + """ + Process the request and determine database routing. + Args: + request: The HTTP request object + Returns: + HttpResponse: The HTTP response from the view + """ + # For non-read operations, set primary database immediately + if request.method not in self.READ_ONLY_METHODS: + set_use_read_replica(False) + logger.debug(f"Routing {request.method} {request.path} to primary database") + + try: + # Process the request through the middleware chain + response = self.get_response(request) + return response + finally: + # Always clean up context, even if an exception occurs + # This prevents context leakage between requests + clear_read_replica_context() + + def process_view( + self, + request: HttpRequest, + view_func: Callable, + view_args: tuple, + view_kwargs: dict, + ) -> None: + """ + Hook called just before Django calls the view. + This is more efficient than resolving URLs in __call__ since Django + provides the view function directly. + Args: + request: The HTTP request object + view_func: The view function to be called + view_args: Positional arguments for the view + view_kwargs: Keyword arguments for the view + """ + # Only process read operations (write operations already handled in __call__) + if request.method in self.READ_ONLY_METHODS: + use_replica = self._should_use_read_replica(view_func) + set_use_read_replica(use_replica) + + db_type = "read replica" if use_replica else "primary database" + logger.debug(f"Routing {request.method} {request.path} to {db_type}") + + # Return None to continue normal request processing + return None + + def _should_use_read_replica(self, view_func: Callable) -> bool: + """ + Determine if the view should use read replica based on its configuration. + Args: + view_func: The view function to inspect + Returns: + bool: True if should use read replica, False for primary database + """ + use_replica_attr = self._get_use_replica_attribute(view_func) + + # Default to primary database for GET requests if no explicit setting + # This ensures only views that explicitly opt-in use read replicas + if use_replica_attr is None: + return False + + return bool(use_replica_attr) + + def _get_use_replica_attribute(self, view_func: Callable) -> Optional[bool]: + """ + Extract the use_read_replica attribute from various view types. + Args: + view_func: The view function to inspect + Returns: + Optional[bool]: The use_read_replica setting, or None if not found + """ + # Return None if view_func is None to prevent AttributeError + if view_func is None: + return None + + # Check function-based view attribute + use_replica = getattr(view_func, "use_read_replica", None) + if use_replica is not None: + return use_replica + + # Check Django CBV wrapper + if hasattr(view_func, "view_class"): + use_replica = getattr(view_func.view_class, "use_read_replica", None) + if use_replica is not None: + return use_replica + + # Check DRF wrapper (APIView / ViewSet) + if hasattr(view_func, "cls"): + use_replica = getattr(view_func.cls, "use_read_replica", None) + if use_replica is not None: + return use_replica + + return None + + def process_exception(self, request: HttpRequest, exception: Exception) -> None: + """ + Handle exceptions that occur during view processing. + This provides an additional safety net for context cleanup when views + raise exceptions, complementing the try/finally in __call__. + Args: + request: The HTTP request object + exception: The exception that was raised + Returns: + None: Don't handle the exception, just clean up context + """ + # Clean up context on exception as a safety measure + # The try/finally in __call__ should handle most cases, but this + # provides extra protection specifically for view exceptions + clear_read_replica_context() + logger.debug(f"Cleaned up read replica context due to exception: {type(exception).__name__}") + + # Return None to let the exception continue propagating + return None diff --git a/apps/api/plane/middleware/logger.py b/apps/api/plane/middleware/logger.py new file mode 100644 index 00000000..d513ee3e --- /dev/null +++ b/apps/api/plane/middleware/logger.py @@ -0,0 +1,127 @@ +# Python imports +import logging +import time + +# Django imports +from django.http import HttpRequest + +# Third party imports +from rest_framework.request import Request + +# Module imports +from plane.utils.ip_address import get_client_ip +from plane.db.models import APIActivityLog + +api_logger = logging.getLogger("plane.api.request") + + +class RequestLoggerMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def _should_log_route(self, request: Request | HttpRequest) -> bool: + """ + Determines whether a route should be logged based on the request and status code. + """ + # Don't log health checks + if request.path == "/" and request.method == "GET": + return False + return True + + def __call__(self, request): + # get the start time + start_time = time.time() + + # Get the response + response = self.get_response(request) + + # calculate the duration + duration = time.time() - start_time + + # Check if logging is required + log_true = self._should_log_route(request=request) + + # If logging is not required, return the response + if not log_true: + return response + + user_id = ( + request.user.id if getattr(request, "user") and getattr(request.user, "is_authenticated", False) else None + ) + + user_agent = request.META.get("HTTP_USER_AGENT", "") + + # Log the request information + api_logger.info( + f"{request.method} {request.get_full_path()} {response.status_code}", + extra={ + "path": request.path, + "method": request.method, + "status_code": response.status_code, + "duration_ms": int(duration * 1000), + "remote_addr": get_client_ip(request), + "user_agent": user_agent, + "user_id": user_id, + }, + ) + + # return the response + return response + + +class APITokenLogMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request_body = request.body + response = self.get_response(request) + self.process_request(request, response, request_body) + return response + + def _safe_decode_body(self, content): + """ + Safely decodes request/response body content, handling binary data. + Returns None if content is None, or a string representation of the content. + """ + # If the content is None, return None + if content is None: + return None + + # If the content is an empty bytes object, return None + if content == b"": + return None + + # Check if content is binary by looking for common binary file signatures + if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"): + return "[Binary Content]" + + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return "[Could not decode content]" + + def process_request(self, request, response, request_body): + api_key_header = "X-Api-Key" + api_key = request.headers.get(api_key_header) + # If the API key is present, log the request + if api_key: + try: + APIActivityLog.objects.create( + token_identifier=api_key, + path=request.path, + method=request.method, + query_params=request.META.get("QUERY_STRING", ""), + headers=str(request.headers), + body=(self._safe_decode_body(request_body) if request_body else None), + response_body=(self._safe_decode_body(response.content) if response.content else None), + response_code=response.status_code, + ip_address=get_client_ip(request=request), + user_agent=request.META.get("HTTP_USER_AGENT", None), + ) + + except Exception as e: + api_logger.exception(e) + # If the token does not exist, you can decide whether to log this as an invalid attempt + + return None diff --git a/apps/api/plane/middleware/request_body_size.py b/apps/api/plane/middleware/request_body_size.py new file mode 100644 index 00000000..9807c571 --- /dev/null +++ b/apps/api/plane/middleware/request_body_size.py @@ -0,0 +1,27 @@ +from django.core.exceptions import RequestDataTooBig +from django.http import JsonResponse + + +class RequestBodySizeLimitMiddleware: + """ + Middleware to catch RequestDataTooBig exceptions and return + 413 Request Entity Too Large instead of 400 Bad Request. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + try: + _ = request.body + except RequestDataTooBig: + return JsonResponse( + { + "error": "REQUEST_BODY_TOO_LARGE", + "detail": "The size of the request body exceeds the maximum allowed size.", + }, + status=413, + ) + + # If body size is OK, continue with the request + return self.get_response(request) diff --git a/apps/api/plane/seeds/data/cycles.json b/apps/api/plane/seeds/data/cycles.json new file mode 100644 index 00000000..484508f7 --- /dev/null +++ b/apps/api/plane/seeds/data/cycles.json @@ -0,0 +1,18 @@ +[ + { + "id": 1, + "name": "Cycle 1: Getting Started with Plane", + "project_id": 1, + "sort_order": 1, + "timezone": "UTC", + "type": "CURRENT" + }, + { + "id": 2, + "name": "Cycle 2: Collaboration & Customization", + "project_id": 1, + "sort_order": 2, + "timezone": "UTC", + "type": "UPCOMING" + } +] \ No newline at end of file diff --git a/apps/api/plane/seeds/data/issues.json b/apps/api/plane/seeds/data/issues.json new file mode 100644 index 00000000..badd0e61 --- /dev/null +++ b/apps/api/plane/seeds/data/issues.json @@ -0,0 +1,99 @@ +[ + { + "id": 1, + "name": "Welcome to Plane 👋", + "sequence_id": 1, + "description_html": "

    Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.

    Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.

    First thing to try

    1. Look in the Properties section below where it says State: Todo.

    2. Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.

    ", + "description_stripped": "Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.First thing to tryLook in the Properties section below where it says State: Todo.Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.", + "sort_order": 1000, + "state_id": 4, + "labels": [], + "priority": "urgent", + "project_id": 1, + "cycle_id": 1, + "module_ids": [1] + }, + { + "id": 2, + "name": "1. Create Projects 🎯", + "sequence_id": 2, + "description_html": "


    A Project in Plane is where all your work comes together. Think of it as a base that organizes your work items and everything else your team needs to get things done.

    Note: This tutorial is already set up as a Project, and these cards you're reading are work items within it!

    We're showing you how to create a new project just so you'll know exactly what to do when you're ready to start your own real one.

    1. Look over at the left sidebar and find where it says Projects.

    2. Hover your mouse there and you'll see a little + icon pop up - go ahead and click it!

    3. A modal opens where you can give your project a name and other details.

    4. Notice the Access type options? Public means anyone (except Guest users) can see and join it, while Private keeps it just for those you invite.

      Tip: You can also quickly create a new project by using the keyboard shortcut P from anywhere in Plane!

    ", + "sort_order": 2000, + "state_id": 2, + "labels": [2], + "priority": "high", + "project_id": 1, + "cycle_id": 1, + "module_ids": [1] + }, + { + "id": 3, + "name": "2. Invite your team 🤜🤛", + "sequence_id": 3, + "description_html": "

    Let's get your teammates on board!

    First, you'll need to invite them to your workspace before they can join specific projects:

    1. Click on your workspace name in the top-left corner, then select Settings from the dropdown.

    2. Head over to the Members tab - this is your user management hub. Click Add member on the top right.

    3. Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.

    4. Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.

    5. To do this, go to your project's Settings page.

    6. Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.


    That's it!

    To learn more about user management, see Manage users and roles.

    ", + "description_stripped": "Let's get your teammates on board!First, you'll need to invite them to your workspace before they can join specific projects:Click on your workspace name in the top-left corner, then select Settings from the dropdown.Head over to the Members tab - this is your user management hub. Click Add member on the top right.Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.To do this, go to your project's Settings page.Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.That's it!To learn more about user management, see Manage users and roles.", + "sort_order": 3000, + "state_id": 1, + "labels": [], + "priority": "high", + "project_id": 1, + "cycle_id": 1, + "module_ids": [1, 2] + }, + { + "id": 4, + "name": "3. Create and assign Work Items ✏️", + "sequence_id": 4, + "description_html": "

    A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.

    Ready to add something to your project's to-do list? Here's how:

    1. Click the Add work item button in the top-right corner of the Work Items page.

    2. Give your task a clear title and add any details in the description.

    3. Set up the essentials:

      • Assign it to a team member (or yourself!)

      • Choose a priority level

      • Add start and due dates if there's a timeline

    Tip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!

    Want to dive deeper into all the things you can do with work items? Check out our documentation.

    ", + "description_stripped": "A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.Ready to add something to your project's to-do list? Here's how:Click the Add work item button in the top-right corner of the Work Items page.Give your task a clear title and add any details in the description.Set up the essentials:Assign it to a team member (or yourself!)Choose a priority levelAdd start and due dates if there's a timelineTip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!Want to dive deeper into all the things you can do with work items? Check out our documentation.", + "sort_order": 4000, + "state_id": 3, + "labels": [2], + "priority": "high", + "project_id": 1, + "cycle_id": 1, + "module_ids": [1, 2] + }, + { + "id": 5, + "name": "4. Visualize your work 🔮", + "sequence_id": 5, + "description_html": "

    Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!

    Switch between layouts

    1. Look at the top toolbar in your project. You'll see several layout icons.

    2. Click any of these icons to instantly switch between layouts.

    Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.

    Filter and display options

    Need to focus on specific work?

    1. Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.

    2. Click the Display dropdown to tailor how the information appears in your layout

    3. Created the perfect setup? Save it for later by clicking the the Save View button.

    4. Access saved views anytime from the Views section in your sidebar.

    ", + "description_stripped": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!Switch between layoutsLook at the top toolbar in your project. You'll see several layout icons.Click any of these icons to instantly switch between layouts.Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.Filter and display optionsNeed to focus on specific work?Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.Click the Display dropdown to tailor how the information appears in your layoutCreated the perfect setup? Save it for later by clicking the the Save View button.Access saved views anytime from the Views section in your sidebar.", + "sort_order": 5000, + "state_id": 3, + "labels": [], + "priority": "none", + "project_id": 1, + "cycle_id": 2, + "module_ids": [2] + }, + { + "id": 6, + "name": "5. Use Cycles to time box tasks 🗓️", + "sequence_id": 6, + "description_html": "

    A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.

    Setup Cycles

    1. Go to the Cycles section in your project (you can find it in the left sidebar)

    2. Click the Add cycle button in the top-right corner

    3. Enter details and set the start and end dates for your cycle.

    4. Click Create cycle and you're ready to go!

    5. Add existing work items to the Cycle or create new ones.

    Tip: To create a new Cycle quickly, just press Q from anywhere in your project!

    Want to learn more?

    • Starting and stopping cycles

    • Transferring work items between cycles

    • Tracking progress with charts

    Check out our detailed documentation for everything you need to know!

    ", + "description_stripped": "A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.Setup CyclesGo to the Cycles section in your project (you can find it in the left sidebar)Click the Add cycle button in the top-right cornerEnter details and set the start and end dates for your cycle.Click Create cycle and you're ready to go!Add existing work items to the Cycle or create new ones.Tip: To create a new Cycle quickly, just press Q from anywhere in your project!Want to learn more?Starting and stopping cyclesTransferring work items between cyclesTracking progress with chartsCheck out our detailed documentation for everything you need to know!", + "sort_order": 6000, + "state_id": 1, + "labels": [2], + "priority": "low", + "project_id": 1, + "cycle_id": 2, + "module_ids": [2, 3] + }, + { + "id": 7, + "name": "6. Customize your settings ⚙️", + "sequence_id": 7, + "description_html": "

    Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!

    Workspace settings

    Remember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:

    • Invite and manage workspace members

    • Upgrade plans and manage billing

    • Import data from other tools

    • Export your data

    • Manage integrations

    Project Settings

    Each project has its own settings where you can:

    • Change project details and visibility

    • Invite specific members to just this project

    • Customize your workflow States (like adding a \"Testing\" state)

    • Create and organize Labels

    • Enable or disable features you need (or don't need)

    Your Profile Settings

    You can also customize your own personal experience! Click on your profile icon in the top-right corner to find:

    • Profile settings (update your name, photo, etc.)

    • Choose your timezone and preferred language for the interface

    • Email notification preferences (what you want to be alerted about)

    • Appearance settings (light/dark mode)

    Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!

    Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.

    ", + "description_stripped": "Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!Workspace settingsRemember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:Invite and manage workspace membersUpgrade plans and manage billingImport data from other toolsExport your dataManage integrationsProject SettingsEach project has its own settings where you can:Change project details and visibilityInvite specific members to just this projectCustomize your workflow States (like adding a \"Testing\" state)Create and organize LabelsEnable or disable features you need (or don't need)Your Profile SettingsYou can also customize your own personal experience! Click on your profile icon in the top-right corner to find:Profile settings (update your name, photo, etc.)Choose your timezone and preferred language for the interfaceEmail notification preferences (what you want to be alerted about)Appearance settings (light/dark mode)Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.", + "sort_order": 7000, + "state_id": 1, + "labels": [], + "priority": "none", + "project_id": 1, + "cycle_id": 2, + "module_ids": [2, 3] + } +] diff --git a/apps/api/plane/seeds/data/labels.json b/apps/api/plane/seeds/data/labels.json new file mode 100644 index 00000000..f7286a69 --- /dev/null +++ b/apps/api/plane/seeds/data/labels.json @@ -0,0 +1,16 @@ +[ + { + "id": 1, + "name": "admin", + "color": "#0693e3", + "sort_order": 85535, + "project_id": 1 + }, + { + "id": 2, + "name": "concepts", + "color": "#9900ef", + "sort_order": 95535, + "project_id": 1 + } +] diff --git a/apps/api/plane/seeds/data/modules.json b/apps/api/plane/seeds/data/modules.json new file mode 100644 index 00000000..f770276d --- /dev/null +++ b/apps/api/plane/seeds/data/modules.json @@ -0,0 +1,26 @@ +[ + { + "id": 1, + "name": "Core Workflow (System)", + "project_id": 1, + "sort_order": 1, + "status": "planned", + "description": "Manage, visualize, and track your work items across views." + }, + { + "id": 2, + "name": "Onboarding Flow (Feature)", + "project_id": 1, + "sort_order": 2, + "status": "backlog", + "description": "Everything about getting started - creating a project, inviting teammates." + }, + { + "id": 3, + "name": "Workspace Setup (Area)", + "project_id": 1, + "sort_order": 3, + "status": "in-progress", + "description": "The personalization layer - settings, labels, automations." + } +] \ No newline at end of file diff --git a/apps/api/plane/seeds/data/pages.json b/apps/api/plane/seeds/data/pages.json new file mode 100644 index 00000000..d719220b --- /dev/null +++ b/apps/api/plane/seeds/data/pages.json @@ -0,0 +1,30 @@ +[ + { + "id": 1, + "name": "Project Design Spec", + "project_id": 1, + "description_html": "

    Welcome to your Project Pages — the documentation hub for this specific project.
    Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.

    🧭 Project Summary

    Field

    Details

    Project Name

    Add your project name

    Owner

    Add project owner(s)

    Status

    🟢 Active / 🟡 In Progress / 🔴 Blocked

    Start Date

    Target Release

    Linked Modules

    Engineering, Security

    Cycle(s)

    Cycle 1, Cycle 2

    🧩 Use tables to summarize key project metadata or links.

    🎯 Goals & Objectives

    🎯 Primary Goals

    • Deliver MVP with all core features

    • Validate feature adoption with early users

    • Prepare launch plan for v1 release

    Success Metrics

    Metric

    Target

    Owner

    User adoption

    100 active users

    Growth

    Performance

    < 200ms latency

    Backend

    Design feedback

    ≥ 8/10 average rating

    Design

    📈 Define measurable outcomes and track progress alongside issues.

    🧩 Scope & Deliverables

    Deliverable

    Owner

    Status

    Authentication flow

    Backend

    Done

    Issue board UI

    Frontend

    🏗 In Progress

    API integration

    Backend

    Pending

    Documentation

    PM

    📝 Drafting

    🧩 Use tables or checklists to track scope and ownership.

    🧱 Architecture or System Design

    Use this section for technical deep dives or diagrams.

    Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs

    ", + "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Welcome to your \", \"type\": \"text\"}, {\"text\": \"Project Pages\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — the documentation hub for this specific project.\", \"type\": \"text\"}, {\"type\": \"hardBreak\"}, {\"text\": \"Each project in Plane can have its own Wiki space where you track \", \"type\": \"text\"}, {\"text\": \"plans, specs, updates, and learnings\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — all connected to your issues and modules.\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"compass\"}}, {\"text\": \" Project Summary\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Field\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Details\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Project Name\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"pencil\"}}, {\"text\": \" Add your project name\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Add project owner(s)\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"green_circle\"}}, {\"text\": \" Active / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"yellow_circle\"}}, {\"text\": \" In Progress / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"red_circle\"}}, {\"text\": \" Blocked\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Start Date\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target Release\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Linked Modules\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Engineering, Security\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle(s)\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle 1, Cycle 2\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables to summarize key project metadata or links.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Goals & Objectives\", \"type\": \"text\"}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Primary Goals\", \"type\": \"text\"}]}, {\"type\": \"bulletList\", \"content\": [{\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliver MVP with all core features\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Validate feature adoption with early users\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Prepare launch plan for v1 release\", \"type\": \"text\"}]}]}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"gear\"}}, {\"text\": \" Success Metrics\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Metric\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"User adoption\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"100 active users\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Growth\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Performance\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"< 200ms latency\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design feedback\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"≥ 8/10 average rating\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"chart_increasing\"}}, {\"text\": \" Define measurable outcomes and track progress alongside issues.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Scope & Deliverables\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliverable\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Authentication flow\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Done\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Issue board UI\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Frontend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"building_construction\"}}, {\"text\": \" In Progress\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"API integration\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"hourglass_flowing_sand\"}}, {\"text\": \" Pending\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Documentation\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"PM\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"memo\"}}, {\"text\": \" Drafting\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables or checklists to track scope and ownership.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Architecture or System Design\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Use this section for \", \"type\": \"text\"}, {\"text\": \"technical deep dives\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" or diagrams.\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"Frontend → GraphQL → Backend → PostgreSQL\\nRedis for caching | RabbitMQ for background jobs\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}", + "description_stripped": "Welcome to your Project Pages — the documentation hub for this specific project.Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.🧭 Project SummaryFieldDetailsProject Name✏ Add your project nameOwnerAdd project owner(s)Status🟢 Active / 🟡 In Progress / 🔴 BlockedStart Date—Target Release—Linked ModulesEngineering, SecurityCycle(s)Cycle 1, Cycle 2🧩 Use tables to summarize key project metadata or links.🎯 Goals & Objectives🎯 Primary GoalsDeliver MVP with all core featuresValidate feature adoption with early usersPrepare launch plan for v1 release⚙ Success MetricsMetricTargetOwnerUser adoption100 active usersGrowthPerformance< 200ms latencyBackendDesign feedback≥ 8/10 average ratingDesign📈 Define measurable outcomes and track progress alongside issues.🧩 Scope & DeliverablesDeliverableOwnerStatusAuthentication flowBackend✅ DoneIssue board UIFrontend🏗 In ProgressAPI integrationBackend⏳ PendingDocumentationPM📝 Drafting🧩 Use tables or checklists to track scope and ownership.🧱 Architecture or System DesignUse this section for technical deep dives or diagrams.Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs", + "type": "PROJECT", + "access": 0, + "logo_props": { + "emoji": { + "url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f680.png", + "value": "128640" + }, + "in_use": "emoji" + } + }, + { + "id": 2, + "name": "Project Draft proposal", + "project_id": 1, + "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"This is your \", \"type\": \"text\"}, {\"text\": \"Project Draft area\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"It’s visible only to you (and collaborators you explicitly share with).\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"writing_hand\"}}, {\"text\": \" Current Work in Progress\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"speech_balloon\"}}, {\"text\": \" Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.\", \"type\": \"text\"}]}]}, {\"type\": \"taskList\", \"content\": [{\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Outline project summary and goals\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Draft new feature spec\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Review dependency list\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Collect team feedback for next iteration\", \"type\": \"text\"}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Tip: Turn these items into actionable issues when finalized.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Prototype Commands (if technical)\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"You can also use \", \"type\": \"text\"}, {\"text\": \"code blocks\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" to store snippets, scripts, or notes:\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"# Rebuild Docker containers\\ndocker compose build backend frontend\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}", + "description_html": "

    This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.

    It’s visible only to you (and collaborators you explicitly share with).

    Current Work in Progress

    💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.

    • Outline project summary and goals

    • Draft new feature spec

    • Review dependency list

    • Collect team feedback for next iteration

    Tip: Turn these items into actionable issues when finalized.

    🧱 Prototype Commands (if technical)

    You can also use code blocks to store snippets, scripts, or notes:

    # Rebuild Docker containers\ndocker compose build backend frontend

    ", + "description_stripped": "This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.It’s visible only to you (and collaborators you explicitly share with).✍ Current Work in Progress💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet. Outline project summary and goals Draft new feature spec Review dependency list Collect team feedback for next iteration✅ Tip: Turn these items into actionable issues when finalized.🧱 Prototype Commands (if technical)You can also use code blocks to store snippets, scripts, or notes:# Rebuild Docker containers\ndocker compose build backend frontend", + "type": "PROJECT", + "access": 1, + "logo_props": "{\"emoji\": {\"url\": \"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f9f1.png\", \"value\": \"129521\"}, \"in_use\": \"emoji\"}" + } +] \ No newline at end of file diff --git a/apps/api/plane/seeds/data/projects.json b/apps/api/plane/seeds/data/projects.json new file mode 100644 index 00000000..1b24b864 --- /dev/null +++ b/apps/api/plane/seeds/data/projects.json @@ -0,0 +1,17 @@ +[ + { + "id": 1, + "name": "Plane Demo Project", + "identifier": "PDP", + "description": "Welcome to the Plane Demo Project! This project throws you into the driver’s seat of Plane, work management software. Through curated work items, you’ll uncover key features, pick up best practices, and see how Plane can streamline your team’s workflow. Whether you’re a startup hungry to scale or an enterprise sharpening efficiency, this demo is your launchpad to mastering Plane. Jump in and see what it can do!", + "network": 2, + "cover_image": "https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80", + "logo_props": { + "emoji": { + "url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f447.png", + "value": "128071" + }, + "in_use": "emoji" + } + } +] diff --git a/apps/api/plane/seeds/data/states.json b/apps/api/plane/seeds/data/states.json new file mode 100644 index 00000000..5eff65b9 --- /dev/null +++ b/apps/api/plane/seeds/data/states.json @@ -0,0 +1,47 @@ +[ + { + "id": 1, + "name": "Backlog", + "color": "#A3A3A3", + "sequence": 15000, + "group": "backlog", + "default": true, + "project_id": 1 + }, + { + "id": 2, + "name": "Todo", + "color": "#3A3A3A", + "sequence": 25000, + "group": "unstarted", + "default": false, + "project_id": 1 + }, + { + "id": 3, + "name": "In Progress", + "color": "#F59E0B", + "sequence": 35000, + "group": "started", + "default": false, + "project_id": 1 + }, + { + "id": 4, + "name": "Done", + "color": "#16A34A", + "sequence": 45000, + "group": "completed", + "default": false, + "project_id": 1 + }, + { + "id": 5, + "name": "Cancelled", + "color": "#EF4444", + "sequence": 55000, + "group": "cancelled", + "default": false, + "project_id": 1 + } +] diff --git a/apps/api/plane/seeds/data/views.json b/apps/api/plane/seeds/data/views.json new file mode 100644 index 00000000..f9d18232 --- /dev/null +++ b/apps/api/plane/seeds/data/views.json @@ -0,0 +1,14 @@ +[ + { + "id": 1, + "name": "Project Urgent Tasks", + "description": "Project Urgent Tasks", + "access": 1, + "filters": {}, + "project_id": 1, + "display_filters": {"layout": "list", "calendar": {"layout": "month", "show_weekends": false}, "group_by": "state", "order_by": "sort_order", "sub_issue": false, "sub_group_by": null, "show_empty_groups": false}, + "display_properties": {"key": true, "link": true, "cycle": true, "state": true, "labels": true, "modules": true, "assignee": true, "due_date": true, "estimate": true, "priority": true, "created_on": true, "issue_type": true, "start_date": true, "updated_on": true, "customer_count": true, "sub_issue_count": true, "attachment_count": true, "customer_request_count": true}, + "sort_order": 75535, + "rich_filters": {"priority__in": "urgent"} + } +] \ No newline at end of file diff --git a/apps/api/plane/settings/__init__.py b/apps/api/plane/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py new file mode 100644 index 00000000..d47bf629 --- /dev/null +++ b/apps/api/plane/settings/common.py @@ -0,0 +1,454 @@ +"""Global Settings""" + +# Python imports +import os +from urllib.parse import urlparse +from urllib.parse import urljoin + +# Third party imports +import dj_database_url + +# Django imports +from django.core.management.utils import get_random_secret_key +from corsheaders.defaults import default_headers + + +# Module imports +from plane.utils.url import is_valid_url + + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Secret Key +SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key()) + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = int(os.environ.get("DEBUG", "0")) + +# Allowed Hosts +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",") + +# Application definition +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + # Inhouse apps + "plane.analytics", + "plane.app", + "plane.space", + "plane.bgtasks", + "plane.db", + "plane.utils", + "plane.web", + "plane.middleware", + "plane.license", + "plane.api", + "plane.authentication", + # Third-party things + "rest_framework", + "corsheaders", + "django_celery_beat", +] + +# Middlewares +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "plane.authentication.middleware.session.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "crum.CurrentRequestUserMiddleware", + "django.middleware.gzip.GZipMiddleware", + "plane.middleware.request_body_size.RequestBodySizeLimitMiddleware", + "plane.middleware.logger.APITokenLogMiddleware", + "plane.middleware.logger.RequestLoggerMiddleware", +] + +# Rest Framework settings +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",), + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), + "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), + "EXCEPTION_HANDLER": "plane.authentication.adapter.exception.auth_exception_handler", + # Preserve original Django URL parameter names (pk) instead of converting to 'id' + "SCHEMA_COERCE_PATH_PK": False, +} + +# Django Auth Backend +AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) # default + +# Root Urls +ROOT_URLCONF = "plane.urls" + +# Templates +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": ["templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + + +# CORS Settings +CORS_ALLOW_CREDENTIALS = True +cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "") +# filter out empty strings +cors_allowed_origins = [origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()] +if cors_allowed_origins: + CORS_ALLOWED_ORIGINS = cors_allowed_origins + secure_origins = False if [origin for origin in cors_allowed_origins if "http:" in origin] else True +else: + CORS_ALLOW_ALL_ORIGINS = True + secure_origins = False + +CORS_ALLOW_HEADERS = [*default_headers, "X-API-Key"] + +# Application Settings +WSGI_APPLICATION = "plane.wsgi.application" +ASGI_APPLICATION = "plane.asgi.application" + +# Django Sites +SITE_ID = 1 + +# User Model +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()} +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB"), + "USER": os.environ.get("POSTGRES_USER"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), + "HOST": os.environ.get("POSTGRES_HOST"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), + } + } + + +if os.environ.get("ENABLE_READ_REPLICA", "0") == "1": + if bool(os.environ.get("DATABASE_READ_REPLICA_URL")): + # Parse database configuration from $DATABASE_URL + DATABASES["replica"] = dj_database_url.parse(os.environ.get("DATABASE_READ_REPLICA_URL")) + else: + DATABASES["replica"] = { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_READ_REPLICA_DB"), + "USER": os.environ.get("POSTGRES_READ_REPLICA_USER"), + "PASSWORD": os.environ.get("POSTGRES_READ_REPLICA_PASSWORD"), + "HOST": os.environ.get("POSTGRES_READ_REPLICA_HOST"), + "PORT": os.environ.get("POSTGRES_READ_REPLICA_PORT", "5432"), + } + + # Database Routers + DATABASE_ROUTERS = ["plane.utils.core.dbrouters.ReadReplicaRouter"] + # Add middleware at the end for read replica routing + MIDDLEWARE.append("plane.middleware.db_routing.ReadReplicaRoutingMiddleware") + + +# Redis Config +REDIS_URL = os.environ.get("REDIS_URL") +REDIS_SSL = REDIS_URL and "rediss" in REDIS_URL + +if REDIS_SSL: + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, + }, + } + } +else: + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, + } + } + +# Password validations +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"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 +PASSWORD_RESET_TIMEOUT = 3600 + +# Static files (CSS, JavaScript, Images) +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "static-assets", "collected-static") +STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) + +# Media Settings +MEDIA_ROOT = "mediafiles" +MEDIA_URL = "/media/" + +# Internationalization +LANGUAGE_CODE = "en-us" +USE_I18N = True +USE_L10N = True + +# Timezones +USE_TZ = True +TIME_ZONE = "UTC" + +# Default Auto Field +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Email settings +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + +# Storage Settings +# Use Minio settings +USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 + +STORAGES = {"staticfiles": {"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") +AWS_REGION = os.environ.get("AWS_REGION", "") +AWS_DEFAULT_ACL = "public-read" +AWS_QUERYSTRING_AUTH = False +AWS_S3_FILE_OVERWRITE = False +AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", None) or os.environ.get("MINIO_ENDPOINT_URL", None) +if AWS_S3_ENDPOINT_URL 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}" + AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" + +# RabbitMQ connection settings +RABBITMQ_HOST = os.environ.get("RABBITMQ_HOST", "localhost") +RABBITMQ_PORT = os.environ.get("RABBITMQ_PORT", "5672") +RABBITMQ_USER = os.environ.get("RABBITMQ_USER", "guest") +RABBITMQ_PASSWORD = os.environ.get("RABBITMQ_PASSWORD", "guest") +RABBITMQ_VHOST = os.environ.get("RABBITMQ_VHOST", "/") +AMQP_URL = os.environ.get("AMQP_URL") + +# Celery Configuration +if AMQP_URL: + CELERY_BROKER_URL = AMQP_URL +else: + CELERY_BROKER_URL = f"amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}" + +CELERY_TIMEZONE = TIME_ZONE +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_ACCEPT_CONTENT = ["application/json"] + + +CELERY_IMPORTS = ( + # scheduled tasks + "plane.bgtasks.issue_automation_task", + "plane.bgtasks.exporter_expired_task", + "plane.bgtasks.file_asset_task", + "plane.bgtasks.email_notification_task", + "plane.bgtasks.cleanup_task", + "plane.license.bgtasks.tracer", + # management tasks + "plane.bgtasks.dummy_data_task", + # issue version tasks + "plane.bgtasks.issue_version_sync", + "plane.bgtasks.issue_description_version_sync", +) + +FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") +# Github Access Token +GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) + +# Analytics +ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) +ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) + +# Posthog settings +POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False) +POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) + +# Skip environment variable configuration +SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1" + +DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + +# Cookie Settings +SESSION_COOKIE_SECURE = secure_origins +SESSION_COOKIE_HTTPONLY = True +SESSION_ENGINE = "plane.db.models.session" +SESSION_COOKIE_AGE = int(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" + +# Admin Cookie +ADMIN_SESSION_COOKIE_NAME = "admin-session-id" +ADMIN_SESSION_COOKIE_AGE = int(os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)) + +# CSRF cookies +CSRF_COOKIE_SECURE = secure_origins +CSRF_COOKIE_HTTPONLY = True +CSRF_TRUSTED_ORIGINS = cors_allowed_origins +CSRF_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) +CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure" + +###### Base URLs ###### + +# Admin Base URL +ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) +if ADMIN_BASE_URL and not is_valid_url(ADMIN_BASE_URL): + ADMIN_BASE_URL = None +ADMIN_BASE_PATH = os.environ.get("ADMIN_BASE_PATH", "/god-mode/") + +# Space Base URL +SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) +if SPACE_BASE_URL and not is_valid_url(SPACE_BASE_URL): + SPACE_BASE_URL = None +SPACE_BASE_PATH = os.environ.get("SPACE_BASE_PATH", "/spaces/") + +# App Base URL +APP_BASE_URL = os.environ.get("APP_BASE_URL", None) +if APP_BASE_URL and not is_valid_url(APP_BASE_URL): + APP_BASE_URL = None +APP_BASE_PATH = os.environ.get("APP_BASE_PATH", "/") + +# Live Base URL +LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL", None) +if LIVE_BASE_URL and not is_valid_url(LIVE_BASE_URL): + LIVE_BASE_URL = None +LIVE_BASE_PATH = os.environ.get("LIVE_BASE_PATH", "/live/") + +LIVE_URL = urljoin(LIVE_BASE_URL, LIVE_BASE_PATH) if LIVE_BASE_URL else None + +# WEB URL +WEB_URL = os.environ.get("WEB_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", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.oasis.opendocument.graphics", + # Microsoft Visio + "application/vnd.visio", + # Netpbm format + "image/x-portable-graymap", + "image/x-portable-bitmap", + "image/x-portable-pixmap", + # Open Office Bae + "application/vnd.oasis.opendocument.database", + # 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", + "application/x-rar-compressed", + "application/x-tar", + "application/gzip", + "application/x-zip", + "application/x-zip-compressed", + "application/x-7z-compressed", + "application/x-compressed", + "application/x-compressed-tar", + "application/x-compressed-tar-gz", + "application/x-compressed-tar-bz2", + "application/x-compressed-tar-zip", + "application/x-compressed-tar-7z", + "application/x-compressed-tar-rar", + "application/x-compressed-tar-zip", + # 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", + # SQL + "application/x-sql", + # Gzip + "application/x-gzip", +] + +# Seed directory path +SEED_DIR = os.path.join(BASE_DIR, "seeds") + +ENABLE_DRF_SPECTACULAR = os.environ.get("ENABLE_DRF_SPECTACULAR", "0") == "1" + +if ENABLE_DRF_SPECTACULAR: + REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema" + INSTALLED_APPS.append("drf_spectacular") + from .openapi import SPECTACULAR_SETTINGS # noqa: F401 + +# MongoDB Settings +MONGO_DB_URL = os.environ.get("MONGO_DB_URL", False) +MONGO_DB_DATABASE = os.environ.get("MONGO_DB_DATABASE", False) diff --git a/apps/api/plane/settings/local.py b/apps/api/plane/settings/local.py new file mode 100644 index 00000000..84737712 --- /dev/null +++ b/apps/api/plane/settings/local.py @@ -0,0 +1,80 @@ +"""Development settings""" + +import os + +from .common import * # noqa + +DEBUG = True + +# Debug Toolbar settings +INSTALLED_APPS += ("debug_toolbar",) # noqa +MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # noqa + +DEBUG_TOOLBAR_PATCH_SETTINGS = False + +# Only show emails in console don't send it to smtp +EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend") + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, # noqa + "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, + } +} + +INTERNAL_IPS = ("127.0.0.1",) + +MEDIA_URL = "/uploads/" +MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") # noqa + +LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa + +if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + +LOGGING = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + "json": { + "()": "pythonjsonlogger.jsonlogger.JsonFormatter", + "fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s", + }, + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "json", + } + }, + "loggers": { + "plane.api.request": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, + "plane.api": {"level": "INFO", "handlers": ["console"], "propagate": False}, + "plane.worker": {"level": "INFO", "handlers": ["console"], "propagate": False}, + "plane.exception": { + "level": "ERROR", + "handlers": ["console"], + "propagate": False, + }, + "plane.external": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, + "plane.mongo": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, + }, +} diff --git a/apps/api/plane/settings/mongo.py b/apps/api/plane/settings/mongo.py new file mode 100644 index 00000000..879d0c43 --- /dev/null +++ b/apps/api/plane/settings/mongo.py @@ -0,0 +1,122 @@ +# Django imports +from django.conf import settings +import logging + +# Third party imports +from pymongo import MongoClient +from pymongo.database import Database +from pymongo.collection import Collection +from typing import Optional, TypeVar, Type + + +T = TypeVar("T", bound="MongoConnection") + +# Set up logger +logger = logging.getLogger("plane.mongo") + + +class MongoConnection: + """ + A singleton class that manages MongoDB connections. + + This class ensures only one MongoDB connection is maintained throughout the application. + It provides methods to access the MongoDB client, database, and collections. + + Attributes: + _instance (Optional[MongoConnection]): The singleton instance of this class + _client (Optional[MongoClient]): The MongoDB client instance + _db (Optional[Database]): The MongoDB database instance + """ + + _instance: Optional["MongoConnection"] = None + _client: Optional[MongoClient] = None + _db: Optional[Database] = None + + def __new__(cls: Type[T]) -> T: + """ + Creates a new instance of MongoConnection if one doesn't exist. + + Returns: + MongoConnection: The singleton instance + """ + if cls._instance is None: + cls._instance = super(MongoConnection, cls).__new__(cls) + try: + mongo_url = getattr(settings, "MONGO_DB_URL", None) + mongo_db_database = getattr(settings, "MONGO_DB_DATABASE", None) + + if not mongo_url or not mongo_db_database: + logger.warning( + "MongoDB connection parameters not configured. MongoDB functionality will be disabled." + ) + return cls._instance + + cls._client = MongoClient(mongo_url) + cls._db = cls._client[mongo_db_database] + + # Test the connection + cls._client.server_info() + logger.info("MongoDB connection established successfully") + except Exception as e: + logger.warning( + f"Failed to initialize MongoDB connection: {str(e)}. MongoDB functionality will be disabled." + ) + return cls._instance + + @classmethod + def get_client(cls) -> Optional[MongoClient]: + """ + Returns the MongoDB client instance. + + Returns: + Optional[MongoClient]: The MongoDB client instance or None if not configured + """ + if cls._client is None: + cls._instance = cls() + return cls._client + + @classmethod + def get_db(cls) -> Optional[Database]: + """ + Returns the MongoDB database instance. + + Returns: + Optional[Database]: The MongoDB database instance or None if not configured + """ + if cls._db is None: + cls._instance = cls() + return cls._db + + @classmethod + def get_collection(cls, collection_name: str) -> Optional[Collection]: + """ + Returns a MongoDB collection by name. + + Args: + collection_name (str): The name of the collection to retrieve + + Returns: + Optional[Collection]: The MongoDB collection instance or None if not configured + """ + try: + db = cls.get_db() + if db is None: + logger.warning(f"Cannot access collection '{collection_name}': MongoDB not configured") + return None + return db[collection_name] + except Exception as e: + logger.warning(f"Failed to access collection '{collection_name}': {str(e)}") + return None + + @classmethod + def is_configured(cls) -> bool: + """ + Check if MongoDB is properly configured and connected. + + Returns: + bool: True if MongoDB is configured and connected, False otherwise + """ + + if cls._client is None: + cls._instance = cls() + return cls._client is not None and cls._db is not None diff --git a/apps/api/plane/settings/openapi.py b/apps/api/plane/settings/openapi.py new file mode 100644 index 00000000..b79daeec --- /dev/null +++ b/apps/api/plane/settings/openapi.py @@ -0,0 +1,272 @@ +""" +OpenAPI/Swagger configuration for drf-spectacular. + +This file contains the complete configuration for API documentation generation. +""" + +SPECTACULAR_SETTINGS = { + # ======================================================================== + # Basic API Information + # ======================================================================== + "TITLE": "The Plane REST API", + "DESCRIPTION": ( + "The Plane REST API\n\n" + "Visit our quick start guide and full API documentation at " + "[developers.plane.so](https://developers.plane.so/api-reference/introduction)." + ), + "CONTACT": { + "name": "Plane", + "url": "https://plane.so", + "email": "support@plane.so", + }, + "VERSION": "0.0.1", + "LICENSE": { + "name": "GNU AGPLv3", + "url": "https://github.com/makeplane/plane/blob/preview/LICENSE.txt", + }, + # ======================================================================== + # Schema Generation Settings + # ======================================================================== + "SERVE_INCLUDE_SCHEMA": False, + "SCHEMA_PATH_PREFIX": "/api/v1/", + "SCHEMA_CACHE_TIMEOUT": 0, # disables caching + # ======================================================================== + # Processing Hooks + # ======================================================================== + "PREPROCESSING_HOOKS": [ + "plane.utils.openapi.hooks.preprocess_filter_api_v1_paths", + ], + # ======================================================================== + # Server Configuration + # ======================================================================== + "SERVERS": [ + {"url": "http://localhost:8000", "description": "Local"}, + {"url": "https://api.plane.so", "description": "Production"}, + ], + # ======================================================================== + # API Tag Definitions + # ======================================================================== + "TAGS": [ + # System Features + { + "name": "Assets", + "description": ( + "**File Upload & Presigned URLs**\n\n" + "Generate presigned URLs for direct file uploads to cloud storage. Handle user avatars, " + "cover images, and generic project assets with secure upload workflows.\n\n" + "*Key Features:*\n" + "- Generate presigned URLs for S3 uploads\n" + "- Support for user avatars and cover images\n" + "- Generic asset upload for projects\n" + "- File validation and size limits\n\n" + "*Use Cases:* User profile images, project file uploads, secure direct-to-cloud uploads." + ), + }, + # Project Organization + { + "name": "Cycles", + "description": ( + "**Sprint & Development Cycles**\n\n" + "Create and manage development cycles (sprints) to organize work into time-boxed iterations. " + "Track progress, assign work items, and monitor team velocity.\n\n" + "*Key Features:*\n" + "- Create and configure development cycles\n" + "- Assign work items to cycles\n" + "- Track cycle progress and completion\n" + "- Generate cycle analytics and reports\n\n" + "*Use Cases:* Sprint planning, iterative development, progress tracking, team velocity." + ), + }, + # System Features + { + "name": "Intake", + "description": ( + "**Work Item Intake Queue**\n\n" + "Manage incoming work items through a dedicated intake queue for triage and review. " + "Submit, update, and process work items before they enter the main project workflow.\n\n" + "*Key Features:*\n" + "- Submit work items to intake queue\n" + "- Review and triage incoming work items\n" + "- Update intake work item status and properties\n" + "- Accept, reject, or modify work items before approval\n\n" + "*Use Cases:* Work item triage, external submissions, quality review, approval workflows." + ), + }, + # Project Organization + { + "name": "Labels", + "description": ( + "**Labels & Tags**\n\n" + "Create and manage labels to categorize and organize work items. Use color-coded labels " + "for easy identification, filtering, and project organization.\n\n" + "*Key Features:*\n" + "- Create custom labels with colors and descriptions\n" + "- Apply labels to work items for categorization\n" + "- Filter and search by labels\n" + "- Organize labels across projects\n\n" + "*Use Cases:* Priority marking, feature categorization, bug classification, team organization." + ), + }, + # Team & User Management + { + "name": "Members", + "description": ( + "**Team Member Management**\n\n" + "Manage team members, roles, and permissions within projects and workspaces. " + "Control access levels and track member participation.\n\n" + "*Key Features:*\n" + "- Invite and manage team members\n" + "- Assign roles and permissions\n" + "- Control project and workspace access\n" + "- Track member activity and participation\n\n" + "*Use Cases:* Team setup, access control, role management, collaboration." + ), + }, + # Project Organization + { + "name": "Modules", + "description": ( + "**Feature Modules**\n\n" + "Group related work items into modules for better organization and tracking. " + "Plan features, track progress, and manage deliverables at a higher level.\n\n" + "*Key Features:*\n" + "- Create and organize feature modules\n" + "- Group work items by module\n" + "- Track module progress and completion\n" + "- Manage module leads and assignments\n\n" + "*Use Cases:* Feature planning, release organization, progress tracking, team coordination." + ), + }, + # Core Project Management + { + "name": "Projects", + "description": ( + "**Project Management**\n\n" + "Create and manage projects to organize your development work. Configure project settings, " + "manage team access, and control project visibility.\n\n" + "*Key Features:*\n" + "- Create, update, and delete projects\n" + "- Configure project settings and preferences\n" + "- Manage team access and permissions\n" + "- Control project visibility and sharing\n\n" + "*Use Cases:* Project setup, team collaboration, access control, project configuration." + ), + }, + # Project Organization + { + "name": "States", + "description": ( + "**Workflow States**\n\n" + "Define custom workflow states for work items to match your team's process. " + "Configure state transitions and track work item progress through different stages.\n\n" + "*Key Features:*\n" + "- Create custom workflow states\n" + "- Configure state transitions and rules\n" + "- Track work item progress through states\n" + "- Set state-based permissions and automation\n\n" + "*Use Cases:* Custom workflows, status tracking, process automation, progress monitoring." + ), + }, + # Team & User Management + { + "name": "Users", + "description": ( + "**Current User Information**\n\n" + "Get information about the currently authenticated user including profile details " + "and account settings.\n\n" + "*Key Features:*\n" + "- Retrieve current user profile\n" + "- Access user account information\n" + "- View user preferences and settings\n" + "- Get authentication context\n\n" + "*Use Cases:* Profile display, user context, account information, authentication status." + ), + }, + # Work Item Management + { + "name": "Work Item Activity", + "description": ( + "**Activity History & Search**\n\n" + "View activity history and search for work items across the workspace. " + "Get detailed activity logs and find work items using text search.\n\n" + "*Key Features:*\n" + "- View work item activity history\n" + "- Search work items across workspace\n" + "- Track changes and modifications\n" + "- Filter search results by project\n\n" + "*Use Cases:* Activity tracking, work item discovery, change history, workspace search." + ), + }, + { + "name": "Work Item Attachments", + "description": ( + "**Work Item File Attachments**\n\n" + "Generate presigned URLs for uploading files directly to specific work items. " + "Upload and manage attachments associated with work items.\n\n" + "*Key Features:*\n" + "- Generate presigned URLs for work item attachments\n" + "- Upload files directly to work items\n" + "- Retrieve and manage attachment metadata\n" + "- Delete attachments from work items\n\n" + "*Use Cases:* Screenshots, error logs, design files, supporting documents." + ), + }, + { + "name": "Work Item Comments", + "description": ( + "**Comments & Discussions**\n\n" + "Add comments and discussions to work items for team collaboration. " + "Support threaded conversations, mentions, and rich text formatting.\n\n" + "*Key Features:*\n" + "- Add comments to work items\n" + "- Thread conversations and replies\n" + "- Mention users and trigger notifications\n" + "- Rich text and markdown support\n\n" + "*Use Cases:* Team discussions, progress updates, code reviews, decision tracking." + ), + }, + { + "name": "Work Item Links", + "description": ( + "**External Links & References**\n\n" + "Link work items to external resources like documentation, repositories, or design files. " + "Maintain connections between work items and external systems.\n\n" + "*Key Features:*\n" + "- Add external URL links to work items\n" + "- Validate and preview linked resources\n" + "- Organize links by type and category\n" + "- Track link usage and access\n\n" + "*Use Cases:* Documentation links, repository connections, design references, external tools." + ), + }, + { + "name": "Work Items", + "description": ( + "**Work Items & Tasks**\n\n" + "Create and manage work items like tasks, bugs, features, and user stories. " + "The core entities for tracking work in your projects.\n\n" + "*Key Features:*\n" + "- Create, update, and manage work items\n" + "- Assign to team members and set priorities\n" + "- Track progress through workflow states\n" + "- Set due dates, estimates, and relationships\n\n" + "*Use Cases:* Bug tracking, task management, feature development, sprint planning." + ), + }, + ], + # ======================================================================== + # Security & Authentication + # ======================================================================== + "AUTHENTICATION_WHITELIST": [ + "plane.api.middleware.api_authentication.APIKeyAuthentication", + ], + # ======================================================================== + # Schema Generation Options + # ======================================================================== + "COMPONENT_NO_READ_ONLY_REQUIRED": True, + "COMPONENT_SPLIT_REQUEST": True, + "ENUM_NAME_OVERRIDES": { + "ModuleStatusEnum": "plane.db.models.module.ModuleStatus", + "IntakeWorkItemStatusEnum": "plane.db.models.intake.IntakeIssueStatus", + }, +} diff --git a/apps/api/plane/settings/production.py b/apps/api/plane/settings/production.py new file mode 100644 index 00000000..4725db38 --- /dev/null +++ b/apps/api/plane/settings/production.py @@ -0,0 +1,90 @@ +"""Production settings""" + +import os + +from .common import * # noqa + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = int(os.environ.get("DEBUG", 0)) == 1 + +# Honor the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +INSTALLED_APPS += ("scout_apm.django",) # noqa + + +# Scout Settings +SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) +SCOUT_KEY = os.environ.get("SCOUT_KEY", "") +SCOUT_NAME = "Plane" + +LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa + +if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + +# Logging configuration +LOGGING = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "verbose": {"format": "%(asctime)s [%(process)d] %(levelname)s %(name)s: %(message)s"}, + "json": { + "()": "pythonjsonlogger.jsonlogger.JsonFormatter", + "fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "json", + "level": "INFO", + }, + "file": { + "class": "plane.utils.logging.SizedTimedRotatingFileHandler", + "filename": ( + os.path.join(BASE_DIR, "logs", "plane-debug.log") # noqa + if DEBUG + else os.path.join(BASE_DIR, "logs", "plane-error.log") # noqa + ), + "when": "s", + "maxBytes": 1024 * 1024 * 1, + "interval": 1, + "backupCount": 5, + "formatter": "json", + "level": "DEBUG" if DEBUG else "ERROR", + }, + }, + "loggers": { + "plane.api.request": { + "level": "DEBUG" if DEBUG else "INFO", + "handlers": ["console"], + "propagate": False, + }, + "plane.api": { + "level": "DEBUG" if DEBUG else "INFO", + "handlers": ["console"], + "propagate": False, + }, + "plane.worker": { + "level": "DEBUG" if DEBUG else "INFO", + "handlers": ["console"], + "propagate": False, + }, + "plane.exception": { + "level": "DEBUG" if DEBUG else "ERROR", + "handlers": ["console", "file"], + "propagate": False, + }, + "plane.external": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, + "plane.mongo": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, + }, +} diff --git a/apps/api/plane/settings/redis.py b/apps/api/plane/settings/redis.py new file mode 100644 index 00000000..628a3d8e --- /dev/null +++ b/apps/api/plane/settings/redis.py @@ -0,0 +1,20 @@ +import redis +from django.conf import settings +from urllib.parse import urlparse + + +def redis_instance(): + # connect to redis + if settings.REDIS_SSL: + url = urlparse(settings.REDIS_URL) + ri = redis.Redis( + host=url.hostname, + port=url.port, + password=url.password, + ssl=True, + ssl_cert_reqs=None, + ) + else: + ri = redis.Redis.from_url(settings.REDIS_URL, db=0) + + return ri diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py new file mode 100644 index 00000000..0a072008 --- /dev/null +++ b/apps/api/plane/settings/storage.py @@ -0,0 +1,160 @@ +# Python imports +import os +import uuid + +# 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": + # Determine protocol based on environment variable + if os.environ.get("MINIO_ENDPOINT_SSL") == "1": + endpoint_protocol = "https" + else: + endpoint_protocol = request.scheme if request else "http" + # 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"{endpoint_protocol}://{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 is None: + filename = uuid.uuid4().hex + + 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", {}), + } + + def copy_object(self, object_name, new_object_name): + """Copy an S3 object to a new location""" + try: + response = self.s3_client.copy_object( + Bucket=self.aws_storage_bucket_name, + CopySource={"Bucket": self.aws_storage_bucket_name, "Key": object_name}, + Key=new_object_name, + ) + except ClientError as e: + log_exception(e) + return None + + return response diff --git a/apps/api/plane/settings/test.py b/apps/api/plane/settings/test.py new file mode 100644 index 00000000..6a75f790 --- /dev/null +++ b/apps/api/plane/settings/test.py @@ -0,0 +1,12 @@ +"""Test Settings""" + +from .common import * # noqa + +DEBUG = True + +# Send it in a dummy outbox +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + +INSTALLED_APPS.append( # noqa + "plane.tests" +) diff --git a/apps/api/plane/space/__init__.py b/apps/api/plane/space/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/space/apps.py b/apps/api/plane/space/apps.py new file mode 100644 index 00000000..6f1e76c5 --- /dev/null +++ b/apps/api/plane/space/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SpaceConfig(AppConfig): + name = "plane.space" diff --git a/apps/api/plane/space/serializer/__init__.py b/apps/api/plane/space/serializer/__init__.py new file mode 100644 index 00000000..a3fe1029 --- /dev/null +++ b/apps/api/plane/space/serializer/__init__.py @@ -0,0 +1,5 @@ +from .user import UserLiteSerializer + +from .issue import LabelLiteSerializer, IssuePublicSerializer + +from .state import StateSerializer diff --git a/apps/api/plane/space/serializer/base.py b/apps/api/plane/space/serializer/base.py new file mode 100644 index 00000000..4b92b06f --- /dev/null +++ b/apps/api/plane/space/serializer/base.py @@ -0,0 +1,58 @@ +from rest_framework import serializers + + +class BaseSerializer(serializers.ModelSerializer): + id = serializers.PrimaryKeyRelatedField(read_only=True) + + +class DynamicBaseSerializer(BaseSerializer): + def __init__(self, *args, **kwargs): + # If 'fields' is provided in the arguments, remove it and store it separately. + # This is done so as not to pass this custom argument up to the superclass. + fields = kwargs.pop("fields", None) + + # Call the initialization of the superclass. + super().__init__(*args, **kwargs) + + # If 'fields' was provided, filter the fields of the serializer accordingly. + if fields is not None: + self.fields = self._filter_fields(fields) + + def _filter_fields(self, fields): + """ + Adjust the serializer's fields based on the provided 'fields' list. + + :param fields: List or dictionary specifying which fields to include in the serializer. + :return: The updated fields for the serializer. + """ + # Check each field_name in the provided fields. + for field_name in fields: + # If the field is a dictionary (indicating nested fields), + # loop through its keys and values. + if isinstance(field_name, dict): + for key, value in field_name.items(): + # If the value of this nested field is a list, + # perform a recursive filter on it. + if isinstance(value, list): + self._filter_fields(self.fields[key], value) + + # Create a list to store allowed fields. + allowed = [] + for item in fields: + # If the item is a string, it directly represents a field's name. + if isinstance(item, str): + allowed.append(item) + # If the item is a dictionary, it represents a nested field. + # Add the key of this dictionary to the allowed list. + elif isinstance(item, dict): + allowed.append(list(item.keys())[0]) + + # Convert the current serializer's fields and the allowed fields to sets. + existing = set(self.fields) + allowed = set(allowed) + + # Remove fields from the serializer that aren't in the 'allowed' list. + for field_name in existing - allowed: + self.fields.pop(field_name) + + return self.fields diff --git a/apps/api/plane/space/serializer/cycle.py b/apps/api/plane/space/serializer/cycle.py new file mode 100644 index 00000000..afa760a5 --- /dev/null +++ b/apps/api/plane/space/serializer/cycle.py @@ -0,0 +1,17 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import Cycle + + +class CycleBaseSerializer(BaseSerializer): + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apps/api/plane/space/serializer/intake.py b/apps/api/plane/space/serializer/intake.py new file mode 100644 index 00000000..444c20d4 --- /dev/null +++ b/apps/api/plane/space/serializer/intake.py @@ -0,0 +1,41 @@ +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .state import StateLiteSerializer +from .project import ProjectLiteSerializer +from .issue import IssueFlatSerializer, LabelLiteSerializer +from plane.db.models import Issue, IntakeIssue + + +class IntakeIssueSerializer(BaseSerializer): + issue_detail = IssueFlatSerializer(source="issue", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = IntakeIssue + fields = "__all__" + read_only_fields = ["project", "workspace"] + + +class IntakeIssueLiteSerializer(BaseSerializer): + class Meta: + model = IntakeIssue + fields = ["id", "status", "duplicate_to", "snoozed_till", "source"] + read_only_fields = fields + + +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) + sub_issues_count = serializers.IntegerField(read_only=True) + bridge_id = serializers.UUIDField(read_only=True) + issue_intake = IntakeIssueLiteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" diff --git a/apps/api/plane/space/serializer/issue.py b/apps/api/plane/space/serializer/issue.py new file mode 100644 index 00000000..a89846cf --- /dev/null +++ b/apps/api/plane/space/serializer/issue.py @@ -0,0 +1,452 @@ +# Django imports +from django.utils import timezone + +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .state import StateSerializer, StateLiteSerializer +from .project import ProjectLiteSerializer +from .cycle import CycleBaseSerializer +from .module import ModuleBaseSerializer +from .workspace import WorkspaceLiteSerializer +from plane.db.models import ( + User, + Issue, + IssueComment, + IssueAssignee, + IssueLabel, + Label, + CycleIssue, + ModuleIssue, + IssueLink, + FileAsset, + IssueReaction, + CommentReaction, + IssueVote, + IssueRelation, +) +from plane.utils.content_validator import ( + validate_html_content, + validate_binary_data, +) + + +class IssueStateFlatSerializer(BaseSerializer): + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + + class Meta: + model = Issue + fields = ["id", "sequence_id", "name", "state_detail", "project_detail"] + + +class LabelSerializer(BaseSerializer): + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = Label + fields = "__all__" + read_only_fields = ["workspace", "project"] + + +class IssueProjectLiteSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = Issue + fields = ["id", "project_detail", "name", "sequence_id"] + read_only_fields = fields + + +class IssueRelationSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") + + class Meta: + model = IssueRelation + fields = ["issue_detail", "relation_type", "related_issue", "issue", "id"] + read_only_fields = ["workspace", "project"] + + +class RelatedIssueSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") + + class Meta: + model = IssueRelation + fields = ["issue_detail", "relation_type", "related_issue", "issue", "id"] + read_only_fields = ["workspace", "project"] + + +class IssueCycleDetailSerializer(BaseSerializer): + cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueModuleDetailSerializer(BaseSerializer): + module_detail = ModuleBaseSerializer(read_only=True, source="module") + + class Meta: + model = ModuleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueLinkSerializer(BaseSerializer): + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + + class Meta: + model = IssueLink + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "issue", + ] + + # Validation if url already exists + def create(self, validated_data): + if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists(): + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) + return IssueLink.objects.create(**validated_data) + + +class IssueAttachmentSerializer(BaseSerializer): + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = [ + "created_by", + "updated_by", + "created_at", + "updated_at", + "workspace", + "project", + "issue", + ] + + +class IssueReactionSerializer(BaseSerializer): + class Meta: + model = IssueReaction + fields = ["issue", "reaction", "workspace", "project", "actor"] + read_only_fields = ["workspace", "project", "issue", "actor"] + + +class IssueSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateSerializer(read_only=True, source="state") + parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") + label_details = LabelSerializer(read_only=True, source="labels", many=True) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) + issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True) + issue_cycle = IssueCycleDetailSerializer(read_only=True) + issue_module = IssueModuleDetailSerializer(read_only=True) + issue_link = IssueLinkSerializer(read_only=True, many=True) + issue_attachment = IssueAttachmentSerializer(read_only=True, many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + issue_reactions = IssueReactionSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueFlatSerializer(BaseSerializer): + ## Contain only flat fields + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description", + "description_html", + "priority", + "start_date", + "target_date", + "sequence_id", + "sort_order", + "is_draft", + ] + + +class CommentReactionLiteSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = CommentReaction + fields = ["id", "reaction", "comment", "actor_detail"] + + +class IssueCommentSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) + is_member = serializers.BooleanField(read_only=True) + + class Meta: + model = IssueComment + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +##TODO: Find a better way to write this serializer +## Find a better approach to save manytomany? +class IssueCreateSerializer(BaseSerializer): + state_detail = StateSerializer(read_only=True, source="state") + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + + assignees = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + labels = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + data["assignees"] = [str(assignee.id) for assignee in instance.assignees.all()] + data["labels"] = [str(label.id) for label in instance.labels.all()] + return data + + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed target date") + + # Validate description content for security + if "description_html" in data and data["description_html"]: + is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"]) + if not is_valid: + raise serializers.ValidationError({"error": "html content is not valid"}) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html + + if "description_binary" in data and data["description_binary"]: + is_valid, error_msg = validate_binary_data(data["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) + + return data + + def create(self, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + project_id = self.context["project_id"] + workspace_id = self.context["workspace_id"] + default_assignee_id = self.context["default_assignee_id"] + + issue = Issue.objects.create(**validated_data, project_id=project_id) + + # Issue Audit Users + created_by_id = issue.created_by_id + updated_by_id = issue.updated_by_id + + if assignees is not None and len(assignees): + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ) + else: + # Then assign it to default assignee + if default_assignee_id is not None: + IssueAssignee.objects.create( + assignee_id=default_assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + + if labels is not None and len(labels): + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label=label, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + return issue + + def update(self, instance, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + # Related models + project_id = instance.project_id + workspace_id = instance.workspace_id + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + + if assignees is not None: + IssueAssignee.objects.filter(issue=instance).delete() + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ) + + if labels is not None: + IssueLabel.objects.filter(issue=instance).delete() + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label=label, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + # Time updation occues even when other related models are updated + instance.updated_at = timezone.now() + return super().update(instance, validated_data) + + +class CommentReactionSerializer(BaseSerializer): + class Meta: + model = CommentReaction + fields = "__all__" + read_only_fields = ["workspace", "project", "comment", "actor"] + + +class IssueVoteSerializer(BaseSerializer): + class Meta: + model = IssueVote + fields = ["issue", "vote", "workspace", "project", "actor"] + read_only_fields = fields + + +class IssuePublicSerializer(BaseSerializer): + reactions = IssueReactionSerializer(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) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "sequence_id", + "state", + "project", + "workspace", + "priority", + "target_date", + "reactions", + "votes", + "module_ids", + "created_by", + "label_ids", + "assignee_ids", + ] + read_only_fields = fields + + +class LabelLiteSerializer(BaseSerializer): + class Meta: + model = Label + fields = ["id", "name", "color"] diff --git a/apps/api/plane/space/serializer/module.py b/apps/api/plane/space/serializer/module.py new file mode 100644 index 00000000..53840f07 --- /dev/null +++ b/apps/api/plane/space/serializer/module.py @@ -0,0 +1,17 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import Module + + +class ModuleBaseSerializer(BaseSerializer): + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apps/api/plane/space/serializer/project.py b/apps/api/plane/space/serializer/project.py new file mode 100644 index 00000000..f79eef68 --- /dev/null +++ b/apps/api/plane/space/serializer/project.py @@ -0,0 +1,18 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import Project + + +class ProjectLiteSerializer(BaseSerializer): + class Meta: + model = Project + fields = [ + "id", + "identifier", + "name", + "cover_image", + "icon_prop", + "emoji", + "description", + ] + read_only_fields = fields diff --git a/apps/api/plane/space/serializer/state.py b/apps/api/plane/space/serializer/state.py new file mode 100644 index 00000000..184f48b4 --- /dev/null +++ b/apps/api/plane/space/serializer/state.py @@ -0,0 +1,17 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import State + + +class StateSerializer(BaseSerializer): + class Meta: + model = State + fields = "__all__" + read_only_fields = ["workspace", "project"] + + +class StateLiteSerializer(BaseSerializer): + class Meta: + model = State + fields = ["id", "name", "color", "group"] + read_only_fields = fields diff --git a/apps/api/plane/space/serializer/user.py b/apps/api/plane/space/serializer/user.py new file mode 100644 index 00000000..9b707a34 --- /dev/null +++ b/apps/api/plane/space/serializer/user.py @@ -0,0 +1,18 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import User + + +class UserLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "avatar", + "avatar_url", + "is_bot", + "display_name", + ] + read_only_fields = ["id", "is_bot"] diff --git a/apps/api/plane/space/serializer/workspace.py b/apps/api/plane/space/serializer/workspace.py new file mode 100644 index 00000000..4945af96 --- /dev/null +++ b/apps/api/plane/space/serializer/workspace.py @@ -0,0 +1,10 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import Workspace + + +class WorkspaceLiteSerializer(BaseSerializer): + class Meta: + model = Workspace + fields = ["name", "slug", "id"] + read_only_fields = fields diff --git a/apps/api/plane/space/urls/__init__.py b/apps/api/plane/space/urls/__init__.py new file mode 100644 index 00000000..d9a1f6ec --- /dev/null +++ b/apps/api/plane/space/urls/__init__.py @@ -0,0 +1,7 @@ +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 = [*intake_urls, *issue_urls, *project_urls, *asset_urls] diff --git a/apps/api/plane/space/urls/asset.py b/apps/api/plane/space/urls/asset.py new file mode 100644 index 00000000..2a5c30a2 --- /dev/null +++ b/apps/api/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/apps/api/plane/space/urls/intake.py b/apps/api/plane/space/urls/intake.py new file mode 100644 index 00000000..59fda12e --- /dev/null +++ b/apps/api/plane/space/urls/intake.py @@ -0,0 +1,31 @@ +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/apps/api/plane/space/urls/issue.py b/apps/api/plane/space/urls/issue.py new file mode 100644 index 00000000..bb63e669 --- /dev/null +++ b/apps/api/plane/space/urls/issue.py @@ -0,0 +1,53 @@ +from django.urls import path + + +from plane.space.views import ( + IssueRetrievePublicEndpoint, + IssueCommentPublicViewSet, + IssueReactionPublicViewSet, + CommentReactionPublicViewSet, + IssueVotePublicViewSet, +) + +urlpatterns = [ + path( + "anchor//issues//", + IssueRetrievePublicEndpoint.as_view(), + name="workspace-project-boards", + ), + path( + "anchor//issues//comments/", + 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"}), + name="issue-comments-project-board", + ), + path( + "anchor//issues//reactions/", + IssueReactionPublicViewSet.as_view({"get": "list", "post": "create"}), + name="issue-reactions-project-board", + ), + path( + "anchor//issues//reactions//", + IssueReactionPublicViewSet.as_view({"delete": "destroy"}), + name="issue-reactions-project-board", + ), + path( + "anchor//comments//reactions/", + CommentReactionPublicViewSet.as_view({"get": "list", "post": "create"}), + name="comment-reactions-project-board", + ), + path( + "anchor//comments//reactions//", + CommentReactionPublicViewSet.as_view({"delete": "destroy"}), + name="comment-reactions-project-board", + ), + path( + "anchor//issues//votes/", + IssueVotePublicViewSet.as_view({"get": "list", "post": "create", "delete": "destroy"}), + name="issue-vote-project-board", + ), +] diff --git a/apps/api/plane/space/urls/project.py b/apps/api/plane/space/urls/project.py new file mode 100644 index 00000000..068b8c5c --- /dev/null +++ b/apps/api/plane/space/urls/project.py @@ -0,0 +1,62 @@ +from django.urls import path + + +from plane.space.views import ( + ProjectDeployBoardPublicSettingsEndpoint, + ProjectIssuesPublicEndpoint, + WorkspaceProjectAnchorEndpoint, + ProjectCyclesEndpoint, + ProjectModulesEndpoint, + ProjectStatesEndpoint, + ProjectLabelsEndpoint, + ProjectMembersEndpoint, + ProjectMetaDataEndpoint, +) + +urlpatterns = [ + path( + "anchor//meta/", + ProjectMetaDataEndpoint.as_view(), + name="project-meta", + ), + path( + "anchor//settings/", + ProjectDeployBoardPublicSettingsEndpoint.as_view(), + name="project-deploy-board-settings", + ), + path( + "anchor//issues/", + ProjectIssuesPublicEndpoint.as_view(), + name="project-deploy-board", + ), + path( + "workspaces//projects//anchor/", + WorkspaceProjectAnchorEndpoint.as_view(), + name="project-deploy-board", + ), + path( + "anchor//cycles/", + ProjectCyclesEndpoint.as_view(), + name="project-cycles", + ), + path( + "anchor//modules/", + ProjectModulesEndpoint.as_view(), + name="project-modules", + ), + path( + "anchor//states/", + ProjectStatesEndpoint.as_view(), + name="project-states", + ), + path( + "anchor//labels/", + ProjectLabelsEndpoint.as_view(), + name="project-labels", + ), + path( + "anchor//members/", + ProjectMembersEndpoint.as_view(), + name="project-members", + ), +] diff --git a/apps/api/plane/space/utils/grouper.py b/apps/api/plane/space/utils/grouper.py new file mode 100644 index 00000000..f8e2c50a --- /dev/null +++ b/apps/api/plane/space/utils/grouper.py @@ -0,0 +1,247 @@ +# 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, CharField +from django.db.models.functions import Coalesce, JSONObject, Concat +from django.db.models import QuerySet + +from typing import List, Optional, Dict, Any, Union + +# Module imports +from plane.db.models import ( + Cycle, + Issue, + Label, + Module, + Project, + ProjectMember, + State, + WorkspaceMember, +) + + +def issue_queryset_grouper( + queryset: QuerySet[Issue], group_by: Optional[str], sub_group_by: Optional[str] +) -> QuerySet[Issue]: + FIELD_MAPPER = { + "label_ids": "labels__id", + "assignee_ids": "assignees__id", + "module_ids": "issue_module__module_id", + } + + GROUP_FILTER_MAPPER = { + "assignees__id": Q(issue_assignee__deleted_at__isnull=True), + "labels__id": Q(label_issue__deleted_at__isnull=True), + "issue_module__module_id": Q(issue_module__deleted_at__isnull=True), + } + + for group_key in [group_by, sub_group_by]: + if group_key in GROUP_FILTER_MAPPER: + queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key]) + + annotations_map = { + "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), + ), + } + default_annotations = { + key: Coalesce( + 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 + } + + return queryset.annotate(**default_annotations) + + +def issue_on_results( + issues: QuerySet[Issue], group_by: Optional[str], sub_group_by: Optional[str] +) -> List[Dict[str, Any]]: + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "issue_module__module_id": "module_ids", + } + + original_list = ["assignee_ids", "label_ids", "module_ids"] + + required_fields = [ + "id", + "name", + "state_id", + "sort_order", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "created_by", + "state__group", + ] + + if group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[group_by]) + original_list.append(group_by) + + if sub_group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[sub_group_by]) + original_list.append(sub_group_by) + + required_fields.extend(original_list) + + issues = issues.annotate( + vote_items=ArrayAgg( + Case( + When( + votes__isnull=False, + votes__deleted_at__isnull=True, + then=JSONObject( + vote=F("votes__vote"), + actor_details=JSONObject( + id=F("votes__actor__id"), + 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=Q(votes__isnull=False, votes__deleted_at__isnull=True), + distinct=True, + ), + reaction_items=ArrayAgg( + Case( + When( + issue_reactions__isnull=False, + issue_reactions__deleted_at__isnull=True, + then=JSONObject( + 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"), + 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"), + ), + ), + ), + default=None, + output_field=JSONField(), + ), + filter=Q(issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True), + distinct=True, + ), + ).values(*required_fields, "vote_items", "reaction_items") + + return issues + + +def issue_group_values( + field: str, + slug: str, + project_id: Optional[str] = None, + filters: Dict[str, Any] = {}, + queryset: Optional[QuerySet] = None, +) -> List[Union[str, Any]]: + if field == "state_id": + queryset = State.objects.filter(is_triage=False, workspace__slug=slug).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + if field == "labels__id": + queryset = Label.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 == "assignees__id": + if project_id: + return ProjectMember.objects.filter( + workspace__slug=slug, project_id=project_id, is_active=True + ).values_list("member_id", flat=True) + else: + return list( + WorkspaceMember.objects.filter(workspace__slug=slug, is_active=True).values_list("member_id", flat=True) + ) + if field == "issue_module__module_id": + 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) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "project_id": + queryset = Project.objects.filter(workspace__slug=slug).values_list("id", flat=True) + return list(queryset) + if field == "priority": + return ["low", "medium", "high", "urgent", "none"] + if field == "state__group": + return ["backlog", "unstarted", "started", "completed", "cancelled"] + if field == "target_date": + queryset = queryset.values_list("target_date", flat=True).distinct() + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + if field == "start_date": + queryset = queryset.values_list("start_date", flat=True).distinct() + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + + if field == "created_by": + queryset = queryset.values_list("created_by", flat=True).distinct() + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + + return [] diff --git a/apps/api/plane/space/views/__init__.py b/apps/api/plane/space/views/__init__.py new file mode 100644 index 00000000..22acfd15 --- /dev/null +++ b/apps/api/plane/space/views/__init__.py @@ -0,0 +1,29 @@ +from .project import ( + ProjectDeployBoardPublicSettingsEndpoint, + WorkspaceProjectDeployBoardEndpoint, + WorkspaceProjectAnchorEndpoint, + ProjectMembersEndpoint, +) + +from .issue import ( + IssueCommentPublicViewSet, + IssueReactionPublicViewSet, + CommentReactionPublicViewSet, + IssueVotePublicViewSet, + IssueRetrievePublicEndpoint, + ProjectIssuesPublicEndpoint, +) + +from .intake import IntakeIssuePublicViewSet + +from .cycle import ProjectCyclesEndpoint + +from .module import ProjectModulesEndpoint + +from .state import ProjectStatesEndpoint + +from .label import ProjectLabelsEndpoint + +from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint + +from .meta import ProjectMetaDataEndpoint diff --git a/apps/api/plane/space/views/asset.py b/apps/api/plane/space/views/asset.py new file mode 100644 index 00000000..6ed5ab9b --- /dev/null +++ b/apps/api/plane/space/views/asset.py @@ -0,0 +1,221 @@ +# 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).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", + "image/jpg", + "image/gif", + ] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF 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).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/apps/api/plane/space/views/base.py b/apps/api/plane/space/views/base.py new file mode 100644 index 00000000..9be6a2e1 --- /dev/null +++ b/apps/api/plane/space/views/base.py @@ -0,0 +1,204 @@ +# Python imports +import zoneinfo +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import IntegrityError + +# Django imports +from django.urls import resolve +from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend + +# Third part imports +from rest_framework import status +from rest_framework.exceptions import APIException +from rest_framework.filters import SearchFilter +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import ModelViewSet + +# Module imports +from plane.utils.exception_logger import log_exception +from plane.utils.paginator import BasePaginator +from plane.authentication.session import BaseSessionAuthentication + + +class TimezoneMixin: + """ + This enables timezone conversion according + to the user set timezone + """ + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if request.user.is_authenticated: + timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone)) + else: + timezone.deactivate() + + +class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): + model = None + + permission_classes = [IsAuthenticated] + + filter_backends = (DjangoFilterBackend, SearchFilter) + + authentication_classes = [BaseSessionAuthentication] + + filterset_fields = [] + + search_fields = [] + + def get_queryset(self): + try: + return self.model.objects.all() + except Exception as e: + log_exception(e) + raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + log_exception(e) + return Response( + {"error": "The required key does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + log_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") + + return response + except Exception as exc: + response = self.handle_exception(exc) + return exc + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def project_id(self): + project_id = self.kwargs.get("project_id", None) + if project_id: + return project_id + + if resolve(self.request.path_info).url_name == "project": + return self.kwargs.get("pk", None) + + +class BaseAPIView(TimezoneMixin, APIView, BasePaginator): + permission_classes = [IsAuthenticated] + + filter_backends = (DjangoFilterBackend, SearchFilter) + + filterset_fields = [] + + search_fields = [] + + authentication_classes = [BaseSessionAuthentication] + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + return Response( + {"error": "The required key does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + log_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") + return response + + except Exception as exc: + response = self.handle_exception(exc) + return exc + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def project_id(self): + return self.kwargs.get("project_id", None) diff --git a/apps/api/plane/space/views/cycle.py b/apps/api/plane/space/views/cycle.py new file mode 100644 index 00000000..505c17ba --- /dev/null +++ b/apps/api/plane/space/views/cycle.py @@ -0,0 +1,24 @@ +# Third Party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import DeployBoard, Cycle + + +class ProjectCyclesEndpoint(BaseAPIView): + 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) + + cycles = Cycle.objects.filter( + workspace__slug=deploy_board.workspace.slug, + project_id=deploy_board.project_id, + ).values("id", "name") + + return Response(cycles, status=status.HTTP_200_OK) diff --git a/apps/api/plane/space/views/intake.py b/apps/api/plane/space/views/intake.py new file mode 100644 index 00000000..60d4443b --- /dev/null +++ b/apps/api/plane/space/views/intake.py @@ -0,0 +1,259 @@ +# Python imports +import json + +# Django import +from django.utils import timezone +from django.db.models import Q, OuterRef, Func, F, Prefetch +from django.core.serializers.json import DjangoJSONEncoder + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from .base import BaseViewSet +from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard +from plane.app.serializers import ( + IssueSerializer, + IntakeIssueSerializer, + IssueCreateSerializer, + IssueStateIntakeSerializer, +) +from plane.utils.issue_filters import issue_filters +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models.intake import SourceType + + +class IntakeIssuePublicViewSet(BaseViewSet): + serializer_class = IntakeIssueSerializer + model = IntakeIssue + + filterset_fields = ["status"] + + def get_queryset(self): + project_deploy_board = DeployBoard.objects.get( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + if project_deploy_board is not None: + return self.filter_queryset( + super() + .get_queryset() + .filter( + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + intake_id=self.kwargs.get("intake_id"), + ) + .select_related("issue", "workspace", "project") + ) + return IntakeIssue.objects.none() + + def list(self, request, anchor, intake_id): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + if project_deploy_board.intake is None: + return Response( + {"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_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_intake__id")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels") + .order_by("issue_intake__snoozed_till", "issue_intake__status") + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .prefetch_related( + Prefetch( + "issue_intake", + queryset=IntakeIssue.objects.only("status", "duplicate_to", "snoozed_till", "source"), + ) + ) + ) + issues_data = IssueStateIntakeSerializer(issues, many=True).data + return Response(issues_data, status=status.HTTP_200_OK) + + def create(self, request, anchor, intake_id): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + if project_deploy_board.intake is None: + return Response( + {"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) + + # Check for valid priority + if request.data.get("issue", {}).get("priority", "none") not in [ + "low", + "medium", + "high", + "urgent", + "none", + ]: + return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST) + + # create an issue + issue = Issue.objects.create( + name=request.data.get("issue", {}).get("name"), + description=request.data.get("issue", {}).get("description", {}), + description_html=request.data.get("issue", {}).get("description_html", "

    "), + priority=request.data.get("issue", {}).get("priority", "low"), + project_id=project_deploy_board.project_id, + ) + + # Create an Issue Activity + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_deploy_board.project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + # create an intake issue + IntakeIssue.objects.create( + intake_id=intake_id, + project_id=project_deploy_board.project_id, + issue=issue, + source=SourceType.IN_APP, + ) + + serializer = IssueStateIntakeSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, anchor, intake_id, pk): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + if project_deploy_board.intake is None: + return Response( + {"error": "Intake is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + intake_issue = IntakeIssue.objects.get( + pk=pk, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, + intake_id=intake_id, + ) + # Get the project member + if str(intake_issue.created_by_id) != str(request.user.id): + return Response( + {"error": "You cannot edit intake issues"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get issue data + issue_data = request.data.pop("issue", False) + + issue = Issue.objects.get( + pk=intake_issue.issue_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, + ) + # viewers and guests since only viewers and guests + issue_data = { + "name": issue_data.get("name", issue.name), + "description_html": issue_data.get("description_html", issue.description_html), + "description": issue_data.get("description", issue.description), + } + + issue_serializer = IssueCreateSerializer( + issue, + data=issue_data, + partial=True, + context={"project_id": project_deploy_board.project_id}, + ) + + if issue_serializer.is_valid(): + current_instance = issue + # Log all the updates + requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder) + if issue is not None: + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_deploy_board.project_id), + current_instance=json.dumps(IssueSerializer(current_instance).data, cls=DjangoJSONEncoder), + epoch=int(timezone.now().timestamp()), + ) + issue_serializer.save() + return Response(issue_serializer.data, status=status.HTTP_200_OK) + return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, anchor, intake_id, pk): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + if project_deploy_board.intake is None: + return Response( + {"error": "Intake is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + intake_issue = IntakeIssue.objects.get( + pk=pk, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, + intake_id=intake_id, + ) + issue = Issue.objects.get( + pk=intake_issue.issue_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, + ) + serializer = IssueStateIntakeSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, anchor, intake_id, pk): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + if project_deploy_board.intake is None: + return Response( + {"error": "Intake is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + intake_issue = IntakeIssue.objects.get( + pk=pk, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, + intake_id=intake_id, + ) + + if str(intake_issue.created_by_id) != str(request.user.id): + return Response( + {"error": "You cannot delete intake issue"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + intake_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py new file mode 100644 index 00000000..220fc130 --- /dev/null +++ b/apps/api/plane/space/views/issue.py @@ -0,0 +1,769 @@ +# Python imports +import json + +# Django imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce, JSONObject +from django.core.serializers.json import DjangoJSONEncoder +from django.utils import timezone +from django.db.models import ( + Exists, + F, + Q, + Prefetch, + UUIDField, + Case, + When, + JSONField, + Value, + OuterRef, + Func, + CharField, + Subquery, +) +from django.db.models.functions import Concat + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated + + +# Module imports +from .base import BaseAPIView, BaseViewSet + +# fetch the space app grouper function separately +from plane.space.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) + + +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator +from plane.app.serializers import ( + CommentReactionSerializer, + IssueCommentSerializer, + IssueReactionSerializer, + IssueVoteSerializer, +) +from plane.db.models import ( + Issue, + IssueComment, + IssueLink, + IssueReaction, + ProjectMember, + CommentReaction, + DeployBoard, + IssueVote, + ProjectPublicMember, + 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] + + def get(self, request, anchor): + filters = issue_filters(request.query_params, "GET") + order_by_param = request.GET.get("order_by", "-created_at") + + deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first() + if not deploy_board: + return Response({"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) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + .prefetch_related(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( + 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") + ) + ).distinct() + + 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 + ) + + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper(queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by) + + if group_by: + if sub_group_by: + if group_by == sub_group_by: + return Response( + {"error": "Group by and sub group by cannot have same parameters"}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_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, + ), + ) + 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_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, + ), + ) + else: + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), + ) + + +class IssueCommentPublicViewSet(BaseViewSet): + serializer_class = IssueCommentSerializer + model = IssueComment + + filterset_fields = ["issue__id", "workspace__id"] + + def get_permissions(self): + if self.action in ["list", "retrieve"]: + self.permission_classes = [AllowAny] + else: + self.permission_classes = [IsAuthenticated] + + return super(IssueCommentPublicViewSet, self).get_permissions() + + def get_queryset(self): + try: + project_deploy_board = DeployBoard.objects.get(anchor=self.kwargs.get("anchor"), entity_name="project") + if project_deploy_board.is_comments_enabled: + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace_id=project_deploy_board.workspace_id) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(access="EXTERNAL") + .select_related("project") + .select_related("workspace") + .select_related("issue") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, + member_id=self.request.user.id, + is_active=True, + ) + ) + ) + .distinct() + ).order_by("created_at") + return IssueComment.objects.none() + except DeployBoard.DoesNotExist: + return IssueComment.objects.none() + + def create(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + + if not project_deploy_board.is_comments_enabled: + return Response( + {"error": "Comments are not enabled for this project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueCommentSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_deploy_board.project_id, + issue_id=issue_id, + actor=request.user, + access="EXTERNAL", + ) + issue_activity.delay( + type="comment.activity.created", + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_deploy_board.project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + if not ProjectMember.objects.filter( + project_id=project_deploy_board.project_id, + member=request.user, + is_active=True, + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_deploy_board.project_id, member=request.user + ) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, anchor, issue_id, pk): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + + if not project_deploy_board.is_comments_enabled: + return Response( + {"error": "Comments are not enabled for this project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + comment = IssueComment.objects.get(pk=pk, actor=request.user) + serializer = IssueCommentSerializer(comment, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="comment.activity.updated", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_deploy_board.project_id), + current_instance=json.dumps(IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder), + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, anchor, issue_id, pk): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + + if not project_deploy_board.is_comments_enabled: + return Response( + {"error": "Comments are not enabled for this project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + comment = IssueComment.objects.get(pk=pk, actor=request.user) + issue_activity.delay( + type="comment.activity.deleted", + requested_data=json.dumps({"comment_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_deploy_board.project_id), + current_instance=json.dumps(IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder), + epoch=int(timezone.now().timestamp()), + ) + comment.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueReactionPublicViewSet(BaseViewSet): + serializer_class = IssueReactionSerializer + model = IssueReaction + + def get_queryset(self): + try: + project_deploy_board = DeployBoard.objects.get( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + if project_deploy_board.is_reactions_enabled: + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .order_by("-created_at") + .distinct() + ) + return IssueReaction.objects.none() + except DeployBoard.DoesNotExist: + return IssueReaction.objects.none() + + def create(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + + if not project_deploy_board.is_reactions_enabled: + return Response( + {"error": "Reactions are not enabled for this project board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueReactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_deploy_board.project_id, + issue_id=issue_id, + actor=request.user, + ) + if not ProjectMember.objects.filter( + project_id=project_deploy_board.project_id, + member=request.user, + is_active=True, + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + 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), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(project_deploy_board.project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, anchor, issue_id, reaction_code): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + + if not project_deploy_board.is_reactions_enabled: + return Response( + {"error": "Reactions are not enabled for this project board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + issue_reaction = IssueReaction.objects.get( + workspace_id=project_deploy_board.workspace_id, + issue_id=issue_id, + reaction=reaction_code, + actor=request.user, + ) + issue_activity.delay( + type="issue_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(project_deploy_board.project_id), + current_instance=json.dumps({"reaction": str(reaction_code), "identifier": str(issue_reaction.id)}), + epoch=int(timezone.now().timestamp()), + ) + issue_reaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CommentReactionPublicViewSet(BaseViewSet): + serializer_class = CommentReactionSerializer + model = CommentReaction + + def get_queryset(self): + try: + project_deploy_board = DeployBoard.objects.get(anchor=self.kwargs.get("anchor"), entity_name="project") + if project_deploy_board.is_reactions_enabled: + return ( + super() + .get_queryset() + .filter(workspace_id=project_deploy_board.workspace_id) + .filter(project_id=project_deploy_board.project_id) + .filter(comment_id=self.kwargs.get("comment_id")) + .order_by("-created_at") + .distinct() + ) + return CommentReaction.objects.none() + except DeployBoard.DoesNotExist: + return CommentReaction.objects.none() + + def create(self, request, anchor, comment_id): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + + if not project_deploy_board.is_reactions_enabled: + return Response( + {"error": "Reactions are not enabled for this board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = CommentReactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_deploy_board.project_id, + comment_id=comment_id, + actor=request.user, + ) + if not ProjectMember.objects.filter( + project_id=project_deploy_board.project_id, + member=request.user, + is_active=True, + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + 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), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, anchor, comment_id, reaction_code): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + if not project_deploy_board.is_reactions_enabled: + return Response( + {"error": "Reactions are not enabled for this board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + comment_reaction = CommentReaction.objects.get( + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + comment_id=comment_id, + reaction=reaction_code, + actor=request.user, + ) + issue_activity.delay( + type="comment_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(project_deploy_board.project_id), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(comment_reaction.id), + "comment_id": str(comment_id), + } + ), + epoch=int(timezone.now().timestamp()), + ) + comment_reaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueVotePublicViewSet(BaseViewSet): + model = IssueVote + serializer_class = IssueVoteSerializer + + def get_queryset(self): + try: + project_deploy_board = DeployBoard.objects.get( + workspace__slug=self.kwargs.get("anchor"), entity_name="project" + ) + if project_deploy_board.is_votes_enabled: + return ( + super() + .get_queryset() + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(workspace_id=project_deploy_board.workspace_id) + .filter(project_id=project_deploy_board.project_id) + ) + return IssueVote.objects.none() + except DeployBoard.DoesNotExist: + return IssueVote.objects.none() + + def create(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + issue_vote, _ = IssueVote.objects.get_or_create( + actor_id=request.user.id, + project_id=project_deploy_board.project_id, + issue_id=issue_id, + ) + # Add the user for workspace tracking + if not ProjectMember.objects.filter( + project_id=project_deploy_board.project_id, + member=request.user, + is_active=True, + ).exists(): + _ = ProjectPublicMember.objects.get_or_create( + 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), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(project_deploy_board.project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + serializer = IssueVoteSerializer(issue_vote) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def destroy(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + issue_vote = IssueVote.objects.get( + issue_id=issue_id, + actor_id=request.user.id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + ) + issue_activity.delay( + type="issue_vote.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(project_deploy_board.project_id), + current_instance=json.dumps({"vote": str(issue_vote.vote), "identifier": str(issue_vote.id)}), + epoch=int(timezone.now().timestamp()), + ) + issue_vote.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueRetrievePublicEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def get(self, request, anchor, issue_id): + deploy_board = DeployBoard.objects.get(anchor=anchor) + + issue_queryset = ( + Issue.issue_objects.filter( + pk=issue_id, + workspace__slug=deploy_board.workspace.slug, + project_id=deploy_board.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(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())), + ), + ) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("issue", "actor"), + ) + ) + .prefetch_related(Prefetch("votes", queryset=IssueVote.objects.select_related("actor"))) + .annotate( + vote_items=ArrayAgg( + Case( + When( + votes__isnull=False, + votes__deleted_at__isnull=True, + then=JSONObject( + vote=F("votes__vote"), + actor_details=JSONObject( + id=F("votes__actor__id"), + 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("/"), + ), + ), + 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"), + ), + ), + ), + default=None, + output_field=JSONField(), + ), + filter=Case( + When( + votes__isnull=False, + votes__deleted_at__isnull=True, + then=True, + ), + default=False, + output_field=JSONField(), + ), + distinct=True, + ), + reaction_items=ArrayAgg( + Case( + When( + issue_reactions__isnull=False, + issue_reactions__deleted_at__isnull=True, + then=JSONObject( + 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"), + 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"), + ), + ), + ), + default=None, + output_field=JSONField(), + ), + filter=Case( + When( + issue_reactions__isnull=False, + issue_reactions__deleted_at__isnull=True, + then=True, + ), + default=False, + output_field=JSONField(), + ), + distinct=True, + ), + ) + .values( + "id", + "name", + "state_id", + "sort_order", + "description", + "description_html", + "description_stripped", + "description_binary", + "module_ids", + "label_ids", + "assignee_ids", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "created_by", + "state__group", + "vote_items", + "reaction_items", + ) + ).first() + + return Response(issue_queryset, status=status.HTTP_200_OK) diff --git a/apps/api/plane/space/views/label.py b/apps/api/plane/space/views/label.py new file mode 100644 index 00000000..51ddb832 --- /dev/null +++ b/apps/api/plane/space/views/label.py @@ -0,0 +1,24 @@ +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny + +# Module imports +from .base import BaseAPIView +from plane.db.models import DeployBoard, Label + + +class ProjectLabelsEndpoint(BaseAPIView): + 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) + + labels = Label.objects.filter( + workspace__slug=deploy_board.workspace.slug, + project_id=deploy_board.project_id, + ).values("id", "name", "color", "parent") + + return Response(labels, status=status.HTTP_200_OK) diff --git a/apps/api/plane/space/views/meta.py b/apps/api/plane/space/views/meta.py new file mode 100644 index 00000000..be612db7 --- /dev/null +++ b/apps/api/plane/space/views/meta.py @@ -0,0 +1,28 @@ +# third party +from rest_framework.permissions import AllowAny +from rest_framework import status +from rest_framework.response import Response + +from plane.db.models import DeployBoard, Project + +from .base import BaseAPIView +from plane.space.serializer.project import ProjectLiteSerializer + + +class ProjectMetaDataEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def get(self, request, anchor): + try: + deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + except DeployBoard.DoesNotExist: + return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) + + try: + project_id = deploy_board.entity_identifier + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) + + serializer = ProjectLiteSerializer(project) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/space/views/module.py b/apps/api/plane/space/views/module.py new file mode 100644 index 00000000..7c4628f6 --- /dev/null +++ b/apps/api/plane/space/views/module.py @@ -0,0 +1,24 @@ +# Third Party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import DeployBoard, Module + + +class ProjectModulesEndpoint(BaseAPIView): + 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) + + modules = Module.objects.filter( + workspace__slug=deploy_board.workspace.slug, + project_id=deploy_board.project_id, + ).values("id", "name") + + return Response(modules, status=status.HTTP_200_OK) diff --git a/apps/api/plane/space/views/project.py b/apps/api/plane/space/views/project.py new file mode 100644 index 00000000..6f332781 --- /dev/null +++ b/apps/api/plane/space/views/project.py @@ -0,0 +1,80 @@ +# Django imports +from django.db.models import Exists, OuterRef + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny + +# Module imports +from .base import BaseAPIView +from plane.app.serializers import DeployBoardSerializer +from plane.db.models import Project, DeployBoard, ProjectMember + + +class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def get(self, request, anchor): + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + serializer = DeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def get(self, request, anchor): + deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").values_list + projects = ( + Project.objects.filter(workspace=deploy_board.workspace) + .annotate( + is_public=Exists( + DeployBoard.objects.filter(anchor=anchor, project_id=OuterRef("pk"), entity_name="project") + ) + ) + .filter(is_public=True) + ).values( + "id", + "identifier", + "name", + "description", + "emoji", + "icon_prop", + "cover_image", + ) + + return Response(projects, status=status.HTTP_200_OK) + + +class WorkspaceProjectAnchorEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def get(self, request, slug, project_id): + project_deploy_board = DeployBoard.objects.get( + workspace__slug=slug, project_id=project_id, entity_name="project" + ) + serializer = DeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class ProjectMembersEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def get(self, request, anchor): + deploy_board = DeployBoard.objects.filter(anchor=anchor).first() + + members = ProjectMember.objects.filter( + project=deploy_board.project, + workspace=deploy_board.workspace, + is_active=True, + ).values( + "id", + "member", + "member__first_name", + "member__last_name", + "member__display_name", + "project", + "workspace", + ) + return Response(members, status=status.HTTP_200_OK) diff --git a/apps/api/plane/space/views/state.py b/apps/api/plane/space/views/state.py new file mode 100644 index 00000000..c1318660 --- /dev/null +++ b/apps/api/plane/space/views/state.py @@ -0,0 +1,28 @@ +# Django imports +from django.db.models import Q + +# Third Party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import DeployBoard, State + + +class ProjectStatesEndpoint(BaseAPIView): + 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) + + states = State.objects.filter( + ~Q(name="Triage"), + workspace__slug=deploy_board.workspace.slug, + project_id=deploy_board.project_id, + ).values("name", "group", "color", "id", "sequence") + + return Response(states, status=status.HTTP_200_OK) diff --git a/apps/api/plane/static/css/style.css b/apps/api/plane/static/css/style.css new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/static/humans.txt b/apps/api/plane/static/humans.txt new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/static/js/script.js b/apps/api/plane/static/js/script.js new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/tests/README.md b/apps/api/plane/tests/README.md new file mode 100644 index 00000000..df9aba6d --- /dev/null +++ b/apps/api/plane/tests/README.md @@ -0,0 +1,143 @@ +# Plane Tests + +This directory contains tests for the Plane application. The tests are organized using pytest. + +## Test Structure + +Tests are organized into the following categories: + +- **Unit tests**: Test individual functions or classes in isolation. +- **Contract tests**: Test interactions between components and verify API contracts are fulfilled. + - **API tests**: Test the external API endpoints (under `/api/v1/`). + - **App tests**: Test the web application API endpoints (under `/api/`). +- **Smoke tests**: Basic tests to verify that the application runs correctly. + +## API vs App Endpoints + +Plane has two types of API endpoints: + +1. **External API** (`plane.api`): + - Available at `/api/v1/` endpoint + - Uses API key authentication (X-Api-Key header) + - Designed for external API contracts and third-party access + - Tests use the `api_key_client` fixture for authentication + - Test files are in `contract/api/` + +2. **Web App API** (`plane.app`): + - Available at `/api/` endpoint + - Uses session-based authentication (CSRF disabled) + - Designed for the web application frontend + - Tests use the `session_client` fixture for authentication + - Test files are in `contract/app/` + +## Running Tests + +To run all tests: + +```bash +python -m pytest +``` + +To run specific test categories: + +```bash +# Run unit tests +python -m pytest plane/tests/unit/ + +# Run API contract tests +python -m pytest plane/tests/contract/api/ + +# Run App contract tests +python -m pytest plane/tests/contract/app/ + +# Run smoke tests +python -m pytest plane/tests/smoke/ +``` + +For convenience, we also provide a helper script: + +```bash +# Run all tests +./run_tests.py + +# Run only unit tests +./run_tests.py -u + +# Run contract tests with coverage report +./run_tests.py -c -o + +# Run tests in parallel +./run_tests.py -p +``` + +## Fixtures + +The following fixtures are available for testing: + +- `api_client`: Unauthenticated API client +- `create_user`: Creates a test user +- `api_token`: API token for the test user +- `api_key_client`: API client with API key authentication (for external API tests) +- `session_client`: API client with session authentication (for app API tests) +- `plane_server`: Live Django test server for HTTP-based smoke tests + +## Writing Tests + +When writing tests, follow these guidelines: + +1. Place tests in the appropriate directory based on their type. +2. Use the correct client fixture based on the API being tested: + - For external API (`/api/v1/`), use `api_key_client` + - For web app API (`/api/`), use `session_client` + - For smoke tests with real HTTP, use `plane_server` +3. Use the correct URL namespace when reverse-resolving URLs: + - For external API, use `reverse("api:endpoint_name")` + - For web app API, use `reverse("endpoint_name")` +4. Add the `@pytest.mark.django_db` decorator to tests that interact with the database. +5. Add the appropriate markers (`@pytest.mark.contract`, etc.) to categorize tests. + +## Test Fixtures + +Common fixtures are defined in: + +- `conftest.py`: General fixtures for authentication, database access, etc. +- `conftest_external.py`: Fixtures for external services (Redis, Elasticsearch, Celery, MongoDB) +- `factories.py`: Test factories for easy model instance creation + +## Best Practices + +When writing tests, follow these guidelines: + +1. **Use pytest's assert syntax** instead of Django's `self.assert*` methods. +2. **Add markers to categorize tests**: + ```python + @pytest.mark.unit + @pytest.mark.contract + @pytest.mark.smoke + ``` +3. **Use fixtures instead of setUp/tearDown methods** for cleaner, more reusable test code. +4. **Mock external dependencies** with the provided fixtures to avoid external service dependencies. +5. **Write focused tests** that verify one specific behavior or edge case. +6. **Keep test files small and organized** by logical components or endpoints. +7. **Target 90% code coverage** for models, serializers, and business logic. + +## External Dependencies + +Tests for components that interact with external services should: + +1. Use the `mock_redis`, `mock_elasticsearch`, `mock_mongodb`, and `mock_celery` fixtures for unit and most contract tests. +2. For more comprehensive contract tests, use Docker-based test containers (optional). + +## Coverage Reports + +Generate a coverage report with: + +```bash +python -m pytest --cov=plane --cov-report=term --cov-report=html +``` + +This creates an HTML report in the `htmlcov/` directory. + +## Migration from Old Tests + +Some tests are still in the old format in the `api/` directory. These need to be migrated to the new contract test structure in the appropriate directories. \ No newline at end of file diff --git a/apps/api/plane/tests/TESTING_GUIDE.md b/apps/api/plane/tests/TESTING_GUIDE.md new file mode 100644 index 00000000..98f4a1db --- /dev/null +++ b/apps/api/plane/tests/TESTING_GUIDE.md @@ -0,0 +1,151 @@ +# Testing Guide for Plane + +This guide explains how to write tests for Plane using our pytest-based testing strategy. + +## Test Categories + +We divide tests into three categories: + +1. **Unit Tests**: Testing individual components in isolation. +2. **Contract Tests**: Testing API endpoints and verifying contracts between components. +3. **Smoke Tests**: Basic end-to-end tests for critical flows. + +## Writing Unit Tests + +Unit tests should be placed in the appropriate directory under `tests/unit/` depending on what you're testing: + +- `tests/unit/models/` - For model tests +- `tests/unit/serializers/` - For serializer tests +- `tests/unit/utils/` - For utility function tests + +### Example Unit Test: + +```python +import pytest +from plane.api.serializers import MySerializer + +@pytest.mark.unit +class TestMySerializer: + def test_serializer_valid_data(self): + # Create input data + data = {"field1": "value1", "field2": 42} + + # Initialize the serializer + serializer = MySerializer(data=data) + + # Validate + assert serializer.is_valid() + + # Check validated data + assert serializer.validated_data["field1"] == "value1" + assert serializer.validated_data["field2"] == 42 +``` + +## Writing Contract Tests + +Contract tests should be placed in `tests/contract/api/` or `tests/contract/app/` directories and should test the API endpoints. + +### Example Contract Test: + +```python +import pytest +from django.urls import reverse +from rest_framework import status + +@pytest.mark.contract +class TestMyEndpoint: + @pytest.mark.django_db + def test_my_endpoint_get(self, auth_client): + # Get the URL + url = reverse("my-endpoint") + + # Make request + response = auth_client.get(url) + + # Check response + assert response.status_code == status.HTTP_200_OK + assert "data" in response.data +``` + +## Writing Smoke Tests + +Smoke tests should be placed in `tests/smoke/` directory and use the `plane_server` fixture to test against a real HTTP server. + +### Example Smoke Test: + +```python +import pytest +import requests + +@pytest.mark.smoke +class TestCriticalFlow: + @pytest.mark.django_db + def test_login_flow(self, plane_server, create_user, user_data): + # Get login URL + url = f"{plane_server.url}/api/auth/signin/" + + # Test login + response = requests.post( + url, + json={ + "email": user_data["email"], + "password": user_data["password"] + } + ) + + # Verify + assert response.status_code == 200 + data = response.json() + assert "access_token" in data +``` + +## Useful Fixtures + +Our test setup provides several useful fixtures: + +1. `api_client`: An unauthenticated DRF APIClient +2. `api_key_client`: API client with API key authentication (for external API tests) +3. `session_client`: API client with session authentication (for web app API tests) +4. `create_user`: Creates and returns a test user +5. `mock_redis`: Mocks Redis interactions +6. `mock_elasticsearch`: Mocks Elasticsearch interactions +7. `mock_celery`: Mocks Celery task execution + +## Using Factory Boy + +For more complex test data setup, use the provided factories: + +```python +from plane.tests.factories import UserFactory, WorkspaceFactory + +# Create a user +user = UserFactory() + +# Create a workspace with a specific owner +workspace = WorkspaceFactory(owner=user) + +# Create multiple objects +users = UserFactory.create_batch(5) +``` + +## Running Tests + +Use pytest to run tests: + +```bash +# Run all tests +python -m pytest + +# Run only unit tests with coverage +python -m pytest -m unit --cov=plane +``` + +## Best Practices + +1. **Keep tests small and focused** - Each test should verify one specific behavior. +2. **Use markers** - Always add appropriate markers (`@pytest.mark.unit`, etc.). +3. **Mock external dependencies** - Use the provided mock fixtures. +4. **Use factories** - For complex data setup, use factories. +5. **Don't test the framework** - Focus on testing your business logic, not Django/DRF itself. +6. **Write readable assertions** - Use plain `assert` statements with clear messaging. +7. **Focus on coverage** - Aim for ≥90% code coverage for critical components. \ No newline at end of file diff --git a/apps/api/plane/tests/__init__.py b/apps/api/plane/tests/__init__.py new file mode 100644 index 00000000..73d90cd2 --- /dev/null +++ b/apps/api/plane/tests/__init__.py @@ -0,0 +1 @@ +# Test package initialization diff --git a/apps/api/plane/tests/apps.py b/apps/api/plane/tests/apps.py new file mode 100644 index 00000000..577414e6 --- /dev/null +++ b/apps/api/plane/tests/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = "plane.tests" diff --git a/apps/api/plane/tests/conftest.py b/apps/api/plane/tests/conftest.py new file mode 100644 index 00000000..abfede19 --- /dev/null +++ b/apps/api/plane/tests/conftest.py @@ -0,0 +1,136 @@ +import pytest +from rest_framework.test import APIClient +from pytest_django.fixtures import django_db_setup + +from plane.db.models import User, Workspace, WorkspaceMember +from plane.db.models.api import APIToken + + +@pytest.fixture(scope="session") +def django_db_setup(django_db_setup): # noqa: F811 + """Set up the Django database for the test session""" + pass + + +@pytest.fixture +def api_client(): + """Return an unauthenticated API client""" + return APIClient() + + +@pytest.fixture +def user_data(): + """Return standard user data for tests""" + return { + "email": "test@plane.so", + "password": "test-password", + "first_name": "Test", + "last_name": "User", + } + + +@pytest.fixture +def create_user(db, user_data): + """Create and return a user instance""" + user = User.objects.create( + email=user_data["email"], + first_name=user_data["first_name"], + last_name=user_data["last_name"], + ) + user.set_password(user_data["password"]) + user.save() + return user + + +@pytest.fixture +def api_token(db, create_user): + """Create and return an API token for testing the external API""" + token = APIToken.objects.create( + user=create_user, + label="Test API Token", + token="test-api-token-12345", + ) + return token + + +@pytest.fixture +def api_key_client(api_client, api_token): + """Return an API key authenticated client for external API testing""" + api_client.credentials(HTTP_X_API_KEY=api_token.token) + return api_client + + +@pytest.fixture +def session_client(api_client, create_user): + """Return a session authenticated API client for app API testing, which is what plane.app uses""" + api_client.force_authenticate(user=create_user) + return api_client + + +@pytest.fixture +def create_bot_user(db): + """Create and return a bot user instance""" + from uuid import uuid4 + + unique_id = uuid4().hex[:8] + user = User.objects.create( + email=f"bot-{unique_id}@plane.so", + username=f"bot_user_{unique_id}", + first_name="Bot", + last_name="User", + is_bot=True, + ) + user.set_password("bot@123") + user.save() + return user + + +@pytest.fixture +def api_token_data(): + """Return sample API token data for testing""" + from django.utils import timezone + from datetime import timedelta + + return { + "label": "Test API Token", + "description": "Test description for API token", + "expired_at": (timezone.now() + timedelta(days=30)).isoformat(), + } + + +@pytest.fixture +def create_api_token_for_user(db, create_user): + """Create and return an API token for a specific user""" + return APIToken.objects.create( + label="Test Token", + description="Test token description", + user=create_user, + user_type=0, + ) + + +@pytest.fixture +def plane_server(live_server): + """ + Renamed version of live_server fixture to avoid name clashes. + Returns a live Django server for testing HTTP requests. + """ + return live_server + + +@pytest.fixture +def workspace(create_user): + """ + Create a new workspace and return the + corresponding Workspace model instance. + """ + # Create the workspace using the model + created_workspace = Workspace.objects.create( + name="Test Workspace", + owner=create_user, + slug="test-workspace", + ) + + WorkspaceMember.objects.create(workspace=created_workspace, member=create_user, role=20) + + return created_workspace diff --git a/apps/api/plane/tests/conftest_external.py b/apps/api/plane/tests/conftest_external.py new file mode 100644 index 00000000..cebb768c --- /dev/null +++ b/apps/api/plane/tests/conftest_external.py @@ -0,0 +1,95 @@ +import pytest +from unittest.mock import MagicMock, patch + + +@pytest.fixture +def mock_redis(): + """ + Mock Redis for testing without actual Redis connection. + + This fixture patches the redis_instance function to return a MagicMock + that behaves like a Redis client. + """ + mock_redis_client = MagicMock() + + # Configure the mock to handle common Redis operations + mock_redis_client.get.return_value = None + mock_redis_client.set.return_value = True + mock_redis_client.delete.return_value = True + mock_redis_client.exists.return_value = 0 + mock_redis_client.ttl.return_value = -1 + + # Start the patch + with patch("plane.settings.redis.redis_instance", return_value=mock_redis_client): + yield mock_redis_client + + +@pytest.fixture +def mock_elasticsearch(): + """ + Mock Elasticsearch for testing without actual ES connection. + + This fixture patches Elasticsearch to return a MagicMock + that behaves like an Elasticsearch client. + """ + mock_es_client = MagicMock() + + # Configure the mock to handle common ES operations + mock_es_client.indices.exists.return_value = True + mock_es_client.indices.create.return_value = {"acknowledged": True} + mock_es_client.search.return_value = {"hits": {"total": {"value": 0}, "hits": []}} + mock_es_client.index.return_value = {"_id": "test_id", "result": "created"} + mock_es_client.update.return_value = {"_id": "test_id", "result": "updated"} + mock_es_client.delete.return_value = {"_id": "test_id", "result": "deleted"} + + # Start the patch + with patch("elasticsearch.Elasticsearch", return_value=mock_es_client): + yield mock_es_client + + +@pytest.fixture +def mock_mongodb(): + """ + Mock MongoDB for testing without actual MongoDB connection. + + This fixture patches PyMongo to return a MagicMock that behaves like a MongoDB client. + """ + # Create mock MongoDB clients and collections + mock_mongo_client = MagicMock() + mock_mongo_db = MagicMock() + mock_mongo_collection = MagicMock() + + # Set up the chain: client -> database -> collection + mock_mongo_client.__getitem__.return_value = mock_mongo_db + mock_mongo_client.get_database.return_value = mock_mongo_db + mock_mongo_db.__getitem__.return_value = mock_mongo_collection + + # Configure common MongoDB collection operations + mock_mongo_collection.find_one.return_value = None + mock_mongo_collection.find.return_value = MagicMock(__iter__=lambda x: iter([]), count=lambda: 0) + mock_mongo_collection.insert_one.return_value = MagicMock(inserted_id="mock_id_123", acknowledged=True) + mock_mongo_collection.insert_many.return_value = MagicMock( + inserted_ids=["mock_id_123", "mock_id_456"], acknowledged=True + ) + mock_mongo_collection.update_one.return_value = MagicMock(modified_count=1, matched_count=1, acknowledged=True) + mock_mongo_collection.update_many.return_value = MagicMock(modified_count=2, matched_count=2, acknowledged=True) + mock_mongo_collection.delete_one.return_value = MagicMock(deleted_count=1, acknowledged=True) + mock_mongo_collection.delete_many.return_value = MagicMock(deleted_count=2, acknowledged=True) + mock_mongo_collection.count_documents.return_value = 0 + + # Start the patch + with patch("pymongo.MongoClient", return_value=mock_mongo_client): + yield mock_mongo_client + + +@pytest.fixture +def mock_celery(): + """ + Mock Celery for testing without actual task execution. + + This fixture patches Celery's task.delay() to prevent actual task execution. + """ + # Start the patch + with patch("celery.app.task.Task.delay") as mock_delay: + mock_delay.return_value = MagicMock(id="mock-task-id") + yield mock_delay diff --git a/apps/api/plane/tests/contract/__init__.py b/apps/api/plane/tests/contract/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/tests/contract/api/__init__.py b/apps/api/plane/tests/contract/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/tests/contract/api/test_cycles.py b/apps/api/plane/tests/contract/api/test_cycles.py new file mode 100644 index 00000000..fb4ad3f3 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_cycles.py @@ -0,0 +1,382 @@ +import pytest +from rest_framework import status +from django.db import IntegrityError +from django.utils import timezone +from datetime import datetime, timedelta +from uuid import uuid4 + +from plane.db.models import Cycle, Project, ProjectMember + + +@pytest.fixture +def project(db, workspace, create_user): + """Create a test project with the user as a member""" + project = Project.objects.create( + name="Test Project", + identifier="TP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + project=project, + member=create_user, + role=20, # Admin role + is_active=True, + ) + return project + + +@pytest.fixture +def cycle_data(): + """Sample cycle data for tests""" + return { + "name": "Test Cycle", + "description": "A test cycle for unit tests", + } + + +@pytest.fixture +def draft_cycle_data(): + """Sample draft cycle data (no dates)""" + return { + "name": "Draft Cycle", + "description": "A draft cycle without dates", + } + + +@pytest.fixture +def create_cycle(db, project, create_user): + """Create a test cycle""" + return Cycle.objects.create( + name="Existing Cycle", + description="An existing cycle", + start_date=timezone.now() + timedelta(days=1), + end_date=timezone.now() + timedelta(days=7), + project=project, + workspace=project.workspace, + owned_by=create_user, + ) + + + + +@pytest.mark.contract +class TestCycleListCreateAPIEndpoint: + """Test Cycle List and Create API Endpoint""" + + def get_cycle_url(self, workspace_slug, project_id): + """Helper to get cycle endpoint URL""" + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/" + + @pytest.mark.django_db + def test_create_cycle_success(self, api_key_client, workspace, project, cycle_data): + """Test successful cycle creation""" + url = self.get_cycle_url(workspace.slug, project.id) + + response = api_key_client.post(url, cycle_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + + assert Cycle.objects.count() == 1 + + created_cycle = Cycle.objects.first() + assert created_cycle.name == cycle_data["name"] + assert created_cycle.description == cycle_data["description"] + assert created_cycle.project == project + assert created_cycle.owned_by_id is not None + + + @pytest.mark.django_db + def test_create_cycle_invalid_data(self, api_key_client, workspace, project): + """Test cycle creation with invalid data""" + url = self.get_cycle_url(workspace.slug, project.id) + + # Test with empty data + response = api_key_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Test with missing name + response = api_key_client.post(url, {"description": "Test cycle"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_cycle_invalid_date_combination(self, api_key_client, workspace, project): + """Test cycle creation with invalid date combination (only start_date)""" + url = self.get_cycle_url(workspace.slug, project.id) + + invalid_data = { + "name": "Invalid Cycle", + "start_date": (timezone.now() + timedelta(days=1)).isoformat(), + # Missing end_date + } + + response = api_key_client.post(url, invalid_data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Both start date and end date are either required or are to be null" in response.data["error"] + + @pytest.mark.django_db + def test_create_cycle_with_external_id(self, api_key_client, workspace, project): + """Test creating cycle with external ID""" + url = self.get_cycle_url(workspace.slug, project.id) + + cycle_data = { + "name": "External Cycle", + "description": "A cycle with external ID", + "external_id": "ext-123", + "external_source": "github", + } + + response = api_key_client.post(url, cycle_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + created_cycle = Cycle.objects.first() + assert created_cycle.external_id == "ext-123" + assert created_cycle.external_source == "github" + + @pytest.mark.django_db + def test_create_cycle_duplicate_external_id(self, api_key_client, workspace, project, create_user): + """Test creating cycle with duplicate external ID""" + url = self.get_cycle_url(workspace.slug, project.id) + + # Create first cycle + Cycle.objects.create( + name="First Cycle", + project=project, + workspace=workspace, + external_id="ext-123", + external_source="github", + owned_by=create_user, + ) + + # Try to create second cycle with same external ID + cycle_data = { + "name": "Second Cycle", + "external_id": "ext-123", + "external_source": "github", + "owned_by": create_user.id, + } + + response = api_key_client.post(url, cycle_data, format="json") + + assert response.status_code == status.HTTP_409_CONFLICT + assert "same external id" in response.data["error"] + + @pytest.mark.django_db + def test_list_cycles_success(self, api_key_client, workspace, project, create_cycle, create_user): + """Test successful cycle listing""" + url = self.get_cycle_url(workspace.slug, project.id) + + # Create additional cycles + Cycle.objects.create( + name="Cycle 2", + project=project, + workspace=workspace, + start_date=timezone.now() + timedelta(days=10), + end_date=timezone.now() + timedelta(days=17), + owned_by=create_user, + ) + Cycle.objects.create( + name="Cycle 3", + project=project, + workspace=workspace, + start_date=timezone.now() + timedelta(days=20), + end_date=timezone.now() + timedelta(days=27), + owned_by=create_user, + ) + + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "results" in response.data + assert len(response.data["results"]) == 3 # Including create_cycle fixture + + @pytest.mark.django_db + def test_list_cycles_with_view_filter(self, api_key_client, workspace, project, create_user): + """Test cycle listing with different view filters""" + url = self.get_cycle_url(workspace.slug, project.id) + + # Create cycles in different states + now = timezone.now() + + # Current cycle (started but not ended) + Cycle.objects.create( + name="Current Cycle", + project=project, + workspace=workspace, + start_date=now - timedelta(days=1), + end_date=now + timedelta(days=6), + owned_by=create_user, + ) + + # Upcoming cycle + Cycle.objects.create( + name="Upcoming Cycle", + project=project, + workspace=workspace, + start_date=now + timedelta(days=1), + end_date=now + timedelta(days=8), + owned_by=create_user, + ) + + # Completed cycle + Cycle.objects.create( + name="Completed Cycle", + project=project, + workspace=workspace, + start_date=now - timedelta(days=10), + end_date=now - timedelta(days=3), + owned_by=create_user, + ) + + # Draft cycle + Cycle.objects.create( + name="Draft Cycle", + project=project, + workspace=workspace, + owned_by=create_user, + ) + + # Test current cycles + response = api_key_client.get(url, {"cycle_view": "current"}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]["name"] == "Current Cycle" + + # Test upcoming cycles + response = api_key_client.get(url, {"cycle_view": "upcoming"}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 1 + assert response.data["results"][0]["name"] == "Upcoming Cycle" + + # Test completed cycles + response = api_key_client.get(url, {"cycle_view": "completed"}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 1 + assert response.data["results"][0]["name"] == "Completed Cycle" + + # Test draft cycles + response = api_key_client.get(url, {"cycle_view": "draft"}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 1 + assert response.data["results"][0]["name"] == "Draft Cycle" + + +@pytest.mark.contract +class TestCycleDetailAPIEndpoint: + """Test Cycle Detail API Endpoint""" + + def get_cycle_detail_url(self, workspace_slug, project_id, cycle_id): + """Helper to get cycle detail endpoint URL""" + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/" + + @pytest.mark.django_db + def test_get_cycle_success(self, api_key_client, workspace, project, create_cycle): + """Test successful cycle retrieval""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert str(response.data["id"]) == str(create_cycle.id) + assert response.data["name"] == create_cycle.name + assert response.data["description"] == create_cycle.description + + @pytest.mark.django_db + def test_get_cycle_not_found(self, api_key_client, workspace, project): + """Test getting non-existent cycle""" + fake_id = uuid4() + url = self.get_cycle_detail_url(workspace.slug, project.id, fake_id) + + response = api_key_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_update_cycle_success(self, api_key_client, workspace, project, create_cycle): + """Test successful cycle update""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + update_data = { + "name": f"Updated Cycle {uuid4()}", + "description": "Updated description", + } + + response = api_key_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_200_OK + + create_cycle.refresh_from_db() + assert create_cycle.name == update_data["name"] + assert create_cycle.description == update_data["description"] + + @pytest.mark.django_db + def test_update_cycle_invalid_data(self, api_key_client, workspace, project, create_cycle): + """Test cycle update with invalid data""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + update_data = {"name": ""} + response = api_key_client.patch(url, update_data, format="json") + + # This might be 400 if name is required, or 200 if empty names are allowed + assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK] + + @pytest.mark.django_db + def test_update_cycle_with_external_id_conflict(self, api_key_client, workspace, project, create_cycle, create_user ): + """Test cycle update with conflicting external ID""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + # Create another cycle with external ID + Cycle.objects.create( + name="Another Cycle", + project=project, + workspace=workspace, + external_id="ext-456", + external_source="github", + owned_by=create_user, + ) + + # Try to update cycle with same external ID + update_data = { + "external_id": "ext-456", + "external_source": "github", + } + + response = api_key_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_409_CONFLICT + assert "same external id" in response.data["error"] + + @pytest.mark.django_db + def test_delete_cycle_success(self, api_key_client, workspace, project, create_cycle): + """Test successful cycle deletion""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + response = api_key_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Cycle.objects.filter(id=create_cycle.id).exists() + + @pytest.mark.django_db + def test_cycle_metrics_annotation(self, api_key_client, workspace, project, create_cycle): + """Test that cycle includes issue metrics annotations""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + + # Check that metrics are included in response + cycle_data = response.data + assert "total_issues" in cycle_data + assert "completed_issues" in cycle_data + assert "cancelled_issues" in cycle_data + assert "started_issues" in cycle_data + assert "unstarted_issues" in cycle_data + assert "backlog_issues" in cycle_data + + # All should be 0 for a new cycle + assert cycle_data["total_issues"] == 0 + assert cycle_data["completed_issues"] == 0 + assert cycle_data["cancelled_issues"] == 0 + assert cycle_data["started_issues"] == 0 + assert cycle_data["unstarted_issues"] == 0 + assert cycle_data["backlog_issues"] == 0 \ No newline at end of file diff --git a/apps/api/plane/tests/contract/api/test_labels.py b/apps/api/plane/tests/contract/api/test_labels.py new file mode 100644 index 00000000..a3a43d90 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_labels.py @@ -0,0 +1,213 @@ +import pytest +from rest_framework import status +from uuid import uuid4 + +from plane.db.models import Label, Project, ProjectMember + + +@pytest.fixture +def project(db, workspace, create_user): + """Create a test project with the user as a member""" + project = Project.objects.create( + name="Test Project", + identifier="TP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + project=project, + member=create_user, + role=20, # Admin role + is_active=True, + ) + return project + + +@pytest.fixture +def label_data(): + """Sample label data for tests""" + return { + "name": "Test Label", + "color": "#FF5733", + "description": "A test label for unit tests", + } + + +@pytest.fixture +def create_label(db, project, create_user): + """Create a test label""" + return Label.objects.create( + name="Existing Label", + color="#00FF00", + description="An existing label", + project=project, + workspace=project.workspace, + created_by=create_user, + ) + + +@pytest.mark.contract +class TestLabelListCreateAPIEndpoint: + """Test Label List and Create API Endpoint""" + + def get_label_url(self, workspace_slug, project_id): + """Helper to get label endpoint URL""" + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/" + + @pytest.mark.django_db + def test_create_label_success(self, api_key_client, workspace, project, label_data): + """Test successful label creation""" + url = self.get_label_url(workspace.slug, project.id) + + response = api_key_client.post(url, label_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert Label.objects.count() == 1 + + created_label = Label.objects.first() + assert created_label.name == label_data["name"] + assert created_label.color == label_data["color"] + assert created_label.description == label_data["description"] + assert created_label.project == project + + @pytest.mark.django_db + def test_create_label_invalid_data(self, api_key_client, workspace, project): + """Test label creation with invalid data""" + url = self.get_label_url(workspace.slug, project.id) + + # Test with empty data + response = api_key_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Test with missing name + response = api_key_client.post(url, {"color": "#FF5733"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_label_with_external_id(self, api_key_client, workspace, project): + """Test creating label with external ID""" + url = self.get_label_url(workspace.slug, project.id) + + label_data = { + "name": "External Label", + "color": "#FF5733", + "external_id": "ext-123", + "external_source": "github", + } + + response = api_key_client.post(url, label_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + created_label = Label.objects.first() + assert created_label.external_id == "ext-123" + assert created_label.external_source == "github" + + @pytest.mark.django_db + def test_create_label_duplicate_external_id(self, api_key_client, workspace, project): + """Test creating label with duplicate external ID""" + url = self.get_label_url(workspace.slug, project.id) + + # Create first label + Label.objects.create( + name="First Label", + project=project, + workspace=workspace, + external_id="ext-123", + external_source="github", + ) + + # Try to create second label with same external ID + label_data = { + "name": "Second Label", + "external_id": "ext-123", + "external_source": "github", + } + + response = api_key_client.post(url, label_data, format="json") + + assert response.status_code == status.HTTP_409_CONFLICT + assert "same external id" in response.data["error"] + + @pytest.mark.django_db + def test_list_labels_success(self, api_key_client, workspace, project, create_label): + """Test successful label listing""" + url = self.get_label_url(workspace.slug, project.id) + + # Create additional labels + Label.objects.create(name="Label 2", project=project, workspace=workspace, color="#00FF00") + Label.objects.create(name="Label 3", project=project, workspace=workspace, color="#0000FF") + + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "results" in response.data + assert len(response.data["results"]) == 3 # Including create_label fixture + + +@pytest.mark.contract +class TestLabelDetailAPIEndpoint: + """Test Label Detail API Endpoint""" + + def get_label_detail_url(self, workspace_slug, project_id, label_id): + """Helper to get label detail endpoint URL""" + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/{label_id}/" + + @pytest.mark.django_db + def test_get_label_success(self, api_key_client, workspace, project, create_label): + """Test successful label retrieval""" + url = self.get_label_detail_url(workspace.slug, project.id, create_label.id) + + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == create_label.id + assert response.data["name"] == create_label.name + assert response.data["color"] == create_label.color + + @pytest.mark.django_db + def test_get_label_not_found(self, api_key_client, workspace, project): + """Test getting non-existent label""" + from uuid import uuid4 + + fake_id = uuid4() + url = self.get_label_detail_url(workspace.slug, project.id, fake_id) + + response = api_key_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_update_label_success(self, api_key_client, workspace, project, create_label): + """Test successful label update""" + url = self.get_label_detail_url(workspace.slug, project.id, create_label.id) + + update_data = { + "name": f"Updated Label {uuid4()}", + } + + response = api_key_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_200_OK + + create_label.refresh_from_db() + assert create_label.name == update_data["name"] + + @pytest.mark.django_db + def test_update_label_invalid_data(self, api_key_client, workspace, project, create_label): + """Test label update with invalid data""" + url = self.get_label_detail_url(workspace.slug, project.id, create_label.id) + + update_data = {"name": ""} + response = api_key_client.patch(url, update_data, format="json") + + # This might be 400 if name is required, or 200 if empty names are allowed + assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK] + + @pytest.mark.django_db + def test_delete_label_success(self, api_key_client, workspace, project, create_label): + """Test successful label deletion""" + url = self.get_label_detail_url(workspace.slug, project.id, create_label.id) + + response = api_key_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Label.objects.filter(id=create_label.id).exists() diff --git a/apps/api/plane/tests/contract/app/__init__.py b/apps/api/plane/tests/contract/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/tests/contract/app/test_api_token.py b/apps/api/plane/tests/contract/app/test_api_token.py new file mode 100644 index 00000000..35d92b11 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_api_token.py @@ -0,0 +1,348 @@ +import pytest +from datetime import timedelta +from uuid import uuid4 +from django.urls import reverse +from django.utils import timezone +from rest_framework import status + +from plane.db.models import APIToken, User + + +@pytest.mark.contract +class TestApiTokenEndpoint: + """Test cases for ApiTokenEndpoint""" + + # POST /user/api-tokens/ tests + @pytest.mark.django_db + def test_create_api_token_success(self, session_client, create_user, api_token_data): + """Test successful API token creation""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens") + + # Act + response = session_client.post(url, api_token_data, format="json") + + # Assert + assert response.status_code == status.HTTP_201_CREATED + assert "token" in response.data + assert response.data["label"] == api_token_data["label"] + assert response.data["description"] == api_token_data["description"] + assert response.data["user_type"] == 0 # Human user + + # Verify token was created in database + token = APIToken.objects.get(pk=response.data["id"]) + assert token.user == create_user + assert token.label == api_token_data["label"] + + @pytest.mark.django_db + def test_create_api_token_for_bot_user(self, session_client, create_bot_user, api_token_data): + """Test API token creation for bot user""" + # Arrange + session_client.force_authenticate(user=create_bot_user) + url = reverse("api-tokens") + + # Act + response = session_client.post(url, api_token_data, format="json") + + # Assert + assert response.status_code == status.HTTP_201_CREATED + assert response.data["user_type"] == 1 # Bot user + + @pytest.mark.django_db + def test_create_api_token_minimal_data(self, session_client, create_user): + """Test API token creation with minimal data""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens") + + # Act + response = session_client.post(url, {}, format="json") + + # Assert + assert response.status_code == status.HTTP_201_CREATED + assert "token" in response.data + assert len(response.data["label"]) == 32 # UUID hex length + assert response.data["description"] == "" + + @pytest.mark.django_db + def test_create_api_token_with_expiry(self, session_client, create_user): + """Test API token creation with expiry date""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens") + future_date = timezone.now() + timedelta(days=30) + data = {"label": "Expiring Token", "expired_at": future_date.isoformat()} + + # Act + response = session_client.post(url, data, format="json") + + # Assert + assert response.status_code == status.HTTP_201_CREATED + + # Verify expiry date was set + token = APIToken.objects.get(pk=response.data["id"]) + assert token.expired_at is not None + + @pytest.mark.django_db + def test_create_api_token_unauthenticated(self, api_client, api_token_data): + """Test API token creation without authentication""" + # Arrange + url = reverse("api-tokens") + + # Act + response = api_client.post(url, api_token_data, format="json") + + # Assert + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + # GET /user/api-tokens/ tests + @pytest.mark.django_db + def test_get_all_api_tokens(self, session_client, create_user): + """Test retrieving all API tokens for user""" + # Arrange + session_client.force_authenticate(user=create_user) + + # Create multiple tokens + APIToken.objects.create(label="Token 1", user=create_user, user_type=0) + APIToken.objects.create(label="Token 2", user=create_user, user_type=0) + # Create a service token (should be excluded) + APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True) + url = reverse("api-tokens") + + # Act + response = session_client.get(url) + + # Assert + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 2 # Only non-service tokens + assert all(token["is_service"] is False for token in response.data) + + @pytest.mark.django_db + def test_get_empty_api_tokens_list(self, session_client, create_user): + """Test retrieving API tokens when none exist""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens") + + # Act + response = session_client.get(url) + + # Assert + assert response.status_code == status.HTTP_200_OK + assert response.data == [] + + # GET /user/api-tokens// tests + @pytest.mark.django_db + def test_get_specific_api_token(self, session_client, create_user, create_api_token_for_user): + """Test retrieving a specific API token""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk}) + + # Act + response = session_client.get(url) + + # Assert + assert response.status_code == status.HTTP_200_OK + assert str(response.data["id"]) == str(create_api_token_for_user.pk) + assert response.data["label"] == create_api_token_for_user.label + assert "token" not in response.data # Token should not be visible in read serializer + + @pytest.mark.django_db + def test_get_nonexistent_api_token(self, session_client, create_user): + """Test retrieving a non-existent API token""" + # Arrange + session_client.force_authenticate(user=create_user) + fake_pk = uuid4() + url = reverse("api-tokens", kwargs={"pk": fake_pk}) + + # Act + response = session_client.get(url) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_get_other_users_api_token(self, session_client, create_user, db): + """Test retrieving another user's API token (should fail)""" + # Arrange + # Create another user and their token with unique email and username + unique_id = uuid4().hex[:8] + unique_email = f"other-{unique_id}@plane.so" + unique_username = f"other_user_{unique_id}" + other_user = User.objects.create(email=unique_email, username=unique_username) + other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0) + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": other_token.pk}) + + # Act + response = session_client.get(url) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + + # DELETE /user/api-tokens// tests + @pytest.mark.django_db + def test_delete_api_token_success(self, session_client, create_user, create_api_token_for_user): + """Test successful API token deletion""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk}) + + # Act + response = session_client.delete(url) + + # Assert + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists() + + @pytest.mark.django_db + def test_delete_nonexistent_api_token(self, session_client, create_user): + """Test deleting a non-existent API token""" + # Arrange + session_client.force_authenticate(user=create_user) + fake_pk = uuid4() + url = reverse("api-tokens", kwargs={"pk": fake_pk}) + + # Act + response = session_client.delete(url) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_delete_other_users_api_token(self, session_client, create_user, db): + """Test deleting another user's API token (should fail)""" + # Arrange + # Create another user and their token with unique email and username + unique_id = uuid4().hex[:8] + unique_email = f"delete-other-{unique_id}@plane.so" + unique_username = f"delete_other_user_{unique_id}" + other_user = User.objects.create(email=unique_email, username=unique_username) + other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0) + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": other_token.pk}) + + # Act + response = session_client.delete(url) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + # Verify token still exists + assert APIToken.objects.filter(pk=other_token.pk).exists() + + @pytest.mark.django_db + def test_delete_service_api_token_forbidden(self, session_client, create_user): + """Test deleting a service API token (should fail)""" + # Arrange + service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True) + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": service_token.pk}) + + # Act + response = session_client.delete(url) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + # Verify token still exists + assert APIToken.objects.filter(pk=service_token.pk).exists() + + # PATCH /user/api-tokens// tests + @pytest.mark.django_db + def test_patch_api_token_success(self, session_client, create_user, create_api_token_for_user): + """Test successful API token update""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk}) + update_data = { + "label": "Updated Token Label", + "description": "Updated description", + } + + # Act + response = session_client.patch(url, update_data, format="json") + + # Assert + assert response.status_code == status.HTTP_200_OK + assert response.data["label"] == update_data["label"] + assert response.data["description"] == update_data["description"] + + # Verify database was updated + create_api_token_for_user.refresh_from_db() + assert create_api_token_for_user.label == update_data["label"] + assert create_api_token_for_user.description == update_data["description"] + + @pytest.mark.django_db + def test_patch_api_token_partial_update(self, session_client, create_user, create_api_token_for_user): + """Test partial API token update""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk}) + original_description = create_api_token_for_user.description + update_data = {"label": "Only Label Updated"} + + # Act + response = session_client.patch(url, update_data, format="json") + + # Assert + assert response.status_code == status.HTTP_200_OK + assert response.data["label"] == update_data["label"] + assert response.data["description"] == original_description + + @pytest.mark.django_db + def test_patch_nonexistent_api_token(self, session_client, create_user): + """Test updating a non-existent API token""" + # Arrange + session_client.force_authenticate(user=create_user) + fake_pk = uuid4() + url = reverse("api-tokens", kwargs={"pk": fake_pk}) + update_data = {"label": "New Label"} + + # Act + response = session_client.patch(url, update_data, format="json") + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_patch_other_users_api_token(self, session_client, create_user, db): + """Test updating another user's API token (should fail)""" + # Arrange + # Create another user and their token with unique email and username + unique_id = uuid4().hex[:8] + unique_email = f"patch-other-{unique_id}@plane.so" + unique_username = f"patch_other_user_{unique_id}" + other_user = User.objects.create(email=unique_email, username=unique_username) + other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0) + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": other_token.pk}) + update_data = {"label": "Hacked Label"} + + # Act + response = session_client.patch(url, update_data, format="json") + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Verify token was not updated + other_token.refresh_from_db() + assert other_token.label == "Other Token" + + # Authentication tests + @pytest.mark.django_db + def test_all_endpoints_require_authentication(self, api_client): + """Test that all endpoints require authentication""" + # Arrange + endpoints = [ + (reverse("api-tokens"), "get"), + (reverse("api-tokens"), "post"), + (reverse("api-tokens", kwargs={"pk": uuid4()}), "get"), + (reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"), + (reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"), + ] + + # Act & Assert + for url, method in endpoints: + response = getattr(api_client, method)(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/apps/api/plane/tests/contract/app/test_authentication.py b/apps/api/plane/tests/contract/app/test_authentication.py new file mode 100644 index 00000000..1c044f19 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_authentication.py @@ -0,0 +1,425 @@ +import json +import uuid +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from django.test import Client +from django.core.exceptions import ValidationError +from unittest.mock import patch + +from plane.db.models import User +from plane.settings.redis import redis_instance +from plane.license.models import Instance + + +@pytest.fixture +def setup_instance(db): + """Create and configure an instance for authentication tests""" + instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id + + # Create or update instance with all required fields + instance, _ = Instance.objects.update_or_create( + id=instance_id, + defaults={ + "instance_name": "Test Instance", + "instance_id": str(uuid.uuid4()), + "current_version": "1.0.0", + "domain": "http://localhost:8000", + "last_checked_at": timezone.now(), + "is_setup_done": True, + }, + ) + return instance + + +@pytest.fixture +def django_client(): + """Return a Django test client with User-Agent header for handling redirects""" + client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1") + return client + + +@pytest.mark.contract +class TestMagicLinkGenerate: + """Test magic link generation functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for magic link tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, api_client, setup_user, setup_instance): + """Test magic link generation with empty data""" + url = reverse("magic-generate") + try: + response = api_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + except ValidationError: + # If a ValidationError is raised directly, that's also acceptable + # as it indicates the empty email was rejected + assert True + + @pytest.mark.django_db + def test_email_validity(self, api_client, setup_user, setup_instance): + """Test magic link generation with invalid email format""" + url = reverse("magic-generate") + try: + response = api_client.post(url, {"email": "useremail.com"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error_code" in response.data # Check for error code in response + except ValidationError: + # If a ValidationError is raised directly, that's also acceptable + # as it indicates the invalid email was rejected + assert True + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance): + """Test successful magic link generation""" + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + response = api_client.post(url, {"email": "user@plane.so"}, format="json") + assert response.status_code == status.HTTP_200_OK + assert "key" in response.data # Check for key in response + + # Verify the mock was called with the expected arguments + mock_magic_link.assert_called_once() + args = mock_magic_link.call_args[0] + assert args[0] == "user@plane.so" # First arg should be the email + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance): + """Test exceeding maximum magic link generation attempts""" + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + for _ in range(4): + api_client.post(url, {"email": "user@plane.so"}, format="json") + + response = api_client.post(url, {"email": "user@plane.so"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error_code" in response.data # Check for error code in response + + +@pytest.mark.contract +class TestSignInEndpoint: + """Test sign-in functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for authentication tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_user, setup_instance): + """Test sign-in with empty data""" + url = reverse("sign-in") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "REQUIRED_EMAIL_PASSWORD_SIGN_IN" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_email_validity(self, django_client, setup_user, setup_instance): + """Test sign-in with invalid email format""" + url = reverse("sign-in") + response = django_client.post(url, {"email": "useremail.com", "password": "user@123"}, follow=True) + + # Check redirect contains error code + assert "INVALID_EMAIL_SIGN_IN" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_user_exists(self, django_client, setup_user, setup_instance): + """Test sign-in with non-existent user""" + url = reverse("sign-in") + response = django_client.post(url, {"email": "user@email.so", "password": "user123"}, follow=True) + + # Check redirect contains error code + assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_password_validity(self, django_client, setup_user, setup_instance): + """Test sign-in with incorrect password""" + url = reverse("sign-in") + response = django_client.post(url, {"email": "user@plane.so", "password": "user123"}, follow=True) + + # Check for the specific authentication error in the URL + redirect_urls = [url for url, _ in response.redirect_chain] + redirect_contents = " ".join(redirect_urls) + + # The actual error code for invalid password is AUTHENTICATION_FAILED_SIGN_IN + assert "AUTHENTICATION_FAILED_SIGN_IN" in redirect_contents + + @pytest.mark.django_db + def test_user_login(self, django_client, setup_user, setup_instance): + """Test successful sign-in""" + url = reverse("sign-in") + + # First make the request without following redirects + response = django_client.post(url, {"email": "user@plane.so", "password": "user@123"}, follow=False) + + # Check that the initial response is a redirect (302) without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Now follow just the first redirect to avoid 404s + response = django_client.get(response.url, follow=False) + + # The user should be authenticated regardless of the final page + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + def test_next_path_redirection(self, django_client, setup_user, setup_instance): + """Test sign-in with next_path parameter""" + url = reverse("sign-in") + next_path = "workspaces" + + # First make the request without following redirects + response = django_client.post( + url, + {"email": "user@plane.so", "password": "user@123", "next_path": next_path}, + follow=False, + ) + + # Check that the initial response is a redirect (302) without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # In a real browser, the next_path would be used to build the absolute URL + # Since we're just testing the authentication logic, we won't check for the exact URL structure + # Instead, just verify that we're authenticated + assert "_auth_user_id" in django_client.session + + +@pytest.mark.contract +class TestMagicSignIn: + """Test magic link sign-in functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for magic sign-in tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_user, setup_instance): + """Test magic link sign-in with empty data""" + url = reverse("magic-sign-in") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance): + """Test magic link sign-in with expired/invalid link""" + ri = redis_instance() + ri.delete("magic_user@plane.so") + + url = reverse("magic-sign-in") + response = django_client.post(url, {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False) + + # Check that we get a redirect + assert response.status_code == 302 + + # The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist) + # or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match) + assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url + + @pytest.mark.django_db + def test_user_does_not_exist(self, django_client, setup_instance): + """Test magic sign-in with non-existent user""" + url = reverse("magic-sign-in") + response = django_client.post( + url, + {"email": "nonexistent@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=True, + ) + + # Check redirect contains error code + assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): + """Test successful magic link sign-in process""" + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get("magic_user@plane.so")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-in") + response = django_client.post(url, {"email": "user@plane.so", "code": token}, follow=False) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # The user should now be authenticated + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): + """Test magic sign-in with next_path parameter""" + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get("magic_user@plane.so")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-in") + next_path = "workspaces" + response = django_client.post( + url, + {"email": "user@plane.so", "code": token, "next_path": next_path}, + follow=False, + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Check that the redirect URL contains the next_path + assert next_path in response.url + + # The user should now be authenticated + assert "_auth_user_id" in django_client.session + + +@pytest.mark.contract +class TestMagicSignUp: + """Test magic link sign-up functionality""" + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_instance): + """Test magic link sign-up with empty data""" + url = reverse("magic-sign-up") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_user_already_exists(self, django_client, db, setup_instance): + """Test magic sign-up with existing user""" + # Create a user that already exists + User.objects.create(email="existing@plane.so") + + url = reverse("magic-sign-up") + response = django_client.post(url, {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=True) + + # Check redirect contains error code + assert "USER_ALREADY_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_expired_invalid_magic_link(self, django_client, setup_instance): + """Test magic link sign-up with expired/invalid link""" + url = reverse("magic-sign-up") + response = django_client.post(url, {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False) + + # Check that we get a redirect + assert response.status_code == 302 + + # The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist) + # or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match) + assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance): + """Test successful magic link sign-up process""" + email = "newuser@plane.so" + + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": email}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get(f"magic_{email}")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-up") + response = django_client.post(url, {"email": email, "code": token}, follow=False) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Check if user was created + assert User.objects.filter(email=email).exists() + + # Check if user is authenticated + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance): + """Test magic sign-up with next_path parameter""" + email = "newuser2@plane.so" + + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": email}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get(f"magic_{email}")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-up") + next_path = "onboarding" + response = django_client.post(url, {"email": email, "code": token, "next_path": next_path}, follow=False) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # In a real browser, the next_path would be used to build the absolute URL + # Since we're just testing the authentication logic, we won't check for the exact URL structure + + # Check if user was created + assert User.objects.filter(email=email).exists() + + # Check if user is authenticated + assert "_auth_user_id" in django_client.session diff --git a/apps/api/plane/tests/contract/app/test_project_app.py b/apps/api/plane/tests/contract/app/test_project_app.py new file mode 100644 index 00000000..38b0f51f --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_project_app.py @@ -0,0 +1,520 @@ +import pytest +from rest_framework import status +import uuid +from django.utils import timezone + +from plane.db.models import ( + Project, + ProjectMember, + IssueUserProperty, + State, + WorkspaceMember, + User, +) + + +class TestProjectBase: + def get_project_url(self, workspace_slug: str, pk: uuid.UUID = None, details: bool = False) -> str: + """ + Constructs the project endpoint URL for the given workspace as reverse() is + unreliable due to duplicate 'name' values in URL patterns ('api' and 'app'). + + Args: + workspace_slug (str): The slug of the workspace. + pk (uuid.UUID, optional): The primary key of a specific project. + details (bool, optional): If True, constructs the URL for the + project details endpoint. Defaults to False. + """ + # Establish the common base URL for all project-related endpoints. + base_url = f"/api/workspaces/{workspace_slug}/projects/" + + # Specific project instance URL. + if pk: + return f"{base_url}{pk}/" + + # Append 'details/' to the base URL. + if details: + return f"{base_url}details/" + + # Return the base project list URL. + return base_url + + +@pytest.mark.contract +class TestProjectAPIPost(TestProjectBase): + """Test project POST operations""" + + @pytest.mark.django_db + def test_create_project_empty_data(self, session_client, workspace): + """Test creating a project with empty data""" + + url = self.get_project_url(workspace.slug) + + # Test with empty data + response = session_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_project_valid_data(self, session_client, workspace, create_user): + url = self.get_project_url(workspace.slug) + + project_data = { + "name": "New Project Test", + "identifier": "NPT", + } + + user = create_user + + # Make the request + response = session_client.post(url, project_data, format="json") + + # Check response status + assert response.status_code == status.HTTP_201_CREATED + + # Verify project was created + assert Project.objects.count() == 1 + project = Project.objects.get(name=project_data["name"]) + assert project.workspace == workspace + + # Check if the member is created with the correct role + assert ProjectMember.objects.count() == 1 + project_member = ProjectMember.objects.filter(project=project, member=user).first() + assert project_member.role == 20 # Administrator + assert project_member.is_active is True + + # Verify IssueUserProperty was created + assert IssueUserProperty.objects.filter(project=project, user=user).exists() + + # Verify default states were created + states = State.objects.filter(project=project) + assert states.count() == 5 + expected_states = ["Backlog", "Todo", "In Progress", "Done", "Cancelled"] + state_names = list(states.values_list("name", flat=True)) + assert set(state_names) == set(expected_states) + + @pytest.mark.django_db + def test_create_project_with_project_lead(self, session_client, workspace, create_user): + """Test creating project with a different project lead""" + # Create another user to be project lead + project_lead = User.objects.create_user(email="lead@example.com", username="projectlead") + + # Add project lead to workspace + WorkspaceMember.objects.create(workspace=workspace, member=project_lead, role=15) + + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Project with Lead", + "identifier": "PWL", + "project_lead": project_lead.id, + } + + response = session_client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + + # Verify both creator and project lead are administrators + project = Project.objects.get(name=project_data["name"]) + assert ProjectMember.objects.filter(project=project, role=20).count() == 2 + + # Verify both have IssueUserProperty + assert IssueUserProperty.objects.filter(project=project).count() == 2 + + @pytest.mark.django_db + def test_create_project_guest_forbidden(self, session_client, workspace): + """Test that guests cannot create projects""" + guest_user = User.objects.create_user(email="guest@example.com", username="guest") + WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5) + + session_client.force_authenticate(user=guest_user) + + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Guest Project", + "identifier": "GP", + } + + response = session_client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert Project.objects.count() == 0 + + @pytest.mark.django_db + def test_create_project_unauthenticated(self, client, workspace): + """Test unauthenticated access""" + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Unauth Project", + "identifier": "UP", + } + + response = client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.django_db + def test_create_project_duplicate_name(self, session_client, workspace, create_user): + """Test creating project with duplicate name""" + # Create first project + Project.objects.create(name="Duplicate Name", identifier="DN1", workspace=workspace) + + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Duplicate Name", + "identifier": "DN2", + } + + response = session_client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_project_duplicate_identifier(self, session_client, workspace, create_user): + """Test creating project with duplicate identifier""" + Project.objects.create(name="First Project", identifier="DUP", workspace=workspace) + + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Second Project", + "identifier": "DUP", + } + + response = session_client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_project_missing_required_fields(self, session_client, workspace, create_user): + """Test validation with missing required fields""" + url = self.get_project_url(workspace.slug) + + # Test missing name + response = session_client.post(url, {"identifier": "MN"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Test missing identifier + response = session_client.post(url, {"name": "Missing Identifier"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_project_with_all_optional_fields(self, session_client, workspace, create_user): + """Test creating project with all optional fields""" + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Full Project", + "identifier": "FP", + "description": "A comprehensive test project", + "network": 2, + "cycle_view": True, + "issue_views_view": False, + "module_view": True, + "page_view": False, + "inbox_view": True, + "guest_view_all_features": True, + "logo_props": { + "in_use": "emoji", + "emoji": {"value": "🚀", "unicode": "1f680"}, + }, + } + + response = session_client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + + response_data = response.json() + assert response_data["description"] == project_data["description"] + assert response_data["network"] == project_data["network"] + + +@pytest.mark.contract +class TestProjectAPIGet(TestProjectBase): + """Test project GET operations""" + + @pytest.mark.django_db + def test_list_projects_authenticated_admin(self, session_client, workspace, create_user): + """Test listing projects as workspace admin""" + # Create a project + project = Project.objects.create(name="Test Project", identifier="TP", workspace=workspace) + + # Add user as project member + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + url = self.get_project_url(workspace.slug) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "Test Project" + assert data[0]["identifier"] == "TP" + + @pytest.mark.django_db + def test_list_projects_authenticated_guest(self, session_client, workspace): + """Test listing projects as workspace guest""" + # Create a guest user + guest_user = User.objects.create_user(email="guest@example.com", username="guest") + WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5, is_active=True) + + # Create projects + project1 = Project.objects.create(name="Project 1", identifier="P1", workspace=workspace) + + Project.objects.create(name="Project 2", identifier="P2", workspace=workspace) + + # Add guest to only one project + ProjectMember.objects.create(project=project1, member=guest_user, role=10, is_active=True) + + session_client.force_authenticate(user=guest_user) + + url = self.get_project_url(workspace.slug) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + # Guest should only see projects they're members of + assert len(data) == 1 + assert data[0]["name"] == "Project 1" + + @pytest.mark.django_db + def test_list_projects_unauthenticated(self, client, workspace): + """Test listing projects without authentication""" + url = self.get_project_url(workspace.slug) + response = client.get(url) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.django_db + def test_list_detail_projects(self, session_client, workspace, create_user): + """Test listing projects with detailed information""" + # Create a project + project = Project.objects.create( + name="Detailed Project", + identifier="DP", + workspace=workspace, + description="A detailed test project", + ) + + # Add user as project member + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + url = self.get_project_url(workspace.slug, details=True) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "Detailed Project" + assert data[0]["description"] == "A detailed test project" + + @pytest.mark.django_db + def test_retrieve_project_success(self, session_client, workspace, create_user): + """Test retrieving a specific project""" + # Create a project + project = Project.objects.create( + name="Retrieve Test Project", + identifier="RTP", + workspace=workspace, + description="Test project for retrieval", + ) + + # Add user as project member + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "Retrieve Test Project" + assert data["identifier"] == "RTP" + assert data["description"] == "Test project for retrieval" + + @pytest.mark.django_db + def test_retrieve_project_not_found(self, session_client, workspace, create_user): + """Test retrieving a non-existent project""" + fake_uuid = uuid.uuid4() + url = self.get_project_url(workspace.slug, pk=fake_uuid) + response = session_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_retrieve_archived_project(self, session_client, workspace, create_user): + """Test retrieving an archived project""" + # Create an archived project + project = Project.objects.create( + name="Archived Project", + identifier="AP", + workspace=workspace, + archived_at=timezone.now(), + ) + + # Add user as project member + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = session_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.contract +class TestProjectAPIPatchDelete(TestProjectBase): + """Test project PATCH, and DELETE operations""" + + @pytest.mark.django_db + def test_partial_update_project_success(self, session_client, workspace, create_user): + """Test successful partial update of project""" + # Create a project + project = Project.objects.create( + name="Original Project", + identifier="OP", + workspace=workspace, + description="Original description", + ) + + # Add user as project administrator + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + url = self.get_project_url(workspace.slug, pk=project.id) + update_data = { + "name": "Updated Project", + "description": "Updated description", + "cycle_view": True, + "module_view": False, + } + + response = session_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_200_OK + + # Verify project was updated + project.refresh_from_db() + assert project.name == "Updated Project" + assert project.description == "Updated description" + assert project.cycle_view is True + assert project.module_view is False + + @pytest.mark.django_db + def test_partial_update_project_forbidden_non_admin(self, session_client, workspace): + """Test that non-admin project members cannot update project""" + # Create a project + project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace) + + # Create a member user (not admin) + member_user = User.objects.create_user(email="member@example.com", username="member") + WorkspaceMember.objects.create(workspace=workspace, member=member_user, role=15, is_active=True) + ProjectMember.objects.create(project=project, member=member_user, role=15, is_active=True) + + session_client.force_authenticate(user=member_user) + + url = self.get_project_url(workspace.slug, pk=project.id) + update_data = {"name": "Hacked Project"} + + response = session_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.django_db + def test_partial_update_duplicate_name_conflict(self, session_client, workspace, create_user): + """Test updating project with duplicate name returns conflict""" + # Create two projects + Project.objects.create(name="Project One", identifier="P1", workspace=workspace) + project2 = Project.objects.create(name="Project Two", identifier="P2", workspace=workspace) + + ProjectMember.objects.create(project=project2, member=create_user, role=20, is_active=True) + + url = self.get_project_url(workspace.slug, pk=project2.id) + update_data = {"name": "Project One"} # Duplicate name + + response = session_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_partial_update_duplicate_identifier_conflict(self, session_client, workspace, create_user): + """Test updating project with duplicate identifier returns conflict""" + # Create two projects + Project.objects.create(name="Project One", identifier="P1", workspace=workspace) + project2 = Project.objects.create(name="Project Two", identifier="P2", workspace=workspace) + + ProjectMember.objects.create(project=project2, member=create_user, role=20, is_active=True) + + url = self.get_project_url(workspace.slug, pk=project2.id) + update_data = {"identifier": "P1"} # Duplicate identifier + + response = session_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_partial_update_invalid_data(self, session_client, workspace, create_user): + """Test partial update with invalid data""" + project = Project.objects.create(name="Valid Project", identifier="VP", workspace=workspace) + + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + url = self.get_project_url(workspace.slug, pk=project.id) + update_data = {"name": ""} + + response = session_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_delete_project_success_project_admin(self, session_client, workspace, create_user): + """Test successful project deletion by project admin""" + project = Project.objects.create(name="Delete Me", identifier="DM", workspace=workspace) + + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = session_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Project.objects.filter(id=project.id).exists() + + @pytest.mark.django_db + def test_delete_project_success_workspace_admin(self, session_client, workspace): + """Test successful project deletion by workspace admin""" + # Create workspace admin user + workspace_admin = User.objects.create_user(email="admin@example.com", username="admin") + WorkspaceMember.objects.create(workspace=workspace, member=workspace_admin, role=20, is_active=True) + + project = Project.objects.create(name="Delete Me", identifier="DM", workspace=workspace) + + session_client.force_authenticate(user=workspace_admin) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = session_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Project.objects.filter(id=project.id).exists() + + @pytest.mark.django_db + def test_delete_project_forbidden_non_admin(self, session_client, workspace): + """Test that non-admin users cannot delete projects""" + # Create a member user (not admin) + member_user = User.objects.create_user(email="member@example.com", username="member") + WorkspaceMember.objects.create(workspace=workspace, member=member_user, role=15, is_active=True) + + project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace) + + ProjectMember.objects.create(project=project, member=member_user, role=15, is_active=True) + + session_client.force_authenticate(user=member_user) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = session_client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert Project.objects.filter(id=project.id).exists() + + @pytest.mark.django_db + def test_delete_project_unauthenticated(self, client, workspace): + """Test unauthenticated project deletion""" + project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = client.delete(url) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert Project.objects.filter(id=project.id).exists() diff --git a/apps/api/plane/tests/contract/app/test_workspace_app.py b/apps/api/plane/tests/contract/app/test_workspace_app.py new file mode 100644 index 00000000..47b04979 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_workspace_app.py @@ -0,0 +1,73 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from unittest.mock import patch + +from plane.db.models import Workspace, WorkspaceMember + + +@pytest.mark.contract +class TestWorkspaceAPI: + """Test workspace CRUD operations""" + + @pytest.mark.django_db + def test_create_workspace_empty_data(self, session_client): + """Test creating a workspace with empty data""" + url = reverse("workspace") + + # Test with empty data + response = session_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + @patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") + def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user): + """Test creating a workspace with valid data""" + url = reverse("workspace") + user = create_user # Use the create_user fixture directly as it returns a user object + + # Test with valid data - include all required fields + workspace_data = { + "name": "Plane", + "slug": "pla-ne-test", + "company_name": "Plane Inc.", + } + + # Make the request + response = session_client.post(url, workspace_data, format="json") + + # Check response status + assert response.status_code == status.HTTP_201_CREATED + + # Verify workspace was created + assert Workspace.objects.count() == 1 + + # Check if the member is created + assert WorkspaceMember.objects.count() == 1 + + # Check other values + workspace = Workspace.objects.get(slug=workspace_data["slug"]) + workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user).first() + assert workspace.owner == user + assert workspace_member.role == 20 + + # Verify the workspace_seed task was called + mock_workspace_seed.assert_called_once_with(response.data["id"]) + + @pytest.mark.django_db + @patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") + def test_create_duplicate_workspace(self, mock_workspace_seed, session_client): + """Test creating a duplicate workspace""" + url = reverse("workspace") + + # Create first workspace + session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json") + + # Try to create a workspace with the same slug + response = session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json") + + # The API returns 400 BAD REQUEST for duplicate slugs, not 409 CONFLICT + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Optionally check the error message to confirm it's related to the duplicate slug + assert "slug" in response.data diff --git a/apps/api/plane/tests/factories.py b/apps/api/plane/tests/factories.py new file mode 100644 index 00000000..b8cd7836 --- /dev/null +++ b/apps/api/plane/tests/factories.py @@ -0,0 +1,81 @@ +import factory +from uuid import uuid4 +from django.utils import timezone + +from plane.db.models import User, Workspace, WorkspaceMember, Project, ProjectMember + + +class UserFactory(factory.django.DjangoModelFactory): + """Factory for creating User instances""" + + class Meta: + model = User + django_get_or_create = ("email",) + + id = factory.LazyFunction(uuid4) + email = factory.Sequence(lambda n: f"user{n}@plane.so") + password = factory.PostGenerationMethodCall("set_password", "password") + first_name = factory.Sequence(lambda n: f"First{n}") + last_name = factory.Sequence(lambda n: f"Last{n}") + is_active = True + is_superuser = False + is_staff = False + + +class WorkspaceFactory(factory.django.DjangoModelFactory): + """Factory for creating Workspace instances""" + + class Meta: + model = Workspace + django_get_or_create = ("slug",) + + id = factory.LazyFunction(uuid4) + name = factory.Sequence(lambda n: f"Workspace {n}") + slug = factory.Sequence(lambda n: f"workspace-{n}") + owner = factory.SubFactory(UserFactory) + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class WorkspaceMemberFactory(factory.django.DjangoModelFactory): + """Factory for creating WorkspaceMember instances""" + + class Meta: + model = WorkspaceMember + + id = factory.LazyFunction(uuid4) + workspace = factory.SubFactory(WorkspaceFactory) + member = factory.SubFactory(UserFactory) + role = 20 # Admin role by default + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class ProjectFactory(factory.django.DjangoModelFactory): + """Factory for creating Project instances""" + + class Meta: + model = Project + django_get_or_create = ("name", "workspace") + + id = factory.LazyFunction(uuid4) + name = factory.Sequence(lambda n: f"Project {n}") + workspace = factory.SubFactory(WorkspaceFactory) + created_by = factory.SelfAttribute("workspace.owner") + updated_by = factory.SelfAttribute("workspace.owner") + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class ProjectMemberFactory(factory.django.DjangoModelFactory): + """Factory for creating ProjectMember instances""" + + class Meta: + model = ProjectMember + + id = factory.LazyFunction(uuid4) + project = factory.SubFactory(ProjectFactory) + member = factory.SubFactory(UserFactory) + role = 20 # Admin role by default + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) diff --git a/apps/api/plane/tests/smoke/__init__.py b/apps/api/plane/tests/smoke/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/tests/smoke/test_auth_smoke.py b/apps/api/plane/tests/smoke/test_auth_smoke.py new file mode 100644 index 00000000..c5a671e9 --- /dev/null +++ b/apps/api/plane/tests/smoke/test_auth_smoke.py @@ -0,0 +1,97 @@ +import pytest +import requests +from django.urls import reverse + + +@pytest.mark.smoke +class TestAuthSmoke: + """Smoke tests for authentication endpoints""" + + @pytest.mark.django_db + def test_login_endpoint_available(self, plane_server, create_user, user_data): + """Test that the login endpoint is available and responds correctly""" + # Get the sign-in URL + relative_url = reverse("sign-in") + url = f"{plane_server.url}{relative_url}" + + # 1. Test bad login - test with wrong password + response = requests.post(url, data={"email": user_data["email"], "password": "wrong-password"}) + + # For bad credentials, any of these status codes would be valid + # The test shouldn't be brittle to minor implementation changes + assert response.status_code != 500, "Authentication should not cause server errors" + assert response.status_code != 404, "Authentication endpoint should exist" + + if response.status_code == 200: + # If API returns 200 for failures, check the response body for error indication + if hasattr(response, "json"): + try: + data = response.json() + # JSON response might indicate error in its structure + assert ( + "error" in data or "error_code" in data or "detail" in data or response.url.endswith("sign-in") + ), "Error response should contain error details" + except ValueError: + # It's ok if response isn't JSON format + pass + elif response.status_code in [302, 303]: + # If it's a redirect, it should redirect to a login page or error page + redirect_url = response.headers.get("Location", "") + assert "error" in redirect_url or "sign-in" in redirect_url, ( + "Failed login should redirect to login page or error page" + ) + + # 2. Test good login with correct credentials + response = requests.post( + url, + data={"email": user_data["email"], "password": user_data["password"]}, + allow_redirects=False, # Don't follow redirects + ) + + # Successful auth should not be a client error or server error + assert response.status_code not in range(400, 600), ( + f"Authentication with valid credentials failed with status {response.status_code}" + ) + + # Specific validation based on response type + if response.status_code in [302, 303]: + # Redirect-based auth: check that redirect URL doesn't contain error + redirect_url = response.headers.get("Location", "") + assert "error" not in redirect_url and "error_code" not in redirect_url, ( + "Successful login redirect should not contain error parameters" + ) + + elif response.status_code == 200: + # API token-based auth: check for tokens or user session + if hasattr(response, "json"): + try: + data = response.json() + # If it's a token response + if "access_token" in data: + assert "refresh_token" in data, "JWT auth should return both access and refresh tokens" + # If it's a user session response + elif "user" in data: + assert "is_authenticated" in data and data["is_authenticated"], ( + "User session response should indicate authentication" + ) + # Otherwise it should at least indicate success + else: + assert not any(error_key in data for error_key in ["error", "error_code", "detail"]), ( + "Success response should not contain error keys" + ) + except ValueError: + # Non-JSON is acceptable if it's a redirect or HTML response + pass + + +@pytest.mark.smoke +class TestHealthCheckSmoke: + """Smoke test for health check endpoint""" + + def test_healthcheck_endpoint(self, plane_server): + """Test that the health check endpoint is available and responds correctly""" + # Make a request to the health check endpoint + response = requests.get(f"{plane_server.url}/") + + # Should be OK + assert response.status_code == 200, "Health check endpoint should return 200 OK" diff --git a/apps/api/plane/tests/unit/__init__.py b/apps/api/plane/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py new file mode 100644 index 00000000..98860365 --- /dev/null +++ b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py @@ -0,0 +1,170 @@ +import pytest +from plane.db.models import Project, ProjectMember, Issue, FileAsset +from unittest.mock import patch, MagicMock +from plane.bgtasks.copy_s3_object import ( + copy_s3_objects_of_description_and_assets, + copy_assets, +) +import base64 + + +@pytest.mark.unit +class TestCopyS3Objects: + """Test the copy_s3_objects_of_description_and_assets function""" + + @pytest.fixture + def project(self, create_user, workspace): + project = Project.objects.create(name="Test Project", identifier="test-project", workspace=workspace) + + ProjectMember.objects.create(project=project, member=create_user) + return project + + @pytest.fixture + def issue(self, workspace, project): + return Issue.objects.create( + name="Test Issue", + workspace=workspace, + project_id=project.id, + description_html='
    ', # noqa: E501 + ) + + @pytest.fixture + def file_asset(self, workspace, project, issue): + return FileAsset.objects.create( + issue=issue, + workspace=workspace, + project=project, + asset="workspace1/test-asset-1.jpg", + attributes={ + "name": "test-asset-1.jpg", + "size": 100, + "type": "image/jpeg", + }, + id="35e8b958-6ee5-43ce-ae56-fb0e776f421e", + entity_type="ISSUE_DESCRIPTION", + ) + + @pytest.mark.django_db + @patch("plane.bgtasks.copy_s3_object.S3Storage") + def test_copy_s3_objects_of_description_and_assets( + self, mock_s3_storage, create_user, workspace, project, issue, file_asset + ): + FileAsset.objects.create( + issue=issue, + workspace=workspace, + project=project, + asset="workspace1/test-asset-2.pdf", + attributes={ + "name": "test-asset-2.pdf", + "size": 100, + "type": "application/pdf", + }, + id="97988198-274f-4dfe-aa7a-4c0ffc684214", + entity_type="ISSUE_DESCRIPTION", + ) + + issue.save() + + # Set up mock S3 storage + mock_storage_instance = MagicMock() + mock_s3_storage.return_value = mock_storage_instance + + # Mock the external service call to avoid actual HTTP requests + with patch("plane.bgtasks.copy_s3_object.sync_with_external_service") as mock_sync: + mock_sync.return_value = { + "description": "test description", + "description_binary": base64.b64encode(b"test binary").decode(), + } + + # Call the actual function (not .delay()) + copy_s3_objects_of_description_and_assets("ISSUE", issue.id, project.id, "test-workspace", create_user.id) + + # Assert that copy_object was called for each asset + assert mock_storage_instance.copy_object.call_count == 2 + + # Get the updated issue and its new assets + updated_issue = Issue.objects.get(id=issue.id) + new_assets = FileAsset.objects.filter( + issue=updated_issue, + entity_type="ISSUE_DESCRIPTION", + ) + + # Verify new assets were created + assert new_assets.count() == 4 # 2 original + 2 copied + + @pytest.mark.django_db + @patch("plane.bgtasks.copy_s3_object.S3Storage") + def test_copy_assets_successful(self, mock_s3_storage, workspace, project, issue, file_asset): + """Test successful copying of assets""" + # Arrange + mock_storage_instance = MagicMock() + mock_s3_storage.return_value = mock_storage_instance + + # Act + result = copy_assets( + entity=issue, + entity_identifier=issue.id, + project_id=project.id, + asset_ids=[file_asset.id], + user_id=issue.created_by_id, + ) + + # Assert + # Verify S3 copy was called + mock_storage_instance.copy_object.assert_called_once() + + # Verify new asset was created + assert len(result) == 1 + new_asset_id = result[0]["new_asset_id"] + new_asset = FileAsset.objects.get(id=new_asset_id) + + # Verify asset properties were copied correctly + assert new_asset.workspace == workspace + assert new_asset.project_id == project.id + assert new_asset.entity_type == file_asset.entity_type + assert new_asset.attributes == file_asset.attributes + assert new_asset.size == file_asset.size + assert new_asset.is_uploaded is True + + @pytest.mark.django_db + @patch("plane.bgtasks.copy_s3_object.S3Storage") + def test_copy_assets_empty_asset_ids(self, mock_s3_storage, workspace, project, issue): + """Test copying with empty asset_ids list""" + # Arrange + mock_storage_instance = MagicMock() + mock_s3_storage.return_value = mock_storage_instance + + # Act + result = copy_assets( + entity=issue, + entity_identifier=issue.id, + project_id=project.id, + asset_ids=[], + user_id=issue.created_by_id, + ) + + # Assert + assert result == [] + mock_storage_instance.copy_object.assert_not_called() + + @pytest.mark.django_db + @patch("plane.bgtasks.copy_s3_object.S3Storage") + def test_copy_assets_nonexistent_asset(self, mock_s3_storage, workspace, project, issue): + """Test copying with non-existent asset ID""" + # Arrange + mock_storage_instance = MagicMock() + mock_s3_storage.return_value = mock_storage_instance + non_existent_id = "00000000-0000-0000-0000-000000000000" + + # Act + result = copy_assets( + entity=issue, + entity_identifier=issue.id, + project_id=project.id, + asset_ids=[non_existent_id], + user_id=issue.created_by_id, + ) + + # Assert + assert result == [] + mock_storage_instance.copy_object.assert_not_called() diff --git a/apps/api/plane/tests/unit/middleware/__init__.py b/apps/api/plane/tests/unit/middleware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/tests/unit/middleware/test_db_routing.py b/apps/api/plane/tests/unit/middleware/test_db_routing.py new file mode 100644 index 00000000..5ac71696 --- /dev/null +++ b/apps/api/plane/tests/unit/middleware/test_db_routing.py @@ -0,0 +1,419 @@ +""" +Unit tests for ReadReplicaRoutingMiddleware. +This module contains comprehensive tests for the ReadReplicaRoutingMiddleware +that handles intelligent database routing to read replicas based on HTTP methods +and view configuration. +Test Organization: +- TestReadReplicaRoutingMiddleware: Core middleware functionality +- TestProcessView: process_view method behavior +- TestReplicaDecisionLogic: Decision logic for replica usage +- TestAttributeDetection: View attribute detection methods +- TestExceptionHandling: Exception handling and cleanup +- TestRealViewIntegration: Real Django/DRF view integration +- TestEdgeCases: Edge cases and error conditions +""" + +import pytest +from unittest.mock import Mock, patch + +from django.http import HttpResponse +from django.test import RequestFactory +from django.views import View +from rest_framework.views import APIView +from rest_framework.viewsets import ViewSet + +from plane.middleware.db_routing import ReadReplicaRoutingMiddleware + + +# Pytest fixtures +@pytest.fixture +def mock_get_response(): + """Fixture for mocked get_response callable.""" + return Mock(return_value=HttpResponse()) + + +@pytest.fixture +def middleware(mock_get_response): + """Fixture for ReadReplicaRoutingMiddleware instance.""" + return ReadReplicaRoutingMiddleware(mock_get_response) + + +@pytest.fixture +def request_factory(): + """Fixture for Django RequestFactory.""" + return RequestFactory() + + +@pytest.fixture +def mock_view_func(): + """Fixture for a basic mocked view function.""" + view = Mock() + view.use_read_replica = True + return view + + +@pytest.fixture +def get_request(request_factory): + """Fixture for a GET request.""" + return request_factory.get("/api/test/") + + +@pytest.fixture +def post_request(request_factory): + """Fixture for a POST request.""" + return request_factory.post("/api/test/") + + +@pytest.mark.unit +class TestReadReplicaRoutingMiddleware: + """Test cases for ReadReplicaRoutingMiddleware core functionality.""" + + def test_middleware_initialization(self, middleware, mock_get_response): + """Test middleware initializes correctly with expected attributes.""" + assert middleware.get_response == mock_get_response + assert hasattr(middleware, "READ_ONLY_METHODS") + assert "GET" in middleware.READ_ONLY_METHODS + assert "HEAD" in middleware.READ_ONLY_METHODS + assert "OPTIONS" in middleware.READ_ONLY_METHODS + + def test_read_only_methods_constant(self, middleware): + """Test READ_ONLY_METHODS contains expected HTTP methods.""" + expected_methods = {"GET", "HEAD", "OPTIONS"} + assert middleware.READ_ONLY_METHODS == expected_methods + + @patch("plane.middleware.db_routing.set_use_read_replica") + @patch("plane.middleware.db_routing.clear_read_replica_context") + def test_call_routes_write_methods_to_primary( + self, mock_clear, mock_set, middleware, post_request, mock_get_response + ): + """Test __call__ routes write methods to primary database.""" + response = middleware(post_request) + + mock_set.assert_called_once_with(False) # Primary database + mock_clear.assert_called_once() + assert response == mock_get_response.return_value + + @patch("plane.middleware.db_routing.clear_read_replica_context") + def test_call_with_read_methods_waits_for_process_view( + self, mock_clear, middleware, get_request, mock_get_response + ): + """Test __call__ with read methods waits for process_view.""" + response = middleware(get_request) + + mock_clear.assert_called_once() + assert response == mock_get_response.return_value + + @patch("plane.middleware.db_routing.clear_read_replica_context") + def test_call_always_cleans_up_context(self, mock_clear, middleware, get_request): + """Test __call__ always cleans up context.""" + middleware(get_request) + + mock_clear.assert_called_once() + + @patch("plane.middleware.db_routing.clear_read_replica_context") + def test_call_cleans_up_context_on_exception(self, mock_clear, middleware, get_request, mock_get_response): + """Test __call__ cleans up context even if get_response raises.""" + mock_get_response.side_effect = Exception("Test exception") + + with pytest.raises(Exception, match="Test exception"): + middleware(get_request) + + mock_clear.assert_called_once() + + +@pytest.mark.unit +class TestProcessView: + """Test cases for process_view method functionality.""" + + @patch("plane.middleware.db_routing.set_use_read_replica") + def test_with_read_method_and_replica_true(self, mock_set, middleware, get_request): + """Test process_view with GET request and use_read_replica=True.""" + view_func = Mock() + view_func.use_read_replica = True + + result = middleware.process_view(get_request, view_func, (), {}) + + mock_set.assert_called_once_with(True) + assert result is None + + @patch("plane.middleware.db_routing.set_use_read_replica") + def test_with_read_method_and_replica_false(self, mock_set, middleware, get_request): + """Test process_view with GET request and use_read_replica=False.""" + view_func = Mock() + view_func.use_read_replica = False + + result = middleware.process_view(get_request, view_func, (), {}) + + mock_set.assert_called_once_with(False) + assert result is None + + @patch("plane.middleware.db_routing.set_use_read_replica") + def test_with_read_method_and_no_replica_attribute(self, mock_set, middleware, get_request): + """Test process_view with GET request and no use_read_replica attr.""" + view_func = Mock(spec=[]) # No use_read_replica attribute + + result = middleware.process_view(get_request, view_func, (), {}) + + mock_set.assert_called_once_with(False) # Default to primary + assert result is None + + def test_with_write_method_ignores_view_attributes(self, middleware, post_request): + """Test process_view with write methods ignores view attributes.""" + view_func = Mock() + view_func.use_read_replica = True # This should be ignored for POST + + result = middleware.process_view(post_request, view_func, (), {}) + + assert result is None # Should not process for write methods + + +@pytest.mark.unit +class TestReplicaDecisionLogic: + """Test cases for replica decision logic methods.""" + + def test_should_use_read_replica_with_true_attribute(self, middleware): + """Test _should_use_read_replica returns True for True attribute.""" + view_func = Mock() + view_func.use_read_replica = True + + result = middleware._should_use_read_replica(view_func) + + assert result is True + + def test_should_use_read_replica_with_false_attribute(self, middleware): + """Test _should_use_read_replica returns False for False attribute.""" + view_func = Mock() + view_func.use_read_replica = False + + result = middleware._should_use_read_replica(view_func) + + assert result is False + + def test_should_use_read_replica_with_no_attribute_defaults_false(self, middleware): + """Test _should_use_read_replica defaults to False for missing attr.""" + view_func = Mock(spec=[]) # No use_read_replica attribute + + result = middleware._should_use_read_replica(view_func) + + assert result is False + + +@pytest.mark.unit +class TestAttributeDetection: + """Test cases for view attribute detection methods.""" + + def test_get_use_replica_attribute_function_based_view(self, middleware): + """Test _get_use_replica_attribute with function-based view.""" + # Test with True + view_func = Mock() + view_func.use_read_replica = True + result = middleware._get_use_replica_attribute(view_func) + assert result is True + + # Test with False + view_func.use_read_replica = False + result = middleware._get_use_replica_attribute(view_func) + assert result is False + + # Test with no attribute + view_func = Mock(spec=[]) + result = middleware._get_use_replica_attribute(view_func) + assert result is None + + def test_get_use_replica_attribute_django_cbv(self, middleware): + """Test _get_use_replica_attribute with Django CBV wrapper.""" + view_class = Mock() + view_class.use_read_replica = True + view_func = Mock() + view_func.view_class = view_class + # Remove use_read_replica from view_func to ensure it checks view_class + del view_func.use_read_replica + + result = middleware._get_use_replica_attribute(view_func) + + assert result is True + + def test_get_use_replica_attribute_drf_wrapper(self, middleware): + """Test _get_use_replica_attribute with DRF wrapper.""" + + # Create a real object to avoid Mock issues + class ViewClass: + use_read_replica = True + + class ViewFunc: + cls = ViewClass() + + view_func = ViewFunc() + + result = middleware._get_use_replica_attribute(view_func) + + assert result is True + + def test_get_use_replica_attribute_priority_order(self, middleware): + """Test attribute priority: direct > view_class > cls.""" + view_func = Mock() + view_func.use_read_replica = True # Direct attribute (highest priority) + + # Add conflicting attributes with lower priority + view_class = Mock() + view_class.use_read_replica = False + view_func.view_class = view_class + + cls = Mock() + cls.use_read_replica = False + view_func.cls = cls + + result = middleware._get_use_replica_attribute(view_func) + + assert result is True # Should use direct attribute + + @pytest.mark.parametrize( + "value,expected", + [ + (True, True), + (False, False), + (1, True), + (0, False), + ("yes", True), + ("", False), + ([], False), + ([1], True), + (None, False), + ], + ) + def test_should_use_read_replica_truthy_falsy_values(self, middleware, value, expected): + """Test _should_use_read_replica with various truthy/falsy values.""" + + # Create a real object to test the attribute handling + class TestView: + pass + + view_func = TestView() + view_func.use_read_replica = value + + result = middleware._should_use_read_replica(view_func) + + assert result == expected + + +@pytest.mark.unit +class TestExceptionHandling: + """Test cases for exception handling and cleanup.""" + + @patch("plane.middleware.db_routing.clear_read_replica_context") + def test_process_exception_cleans_up_context(self, mock_clear, middleware, request_factory): + """Test process_exception cleans up context.""" + request = request_factory.get("/api/test/") + exception = Exception("Test exception") + + result = middleware.process_exception(request, exception) + + mock_clear.assert_called_once() + assert result is None # Don't handle the exception + + @patch("plane.middleware.db_routing.set_use_read_replica") + @patch("plane.middleware.db_routing.clear_read_replica_context") + def test_integration_full_request_cycle(self, mock_clear, mock_set, middleware, request_factory, mock_get_response): + """Test complete request cycle from __call__ through process_view.""" + request = request_factory.get("/api/test/") + view_func = Mock() + view_func.use_read_replica = True + + # Call middleware and process_view manually + response = middleware(request) + middleware.process_view(request, view_func, (), {}) + + mock_set.assert_called_once_with(True) + mock_clear.assert_called_once() + assert response == mock_get_response.return_value + + +@pytest.mark.unit +class TestRealViewIntegration: + """Test middleware with real Django/DRF view classes.""" + + @patch("plane.middleware.db_routing.set_use_read_replica") + def test_with_django_class_based_view(self, mock_set, middleware, request_factory): + """Test middleware with actual Django CBV.""" + + class TestView(View): + use_read_replica = True + + # Simulate Django's URL resolver creating a view wrapper + view_func = TestView.as_view() + request = request_factory.get("/api/test/") + + middleware.process_view(request, view_func, (), {}) + + mock_set.assert_called_once_with(True) + + @patch("plane.middleware.db_routing.set_use_read_replica") + def test_with_drf_api_view(self, mock_set, middleware, request_factory): + """Test middleware with DRF APIView.""" + + class TestAPIView(APIView): + use_read_replica = True + + # Simulate DRF's URL pattern creating a view wrapper + view_func = TestAPIView.as_view() + request = request_factory.get("/api/test/") + + middleware.process_view(request, view_func, (), {}) + + mock_set.assert_called_once_with(True) + + @patch("plane.middleware.db_routing.set_use_read_replica") + def test_with_drf_viewset(self, mock_set, middleware, request_factory): + """Test middleware with DRF ViewSet.""" + + class TestViewSet(ViewSet): + use_read_replica = True + + # Simulate DRF router creating viewset action + view_func = TestViewSet.as_view({"get": "list"}) + request = request_factory.get("/api/test/") + + middleware.process_view(request, view_func, (), {}) + + mock_set.assert_called_once_with(True) + + +@pytest.mark.unit +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_process_view_with_none_view_func(self, middleware, request_factory): + """Test process_view handles None view_func gracefully.""" + request = request_factory.get("/api/test/") + + result = middleware.process_view(request, None, (), {}) + + assert result is None # Should not crash + + def test_get_use_replica_attribute_with_attribute_error(self, middleware): + """Test _get_use_replica_attribute with view that raises AttributeError.""" + + # Create a view class that raises AttributeError on access + class ProblematicView: + def __getattr__(self, name): + if name == "use_read_replica": + raise AttributeError("Simulated attribute error") + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + view_func = ProblematicView() + + result = middleware._get_use_replica_attribute(view_func) + + assert result is None # Should handle gracefully + + def test_multiple_exception_calls_are_safe(self, middleware, request_factory): + """Test that multiple calls to process_exception don't cause issues.""" + request = request_factory.get("/api/test/") + exception = Exception("Test exception") + + # Call multiple times + result1 = middleware.process_exception(request, exception) + result2 = middleware.process_exception(request, exception) + + assert result1 is None # Both should return None safely + assert result2 is None diff --git a/apps/api/plane/tests/unit/models/__init__.py b/apps/api/plane/tests/unit/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/tests/unit/models/test_workspace_model.py b/apps/api/plane/tests/unit/models/test_workspace_model.py new file mode 100644 index 00000000..26a79751 --- /dev/null +++ b/apps/api/plane/tests/unit/models/test_workspace_model.py @@ -0,0 +1,44 @@ +import pytest +from uuid import uuid4 + +from plane.db.models import Workspace, WorkspaceMember + + +@pytest.mark.unit +class TestWorkspaceModel: + """Test the Workspace model""" + + @pytest.mark.django_db + def test_workspace_creation(self, create_user): + """Test creating a workspace""" + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user + ) + + # Verify it was created + assert workspace.id is not None + assert workspace.name == "Test Workspace" + assert workspace.slug == "test-workspace" + assert workspace.owner == create_user + + @pytest.mark.django_db + def test_workspace_member_creation(self, create_user): + """Test creating a workspace member""" + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user + ) + + # Create a workspace member + workspace_member = WorkspaceMember.objects.create( + workspace=workspace, + member=create_user, + role=20, # Admin role + ) + + # Verify it was created + assert workspace_member.id is not None + assert workspace_member.workspace == workspace + assert workspace_member.member == create_user + assert workspace_member.role == 20 diff --git a/apps/api/plane/tests/unit/serializers/__init__.py b/apps/api/plane/tests/unit/serializers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py b/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py new file mode 100644 index 00000000..eac92384 --- /dev/null +++ b/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py @@ -0,0 +1,69 @@ +import pytest + +from plane.db.models import ( + Workspace, + Project, + Issue, + User, + IssueAssignee, + WorkspaceMember, + ProjectMember, +) +from plane.app.serializers.workspace import IssueRecentVisitSerializer +from django.utils import timezone + + +@pytest.mark.unit +class TestIssueRecentVisitSerializer: + """Test the IssueRecentVisitSerializer""" + + def test_issue_recent_visit_serializer_fields(self, db): + """Test that the serializer includes the correct fields""" + + test_user_1 = User.objects.create(email="test_user_1@example.com", first_name="Test", last_name="User") + + # To test for deleted issue assignee + test_user_2 = User.objects.create( + email="test_user_2@example.com", + first_name="Other", + last_name="User", + username="some user name", + ) + + workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", owner=test_user_1) + + WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace) + + project = Project.objects.create(name="Test Project", identifier="test-project", workspace=workspace) + ProjectMember.objects.create(project=project, member=test_user_2) + + issue = Issue.objects.create( + name="Test Issue", + workspace=workspace, + project=project, + ) + + IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project) + + # Deleted issue assignee + IssueAssignee.objects.create( + issue=issue, + assignee=test_user_2, + project=project, + deleted_at=timezone.now(), + ) + + serialized_data = IssueRecentVisitSerializer( + issue, + ).data + + # Check fields are present and correct + assert "name" in serialized_data + assert "assignees" in serialized_data + assert "project_identifier" in serialized_data + + assert serialized_data["name"] == "Test Issue" + assert serialized_data["project_identifier"] == "TEST-PROJECT" + + # Only including non-deleted issue assignees + assert serialized_data["assignees"] == [test_user_1.id] diff --git a/apps/api/plane/tests/unit/serializers/test_workspace.py b/apps/api/plane/tests/unit/serializers/test_workspace.py new file mode 100644 index 00000000..21844c71 --- /dev/null +++ b/apps/api/plane/tests/unit/serializers/test_workspace.py @@ -0,0 +1,50 @@ +import pytest +from uuid import uuid4 + +from plane.api.serializers import WorkspaceLiteSerializer +from plane.db.models import Workspace, User + + +@pytest.mark.unit +class TestWorkspaceLiteSerializer: + """Test the WorkspaceLiteSerializer""" + + def test_workspace_lite_serializer_fields(self, db): + """Test that the serializer includes the correct fields""" + # Create a user to be the owner + owner = User.objects.create(email="test@example.com", first_name="Test", last_name="User") + + # Create a workspace with explicit ID to test serialization + workspace_id = uuid4() + workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", id=workspace_id, owner=owner) + + # Serialize the workspace + serialized_data = WorkspaceLiteSerializer(workspace).data + + # Check fields are present and correct + assert "name" in serialized_data + assert "slug" in serialized_data + assert "id" in serialized_data + + assert serialized_data["name"] == "Test Workspace" + assert serialized_data["slug"] == "test-workspace" + assert str(serialized_data["id"]) == str(workspace_id) + + def test_workspace_lite_serializer_read_only(self, db): + """Test that the serializer fields are read-only""" + # Create a user to be the owner + owner = User.objects.create(email="test2@example.com", first_name="Test", last_name="User") + + # Create a workspace + workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", id=uuid4(), owner=owner) + + # Try to update via serializer + serializer = WorkspaceLiteSerializer(workspace, data={"name": "Updated Name", "slug": "updated-slug"}) + + # Serializer should be valid (since read-only fields are ignored) + assert serializer.is_valid() + + # Save should not update the read-only fields + updated_workspace = serializer.save() + assert updated_workspace.name == "Test Workspace" + assert updated_workspace.slug == "test-workspace" diff --git a/apps/api/plane/tests/unit/utils/__init__.py b/apps/api/plane/tests/unit/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/tests/unit/utils/test_url.py b/apps/api/plane/tests/unit/utils/test_url.py new file mode 100644 index 00000000..465cb302 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_url.py @@ -0,0 +1,253 @@ +import pytest +from plane.utils.url import ( + contains_url, + is_valid_url, + normalize_url_path, +) + + +@pytest.mark.unit +class TestContainsURL: + """Test the contains_url function""" + + def test_contains_url_with_http_protocol(self): + """Test contains_url with HTTP protocol URLs""" + assert contains_url("Check out http://example.com") is True + assert contains_url("Visit http://google.com/search") is True + assert contains_url("http://localhost:8000") is True + + def test_contains_url_with_https_protocol(self): + """Test contains_url with HTTPS protocol URLs""" + assert contains_url("Check out https://example.com") is True + assert contains_url("Visit https://google.com/search") is True + assert contains_url("https://secure.example.com") is True + + def test_contains_url_with_www_prefix(self): + """Test contains_url with www prefix""" + assert contains_url("Visit www.example.com") is True + assert contains_url("Check www.google.com") is True + assert contains_url("Go to www.test-site.org") is True + + def test_contains_url_with_domain_patterns(self): + """Test contains_url with domain patterns""" + assert contains_url("Visit example.com") is True + assert contains_url("Check google.org") is True + assert contains_url("Go to test-site.co.uk") is True + assert contains_url("Visit sub.domain.com") is True + + def test_contains_url_with_ip_addresses(self): + """Test contains_url with IP addresses""" + assert contains_url("Connect to 192.168.1.1") is True + assert contains_url("Visit 10.0.0.1") is True + assert contains_url("Check 127.0.0.1") is True + assert contains_url("Go to 8.8.8.8") is True + + def test_contains_url_case_insensitive(self): + """Test contains_url is case insensitive""" + assert contains_url("Check HTTP://EXAMPLE.COM") is True + assert contains_url("Visit WWW.GOOGLE.COM") is True + assert contains_url("Go to Https://Test.Com") is True + + def test_contains_url_with_no_urls(self): + """Test contains_url with text that doesn't contain URLs""" + assert contains_url("This is just plain text") is False + assert contains_url("No URLs here!") is False + assert contains_url("com org net") is False # Just TLD words + assert contains_url("192.168") is False # Incomplete IP + assert contains_url("") is False # Empty string + + def test_contains_url_edge_cases(self): + """Test contains_url with edge cases""" + assert contains_url("example.c") is False # TLD too short + assert contains_url("999.999.999.999") is False # Invalid IP (octets > 255) + assert contains_url("just-a-hyphen") is False # No domain + assert contains_url("www.") is False # Incomplete www - needs at least one char after dot + + def test_contains_url_length_limit_under_1000(self): + """Test contains_url with input under 1000 characters containing URLs""" + # Create a string under 1000 characters with a URL + text_with_url = "a" * 970 + " https://example.com" # 970 + 1 + 19 = 990 chars + assert len(text_with_url) < 1000 + assert contains_url(text_with_url) is True + + # Test with exactly 1000 characters + text_exact_1000 = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars + assert len(text_exact_1000) == 1000 + assert contains_url(text_exact_1000) is True + + def test_contains_url_length_limit_over_1000(self): + """Test contains_url with input over 1000 characters returns False""" + # Create a string over 1000 characters with a URL + text_with_url = "a" * 982 + "https://example.com" # 982 + 19 = 1001 chars + assert len(text_with_url) > 1000 + assert contains_url(text_with_url) is False + + # Test with much longer input + long_text_with_url = "a" * 5000 + " https://example.com" + assert contains_url(long_text_with_url) is False + + def test_contains_url_length_limit_exactly_1000(self): + """Test contains_url with input exactly 1000 characters""" + # Test with exactly 1000 characters without URL + text_no_url = "a" * 1000 + assert len(text_no_url) == 1000 + assert contains_url(text_no_url) is False + + # Test with exactly 1000 characters with URL at the end + text_with_url = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars + assert len(text_with_url) == 1000 + assert contains_url(text_with_url) is True + + def test_contains_url_line_length_scenarios(self): + """Test contains_url with realistic line length scenarios""" + # Test with multiline input where total is under 1000 but we test line processing + # Short lines with URL + multiline_short = "Line 1\nLine 2 with https://example.com\nLine 3" + assert contains_url(multiline_short) is True + + # Multiple lines under total limit + multiline_text = "a" * 200 + "\n" + "b" * 200 + "https://example.com\n" + "c" * 200 + assert len(multiline_text) < 1000 + assert contains_url(multiline_text) is True + + def test_contains_url_total_length_vs_line_length(self): + """Test the interaction between total length limit and line processing""" + # Test that total length limit takes precedence + # Even if individual lines would be processed, total > 1000 means immediate False + over_limit_text = "a" * 1001 # No URL, but over total limit + assert contains_url(over_limit_text) is False + + # Test that under total limit, line processing works normally + under_limit_with_url = "a" * 900 + "https://example.com" # 919 chars total + assert len(under_limit_with_url) < 1000 + assert contains_url(under_limit_with_url) is True + + def test_contains_url_multiline_mixed_lengths(self): + """Test contains_url with multiple lines of different lengths""" + # Test realistic multiline scenario under 1000 chars total + multiline_text = ( + "Short line\n" + + "a" * 400 + + "https://example.com\n" # Line with URL + + "b" * 300 # Another line + ) + assert len(multiline_text) < 1000 + assert contains_url(multiline_text) is True + + # Test multiline without URLs + multiline_no_url = "Short line\n" + "a" * 400 + "\n" + "b" * 300 + assert len(multiline_no_url) < 1000 + assert contains_url(multiline_no_url) is False + + def test_contains_url_edge_cases_with_length_limits(self): + """Test contains_url edge cases related to length limits""" + # Empty string + assert contains_url("") is False + + # Very short string with URL + assert contains_url("http://a.co") is True + + # String with newlines and mixed content + mixed_content = "Line 1\nLine 2 with https://example.com\nLine 3" + assert contains_url(mixed_content) is True + + # String with many newlines under total limit + many_newlines = "\n" * 500 + "https://example.com" + assert len(many_newlines) < 1000 + assert contains_url(many_newlines) is True + + +@pytest.mark.unit +class TestIsValidURL: + """Test the is_valid_url function""" + + def test_is_valid_url_with_valid_urls(self): + """Test is_valid_url with valid URLs""" + assert is_valid_url("https://example.com") is True + assert is_valid_url("http://google.com") is True + assert is_valid_url("https://sub.domain.com/path") is True + assert is_valid_url("http://localhost:8000") is True + assert is_valid_url("https://example.com/path?query=1") is True + assert is_valid_url("ftp://files.example.com") is True + + def test_is_valid_url_with_invalid_urls(self): + """Test is_valid_url with invalid URLs""" + assert is_valid_url("not a url") is False + assert is_valid_url("example.com") is False # No scheme + assert is_valid_url("https://") is False # No netloc + assert is_valid_url("") is False # Empty string + assert is_valid_url("://example.com") is False # No scheme + assert is_valid_url("https:/example.com") is False # Malformed + + def test_is_valid_url_with_non_string_input(self): + """Test is_valid_url with non-string input""" + assert is_valid_url(None) is False + assert is_valid_url([]) is False + assert is_valid_url({}) is False + + def test_is_valid_url_with_special_schemes(self): + """Test is_valid_url with special URL schemes""" + assert is_valid_url("ftp://ftp.example.com") is True + assert is_valid_url("mailto:user@example.com") is False + assert is_valid_url("file:///path/to/file") is False + + +@pytest.mark.unit +class TestNormalizeURLPath: + """Test the normalize_url_path function""" + + def test_normalize_url_path_with_multiple_slashes(self): + """Test normalize_url_path with multiple consecutive slashes""" + result = normalize_url_path("https://example.com//foo///bar//baz") + assert result == "https://example.com/foo/bar/baz" + + def test_normalize_url_path_with_query_and_fragment(self): + """Test normalize_url_path preserves query and fragment""" + result = normalize_url_path("https://example.com//foo///bar//baz?x=1&y=2#fragment") + assert result == "https://example.com/foo/bar/baz?x=1&y=2#fragment" + + def test_normalize_url_path_with_no_redundant_slashes(self): + """Test normalize_url_path with already normalized URL""" + url = "https://example.com/foo/bar/baz?x=1#fragment" + result = normalize_url_path(url) + assert result == url + + def test_normalize_url_path_with_root_path(self): + """Test normalize_url_path with root path""" + result = normalize_url_path("https://example.com//") + assert result == "https://example.com/" + + def test_normalize_url_path_with_empty_path(self): + """Test normalize_url_path with empty path""" + result = normalize_url_path("https://example.com") + assert result == "https://example.com" + + def test_normalize_url_path_with_complex_path(self): + """Test normalize_url_path with complex path structure""" + result = normalize_url_path("https://example.com///api//v1///users//123//profile") + assert result == "https://example.com/api/v1/users/123/profile" + + def test_normalize_url_path_with_different_schemes(self): + """Test normalize_url_path with different URL schemes""" + # HTTP + result = normalize_url_path("http://example.com//path") + assert result == "http://example.com/path" + + # FTP + result = normalize_url_path("ftp://ftp.example.com//files//document.txt") + assert result == "ftp://ftp.example.com/files/document.txt" + + def test_normalize_url_path_with_port(self): + """Test normalize_url_path with port number""" + result = normalize_url_path("https://example.com:8080//api//v1") + assert result == "https://example.com:8080/api/v1" + + def test_normalize_url_path_edge_cases(self): + """Test normalize_url_path with edge cases""" + # Many consecutive slashes + result = normalize_url_path("https://example.com///////path") + assert result == "https://example.com/path" + + # Mixed single and multiple slashes + result = normalize_url_path("https://example.com/a//b/c///d") + assert result == "https://example.com/a/b/c/d" diff --git a/apps/api/plane/tests/unit/utils/test_uuid.py b/apps/api/plane/tests/unit/utils/test_uuid.py new file mode 100644 index 00000000..d47e59c4 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_uuid.py @@ -0,0 +1,49 @@ +import uuid +import pytest +from plane.utils.uuid import is_valid_uuid, convert_uuid_to_integer + + +@pytest.mark.unit +class TestUUIDUtils: + """Test the UUID utilities""" + + def test_is_valid_uuid_with_valid_uuid(self): + """Test is_valid_uuid with a valid UUID""" + # Generate a valid UUID + valid_uuid = str(uuid.uuid4()) + assert is_valid_uuid(valid_uuid) is True + + def test_is_valid_uuid_with_invalid_uuid(self): + """Test is_valid_uuid with invalid UUID strings""" + # Test with different invalid formats + assert is_valid_uuid("not-a-uuid") is False + assert is_valid_uuid("123456789") is False + assert is_valid_uuid("") is False + assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1 + + def test_convert_uuid_to_integer(self): + """Test convert_uuid_to_integer function""" + # Create a known UUID + test_uuid = uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479") + + # Convert to integer + result = convert_uuid_to_integer(test_uuid) + + # Check that the result is an integer + assert isinstance(result, int) + + # Ensure consistent results with the same input + assert convert_uuid_to_integer(test_uuid) == result + + # Different UUIDs should produce different integers + different_uuid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000") + assert convert_uuid_to_integer(different_uuid) != result + + def test_convert_uuid_to_integer_string_input(self): + """Test convert_uuid_to_integer handles string UUID""" + # Test with a UUID string + test_uuid_str = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + test_uuid = uuid.UUID(test_uuid_str) + + # Should get the same result whether passing UUID or string + assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str) diff --git a/apps/api/plane/urls.py b/apps/api/plane/urls.py new file mode 100644 index 00000000..4b106255 --- /dev/null +++ b/apps/api/plane/urls.py @@ -0,0 +1,43 @@ +"""plane URL Configuration""" + +from django.conf import settings +from django.urls import include, path, re_path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) + +handler404 = "plane.app.views.error_404.custom_404_view" + +urlpatterns = [ + path("api/", include("plane.app.urls")), + path("api/public/", include("plane.space.urls")), + path("api/instances/", include("plane.license.urls")), + path("api/v1/", include("plane.api.urls")), + path("auth/", include("plane.authentication.urls")), + path("", include("plane.web.urls")), +] + +if settings.ENABLE_DRF_SPECTACULAR: + urlpatterns += [ + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "api/schema/swagger-ui/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path( + "api/schema/redoc/", + SpectacularRedocView.as_view(url_name="schema"), + name="redoc", + ), + ] + +if settings.DEBUG: + try: + import debug_toolbar + + urlpatterns = [re_path(r"^__debug__/", include(debug_toolbar.urls))] + urlpatterns + except ImportError: + pass diff --git a/apps/api/plane/utils/__init__.py b/apps/api/plane/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/utils/analytics_plot.py b/apps/api/plane/utils/analytics_plot.py new file mode 100644 index 00000000..12fa39cc --- /dev/null +++ b/apps/api/plane/utils/analytics_plot.py @@ -0,0 +1,235 @@ +# Python imports +from datetime import timedelta +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.functions import ( + Coalesce, + Concat, + ExtractMonth, + ExtractYear, + TruncDate, + Cast, +) +from django.utils import timezone + +# Module imports +from plane.db.models import Issue, Project + + +def annotate_with_monthly_dimension(queryset, field_name, attribute): + # Get the year and the months + year = ExtractYear(field_name) + month = ExtractMonth(field_name) + # Concat the year and month + dimension = Concat(year, Value("-"), month, output_field=CharField()) + # Annotate the dimension + return queryset.annotate(**{attribute: dimension}) + + +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") + return queryset, "dimension" + else: + return queryset.annotate(dimension=F(x_axis)), "dimension" + + +def sort_data(data, temp_axis): + # When the axis is in priority order by + if temp_axis == "priority": + order = ["low", "medium", "high", "urgent", "none"] + return {key: data[key] for key in order if key in data} + else: + return dict(sorted(data.items(), key=lambda x: (x[0] == "none", x[0]))) + + +def build_graph_plot(queryset, x_axis, y_axis, segment=None): + # temp x_axis + temp_axis = x_axis + # Extract the x_axis and queryset + queryset, x_axis = extract_axis(queryset, x_axis) + if x_axis == "dimension": + queryset = queryset.exclude(dimension__isnull=True) + + # + if segment in ["created_at", "start_date", "target_date", "completed_at"]: + queryset = annotate_with_monthly_dimension(queryset, segment, "segmented") + segment = "segmented" + + queryset = queryset.values(x_axis) + + # Issue count + if y_axis == "issue_count": + queryset = queryset.annotate( + is_null=Case( + When(dimension__isnull=True, then=Value("None")), + default=Value("not_null"), + output_field=models.CharField(max_length=8), + ), + dimension_ex=Coalesce("dimension", Value("null")), + ).values("dimension") + queryset = queryset.annotate(segment=F(segment)) if segment else queryset + queryset = queryset.values("dimension", "segment") if segment else queryset.values("dimension") + queryset = queryset.annotate(count=Count("*")).order_by("dimension") + + # Estimate + else: + 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.values("dimension", "segment", "estimate") if segment else queryset.values("dimension", "estimate") + ) + + result_values = list(queryset) + grouped_data = {str(key): list(items) 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): + # Total Issues in Cycle or Module + total_issues = queryset.total_issues + # check whether the estimate is a point or not + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + if estimate_type and plot_type == "points" and cycle_id: + 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) + + issue_estimates = [float(value) for value in issue_estimates] + total_estimate_points = sum(issue_estimates) + + if estimate_type and plot_type == "points" and module_id: + 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) + + issue_estimates = [float(value) for value in issue_estimates] + total_estimate_points = sum(issue_estimates) + + if cycle_id: + if queryset.end_date and queryset.start_date: + # Get all dates between the two dates + date_range = [ + (queryset.start_date + timedelta(days=x)).date() + for x in range((queryset.end_date.date() - queryset.start_date.date()).days + 1) + ] + else: + date_range = [] + + chart_data = {str(date): 0 for date in date_range} + + if plot_type == "points": + completed_issues_estimate_point_distribution = ( + 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, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .values("date", "estimate_point__value") + .order_by("date") + ) + else: + completed_issues_distribution = ( + Issue.issue_objects.filter( + 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") + .annotate(total_completed=Count("id")) + .values("date", "total_completed") + .order_by("date") + ) + + 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) + ] + + chart_data = {str(date): 0 for date in date_range} + + if plot_type == "points": + completed_issues_estimate_point_distribution = ( + 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, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .values("date", "estimate_point__value") + .order_by("date") + ) + else: + completed_issues_distribution = ( + Issue.issue_objects.filter( + 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") + .annotate(total_completed=Count("id")) + .values("date", "total_completed") + .order_by("date") + ) + + if plot_type == "points": + for date in date_range: + cumulative_pending_issues = total_estimate_points + total_completed = 0 + total_completed = sum( + float(item["estimate_point__value"]) + for item in completed_issues_estimate_point_distribution + if item["date"] is not None and item["date"] <= date + ) + cumulative_pending_issues -= total_completed + if date > timezone.now().date(): + chart_data[str(date)] = None + else: + chart_data[str(date)] = cumulative_pending_issues + else: + for date in date_range: + cumulative_pending_issues = total_issues + total_completed = 0 + total_completed = sum( + item["total_completed"] + for item in completed_issues_distribution + if item["date"] is not None and item["date"] <= date + ) + cumulative_pending_issues -= total_completed + if date > timezone.now().date(): + chart_data[str(date)] = None + else: + chart_data[str(date)] = cumulative_pending_issues + + return chart_data diff --git a/apps/api/plane/utils/build_chart.py b/apps/api/plane/utils/build_chart.py new file mode 100644 index 00000000..9a2d9c3a --- /dev/null +++ b/apps/api/plane/utils/build_chart.py @@ -0,0 +1,190 @@ +from typing import Dict, Any, Tuple, Optional, List, Union + + +# Django imports +from django.db.models import ( + Count, + F, + QuerySet, + Aggregate, +) + +from plane.db.models import Issue +from rest_framework.exceptions import ValidationError + + +x_axis_mapper = { + "STATES": "STATES", + "STATE_GROUPS": "STATE_GROUPS", + "LABELS": "LABELS", + "ASSIGNEES": "ASSIGNEES", + "ESTIMATE_POINTS": "ESTIMATE_POINTS", + "CYCLES": "CYCLES", + "MODULES": "MODULES", + "PRIORITY": "PRIORITY", + "START_DATE": "START_DATE", + "TARGET_DATE": "TARGET_DATE", + "CREATED_AT": "CREATED_AT", + "COMPLETED_AT": "COMPLETED_AT", + "CREATED_BY": "CREATED_BY", +} + + +def get_y_axis_filter(y_axis: str) -> Dict[str, Any]: + filter_mapping = { + "WORK_ITEM_COUNT": {"id": F("id")}, + } + return filter_mapping.get(y_axis, {}) + + +def get_x_axis_field() -> Dict[str, Tuple[str, str, Optional[Dict[str, Any]]]]: + return { + "STATES": ("state__id", "state__name", None), + "STATE_GROUPS": ("state__group", "state__group", None), + "LABELS": ( + "labels__id", + "labels__name", + {"label_issue__deleted_at__isnull": True}, + ), + "ASSIGNEES": ( + "assignees__id", + "assignees__display_name", + {"issue_assignee__deleted_at__isnull": True}, + ), + "ESTIMATE_POINTS": ("estimate_point__value", "estimate_point__key", None), + "CYCLES": ( + "issue_cycle__cycle_id", + "issue_cycle__cycle__name", + {"issue_cycle__deleted_at__isnull": True}, + ), + "MODULES": ( + "issue_module__module_id", + "issue_module__module__name", + {"issue_module__deleted_at__isnull": True}, + ), + "PRIORITY": ("priority", "priority", None), + "START_DATE": ("start_date", "start_date", None), + "TARGET_DATE": ("target_date", "target_date", None), + "CREATED_AT": ("created_at__date", "created_at__date", None), + "COMPLETED_AT": ("completed_at__date", "completed_at__date", None), + "CREATED_BY": ("created_by_id", "created_by__display_name", None), + } + + +def process_grouped_data( + data: List[Dict[str, Any]], +) -> Tuple[List[Dict[str, Any]], Dict[str, str]]: + response = {} + schema = {} + + for item in data: + key = item["key"] + if key not in response: + response[key] = { + "key": key if key else "none", + "name": (item.get("display_name", key) if item.get("display_name", key) else "None"), + "count": 0, + } + group_key = str(item["group_key"]) if item["group_key"] else "none" + schema[group_key] = item.get("group_name", item["group_key"]) + schema[group_key] = schema[group_key] if schema[group_key] else "None" + response[key][group_key] = response[key].get(group_key, 0) + item["count"] + response[key]["count"] += item["count"] + + return list(response.values()), schema + + +def build_number_chart_response( + queryset: QuerySet[Issue], + y_axis_filter: Dict[str, Any], + y_axis: str, + aggregate_func: Aggregate, +) -> List[Dict[str, Any]]: + count = queryset.filter(**y_axis_filter).aggregate(total=aggregate_func).get("total", 0) + return [{"key": y_axis, "name": y_axis, "count": count}] + + +def build_grouped_chart_response( + queryset: QuerySet[Issue], + id_field: str, + name_field: str, + group_field: str, + group_name_field: str, + aggregate_func: Aggregate, +) -> Tuple[List[Dict[str, Any]], Dict[str, str]]: + data = ( + queryset.annotate( + key=F(id_field), + group_key=F(group_field), + group_name=F(group_name_field), + display_name=F(name_field) if name_field else F(id_field), + ) + .values("key", "group_key", "group_name", "display_name") + .annotate(count=aggregate_func) + .order_by("-count") + ) + return process_grouped_data(data) + + +def build_simple_chart_response( + queryset: QuerySet, id_field: str, name_field: str, aggregate_func: Aggregate +) -> List[Dict[str, Any]]: + data = ( + queryset.annotate(key=F(id_field), display_name=F(name_field) if name_field else F(id_field)) + .values("key", "display_name") + .annotate(count=aggregate_func) + .order_by("key") + ) + + return [ + { + "key": item["key"] if item["key"] else "None", + "name": item["display_name"] if item["display_name"] else "None", + "count": item["count"], + } + for item in data + ] + + +def build_analytics_chart( + queryset: QuerySet[Issue], + x_axis: str, + group_by: Optional[str] = None, + date_filter: Optional[str] = None, +) -> Dict[str, Union[List[Dict[str, Any]], Dict[str, str]]]: + # Validate x_axis + if x_axis not in x_axis_mapper: + raise ValidationError(f"Invalid x_axis field: {x_axis}") + + # Validate group_by + if group_by and group_by not in x_axis_mapper: + raise ValidationError(f"Invalid group_by field: {group_by}") + + field_mapping = get_x_axis_field() + + id_field, name_field, additional_filter = field_mapping.get(x_axis, (None, None, {})) + group_field, group_name_field, group_additional_filter = field_mapping.get(group_by, (None, None, {})) + + # Apply additional filters if they exist + if additional_filter or {}: + queryset = queryset.filter(**additional_filter) + + if group_additional_filter or {}: + queryset = queryset.filter(**group_additional_filter) + + aggregate_func = Count("id", distinct=True) + + if group_field: + response, schema = build_grouped_chart_response( + queryset, + id_field, + name_field, + group_field, + group_name_field, + aggregate_func, + ) + else: + response = build_simple_chart_response(queryset, id_field, name_field, aggregate_func) + schema = {} + + return {"data": response, "schema": schema} diff --git a/apps/api/plane/utils/cache.py b/apps/api/plane/utils/cache.py new file mode 100644 index 00000000..da3fd451 --- /dev/null +++ b/apps/api/plane/utils/cache.py @@ -0,0 +1,84 @@ +# Python imports +from functools import wraps + +# Django imports +from django.conf import settings +from django.core.cache import cache + +# Third party imports +from rest_framework.response import Response + + +def generate_cache_key(custom_path, auth_header=None): + """Generate a cache key with the given params""" + if auth_header: + key_data = f"{custom_path}:{auth_header}" + else: + key_data = custom_path + return key_data + + +def cache_response(timeout=60 * 60, path=None, user=True): + """decorator to create cache per user""" + + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(instance, request, *args, **kwargs): + # Function to generate cache key + auth_header = None if request.user.is_anonymous 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"]) + response = view_func(instance, request, *args, **kwargs) + if response.status_code == 200 and not settings.DEBUG: + cache.set( + key, + {"data": response.data, "status": response.status_code}, + timeout, + ) + + return response + + return _wrapped_view + + return decorator + + +def invalidate_cache_directly(path=None, url_params=False, user=True, request=None, multiple=False): + if url_params and path: + path_with_values = path + # Assuming `kwargs` could be passed directly if needed, otherwise, skip this part + for key, value in request.resolver_match.kwargs.items(): + path_with_values = path_with_values.replace(f":{key}", str(value)) + custom_path = path_with_values + else: + custom_path = path if path is not None else request.get_full_path() + auth_header = None if request and request.user.is_anonymous else str(request.user.id) if user else None + key = generate_cache_key(custom_path, auth_header) + + if multiple: + cache.delete_many(keys=cache.keys(f"*{key}*")) + else: + cache.delete(key) + + +def invalidate_cache(path=None, url_params=False, user=True, multiple=False): + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(instance, request, *args, **kwargs): + # invalidate the cache + invalidate_cache_directly( + path=path, + url_params=url_params, + user=user, + request=request, + multiple=multiple, + ) + return view_func(instance, request, *args, **kwargs) + + return _wrapped_view + + return decorator diff --git a/apps/api/plane/utils/color.py b/apps/api/plane/utils/color.py new file mode 100644 index 00000000..8c45389b --- /dev/null +++ b/apps/api/plane/utils/color.py @@ -0,0 +1,9 @@ +import random +import string + + +def get_random_color(): + """ + Get a random color in hex format + """ + return "#" + "".join(random.choices(string.hexdigits, k=6)) diff --git a/apps/api/plane/utils/constants.py b/apps/api/plane/utils/constants.py new file mode 100644 index 00000000..0d5e64a2 --- /dev/null +++ b/apps/api/plane/utils/constants.py @@ -0,0 +1,67 @@ +RESTRICTED_WORKSPACE_SLUGS = [ + "404", + "accounts", + "api", + "create-workspace", + "god-mode", + "installations", + "invitations", + "onboarding", + "profile", + "spaces", + "workspace-invitations", + "password", + "flags", + "monitor", + "monitoring", + "ingest", + "plane-pro", + "plane-ultimate", + "enterprise", + "plane-enterprise", + "disco", + "silo", + "chat", + "calendar", + "drive", + "channels", + "upgrade", + "billing", + "sign-in", + "sign-up", + "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/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py new file mode 100644 index 00000000..ff06a562 --- /dev/null +++ b/apps/api/plane/utils/content_validator.py @@ -0,0 +1,247 @@ +# Python imports +import base64 +import nh3 +from plane.utils.exception_logger import log_exception +from bs4 import BeautifulSoup +from collections import defaultdict +import logging + +logger = logging.getLogger("plane.api") + +# Maximum allowed size for binary data (10MB) +MAX_SIZE = 10 * 1024 * 1024 + +# Suspicious patterns for binary data content +SUSPICIOUS_BINARY_PATTERNS = [ + " MAX_SIZE: + return False, "Binary data exceeds maximum size limit (10MB)" + + # Basic format validation + if len(binary_data) < 4: + return False, "Binary data too short to be valid document format" + + # Check for suspicious text patterns (HTML/JS) + try: + decoded_text = binary_data.decode("utf-8", errors="ignore")[:200] + if any( + pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS + ): + return False, "Binary data contains suspicious content patterns" + except Exception: + pass # Binary data might not be decodable as text, which is fine + + return True, None + + +# Combine custom components and editor-specific nodes into a single set of tags +CUSTOM_TAGS = { + # editor node/tag names + "mention-component", + "label", + "input", + "image-component", +} +ALLOWED_TAGS = nh3.ALLOWED_TAGS | CUSTOM_TAGS + +# Merge nh3 defaults with all attributes used across our custom components +ATTRIBUTES = { + "*": { + "class", + "id", + "title", + "role", + "aria-label", + "aria-hidden", + "style", + "start", + "type", + "xmlns", + # common editor data-* attributes seen in stored HTML + # (wildcards like data-* are NOT supported by nh3; we add known keys + # here and dynamically include all data-* seen in the input below) + "data-tight", + "data-node-type", + "data-type", + "data-checked", + "data-background-color", + "data-text-color", + "data-name", + # callout attributes + "data-icon-name", + "data-icon-color", + "data-background", + "data-emoji-unicode", + "data-emoji-url", + "data-logo-in-use", + "data-block-type", + }, + "a": {"href", "target"}, + # editor node/tag attributes + "image-component": { + "id", + "width", + "height", + "aspectRatio", + "aspectratio", + "src", + "alignment", + }, + "img": { + "width", + "height", + "aspectRatio", + "aspectratio", + "alignment", + "src", + "alt", + "title", + }, + "mention-component": {"id", "entity_identifier", "entity_name"}, + "th": { + "colspan", + "rowspan", + "colwidth", + "background", + "hideContent", + "hidecontent", + "style", + }, + "td": { + "colspan", + "rowspan", + "colwidth", + "background", + "textColor", + "textcolor", + "hideContent", + "hidecontent", + "style", + }, + "tr": {"background", "textColor", "textcolor", "style"}, + "pre": {"language"}, + "code": {"language", "spellcheck"}, + "input": {"type", "checked"}, +} + +SAFE_PROTOCOLS = {"http", "https", "mailto", "tel"} + + +def _compute_html_sanitization_diff(before_html: str, after_html: str): + """ + Compute a coarse diff between original and sanitized HTML. + + Returns a dict with: + - removed_tags: mapping[tag] -> removed_count + - removed_attributes: mapping[tag] -> sorted list of attribute names removed + """ + try: + + def collect(soup): + tag_counts = defaultdict(int) + attrs_by_tag = defaultdict(set) + for el in soup.find_all(True): + tag_name = (el.name or "").lower() + if not tag_name: + continue + tag_counts[tag_name] += 1 + for attr_name in list(el.attrs.keys()): + if isinstance(attr_name, str) and attr_name: + attrs_by_tag[tag_name].add(attr_name.lower()) + return tag_counts, attrs_by_tag + + soup_before = BeautifulSoup(before_html or "", "html.parser") + soup_after = BeautifulSoup(after_html or "", "html.parser") + + counts_before, attrs_before = collect(soup_before) + counts_after, attrs_after = collect(soup_after) + + removed_tags = {} + for tag, cnt_before in counts_before.items(): + cnt_after = counts_after.get(tag, 0) + if cnt_after < cnt_before: + removed = cnt_before - cnt_after + removed_tags[tag] = removed + + removed_attributes = {} + for tag, before_set in attrs_before.items(): + after_set = attrs_after.get(tag, set()) + removed = before_set - after_set + if removed: + removed_attributes[tag] = sorted(list(removed)) + + return {"removed_tags": removed_tags, "removed_attributes": removed_attributes} + except Exception: + # Best-effort only; if diffing fails we don't block the request + return {"removed_tags": {}, "removed_attributes": {}} + + +def validate_html_content(html_content: str): + """ + Sanitize HTML content using nh3. + Returns a tuple: (is_valid, error_message, clean_html) + """ + if not html_content: + return True, None, None + + # Size check - 10MB limit (consistent with binary validation) + if len(html_content.encode("utf-8")) > MAX_SIZE: + return False, "HTML content exceeds maximum size limit (10MB)", None + + try: + clean_html = nh3.clean( + html_content, + tags=ALLOWED_TAGS, + attributes=ATTRIBUTES, + url_schemes=SAFE_PROTOCOLS, + ) + # Report removals to logger (Sentry) if anything was stripped + diff = _compute_html_sanitization_diff(html_content, clean_html) + if diff.get("removed_tags") or diff.get("removed_attributes"): + try: + import json + + summary = json.dumps(diff) + except Exception: + summary = str(diff) + logger.warning(f"HTML sanitization removals: {summary}") + log_exception( + ValueError(f"HTML sanitization removals: {summary}"), + warning=True, + ) + return True, None, clean_html + except Exception as e: + log_exception(e) + return False, "Failed to sanitize HTML", None diff --git a/apps/api/plane/utils/core/__init__.py b/apps/api/plane/utils/core/__init__.py new file mode 100644 index 00000000..37c6e374 --- /dev/null +++ b/apps/api/plane/utils/core/__init__.py @@ -0,0 +1,21 @@ +""" +Core utilities for Plane database routing and request scoping. +This package contains essential components for managing read replica routing +and request-scoped context in the Plane application. +""" + +from .dbrouters import ReadReplicaRouter +from .mixins import ReadReplicaControlMixin +from .request_scope import ( + set_use_read_replica, + should_use_read_replica, + clear_read_replica_context, +) + +__all__ = [ + "ReadReplicaRouter", + "ReadReplicaControlMixin", + "set_use_read_replica", + "should_use_read_replica", + "clear_read_replica_context", +] diff --git a/apps/api/plane/utils/core/dbrouters.py b/apps/api/plane/utils/core/dbrouters.py new file mode 100644 index 00000000..e1756833 --- /dev/null +++ b/apps/api/plane/utils/core/dbrouters.py @@ -0,0 +1,71 @@ +""" +Database router for read replica selection. +This router determines which database to use for read/write operations +based on the request context set by the ReadReplicaRoutingMiddleware. +""" + +import logging +from typing import Type + +from django.db import models + +from .request_scope import should_use_read_replica + +logger = logging.getLogger("plane.db") + + +class ReadReplicaRouter: + """ + Database router that directs read operations to replica when appropriate. + This router works in conjunction with ReadReplicaRoutingMiddleware to: + - Route read operations to replica database when request context allows + - Always route write operations to primary database + - Ensure migrations only run on primary database + """ + + def db_for_read(self, model: Type[models.Model], **hints) -> str: + """ + Determine which database to use for read operations. + Args: + model: The Django model class being queried + **hints: Additional routing hints + Returns: + str: Database alias ('replica' or 'default') + """ + if should_use_read_replica(): + logger.debug(f"Routing read for {model._meta.label} to replica database") + return "replica" + else: + logger.debug(f"Routing read for {model._meta.label} to primary database") + return "default" + + def db_for_write(self, model: Type[models.Model], **hints) -> str: + """ + Determine which database to use for write operations. + All write operations always go to the primary database to ensure + data consistency and avoid replication lag issues. + Args: + model: The Django model class being written to + **hints: Additional routing hints + Returns: + str: Always returns 'default' (primary database) + """ + logger.debug(f"Routing write for {model._meta.label} to primary database") + return "default" + + def allow_migrate(self, db: str, app_label: str, model_name: str = None, **hints) -> bool: + """ + Ensure migrations only run on the primary database. + Args: + db: Database alias + app_label: Application label + model_name: Model name (optional) + **hints: Additional routing hints + Returns: + bool: True if migration is allowed on this database + """ + # Only allow migrations on the primary database + allowed = db == "default" + if not allowed: + logger.debug(f"Blocking migration for {app_label} on {db} database") + return allowed diff --git a/apps/api/plane/utils/core/mixins/__init__.py b/apps/api/plane/utils/core/mixins/__init__.py new file mode 100644 index 00000000..cedd9d45 --- /dev/null +++ b/apps/api/plane/utils/core/mixins/__init__.py @@ -0,0 +1,11 @@ +""" +Core mixins for read replica functionality. +This package provides mixins for different aspects of read replica management +in Django and Django REST Framework applications. +""" + +from .view import ReadReplicaControlMixin + +__all__ = [ + "ReadReplicaControlMixin", +] diff --git a/apps/api/plane/utils/core/mixins/view.py b/apps/api/plane/utils/core/mixins/view.py new file mode 100644 index 00000000..e15ec677 --- /dev/null +++ b/apps/api/plane/utils/core/mixins/view.py @@ -0,0 +1,20 @@ +""" +Mixins for Django REST Framework views. +""" + + +class ReadReplicaControlMixin: + """ + Mixin to control read replica usage in DRF views. + Set use_read_replica = True/False to route read operations to + replica/primary database. Works with ReadReplicaRoutingMiddleware. + Usage: + class MyViewSet(ReadReplicaControlMixin, ModelViewSet): + use_read_replica = True # Use replica for GET requests + Note: + - Only affects GET, HEAD, OPTIONS requests + - Write operations always use primary database + - Defaults to True for safe replica usage + """ + + use_read_replica: bool = True diff --git a/apps/api/plane/utils/core/request_scope.py b/apps/api/plane/utils/core/request_scope.py new file mode 100644 index 00000000..b09e7710 --- /dev/null +++ b/apps/api/plane/utils/core/request_scope.py @@ -0,0 +1,72 @@ +""" +Database routing utilities for read replica selection. +This module provides request-scoped context management for database routing, +specifically for determining when to use read replicas vs primary database. +Used in conjunction with middleware and DRF views that set use_read_replica=True. +The context is maintained per request to ensure proper isolation between +concurrent requests in async environments. +""" + +from asgiref.local import Local + +__all__ = [ + "set_use_read_replica", + "should_use_read_replica", + "clear_read_replica_context", +] + +# Request-scoped context storage for database routing preferences +# Uses asgiref.local.Local which provides ContextVar under the hood +# This ensures proper context isolation per request in async environments +_db_routing_context = Local() + + +def set_use_read_replica(use_replica: bool) -> None: + """ + Mark the current request context to use read replica database. + This function sets a request-scoped flag that determines database routing. + The context is isolated per request to ensure thread safety in async environments. + This function is typically called from: + - Middleware that detects read-only operations + - DRF views with use_read_replica=True attribute + - API endpoints that only perform read operations + Args: + use_replica (bool): True to route database queries to read replica, + False to use primary database + Note: + The context is automatically isolated per request and should be + cleared at the end of each request using clear_read_replica_context(). + """ + _db_routing_context.use_read_replica = bool(use_replica) + + +def should_use_read_replica() -> bool: + """ + Check if the current request should use read replica database. + This function reads the request-scoped context to determine database routing. + It's called by the database router to decide which connection to use. + Returns: + bool: True if queries should be routed to read replica, + False if they should use primary database (default) + Note: + Returns False by default if no context is set for the current request. + The context is automatically isolated per request. + """ + return getattr(_db_routing_context, "use_read_replica", False) + + +def clear_read_replica_context() -> None: + """ + Clear the read replica context for the current request. + This function should be called at the end of each request to ensure + that context doesn't leak between requests. Typically called from + middleware during request cleanup. + This is important for: + - Preventing context leakage between requests + - Ensuring clean state for each new request + - Proper memory management in long-running processes + """ + try: + delattr(_db_routing_context, "use_read_replica") + except AttributeError: + pass diff --git a/apps/api/plane/utils/cycle_transfer_issues.py b/apps/api/plane/utils/cycle_transfer_issues.py new file mode 100644 index 00000000..ec934e88 --- /dev/null +++ b/apps/api/plane/utils/cycle_transfer_issues.py @@ -0,0 +1,486 @@ +# Python imports +import json + +# Django imports +from django.db.models import ( + Case, + Count, + F, + Q, + Sum, + FloatField, + Value, + When, +) +from django.db import models +from django.db.models.functions import Cast, Concat +from django.utils import timezone + +# Module imports +from plane.db.models import ( + Cycle, + CycleIssue, + Issue, + Project, +) +from plane.utils.analytics_plot import burndown_plot +from plane.bgtasks.issue_activities_task import issue_activity +from plane.utils.host import base_host + + +def transfer_cycle_issues( + slug, + project_id, + cycle_id, + new_cycle_id, + request, + user_id, +): + """ + Transfer incomplete issues from one cycle to another and create progress snapshot. + + Args: + slug: Workspace slug + project_id: Project ID + cycle_id: Source cycle ID + new_cycle_id: Destination cycle ID + request: HTTP request object + user_id: User ID performing the transfer + + Returns: + dict: Response data with success or error message + """ + # Get the new cycle + new_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=new_cycle_id + ).first() + + # Check if new cycle is already completed + if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): + return { + "success": False, + "error": "The cycle where the issues are transferred is already completed", + } + + # Get the old cycle with issue counts + old_cycle = ( + Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + ) + old_cycle = old_cycle.first() + + if old_cycle is None: + return { + "success": False, + "error": "Source cycle not found", + } + + # Check if project uses estimates + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + # Initialize estimate distribution variables + assignee_estimate_distribution = [] + label_estimate_distribution = [] + estimate_completion_chart = {} + + if estimate_type: + 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_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", + 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()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + # Assignee estimate distribution serialization + assignee_estimate_distribution = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) if item["assignee_id"] else None + ), + "avatar_url": item.get("avatar_url"), + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in assignee_estimate_data + ] + + 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, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + estimate_completion_chart = burndown_plot( + queryset=old_cycle, + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=cycle_id, + ) + # Label estimate distribution serialization + label_estimate_distribution = [ + { + "label_name": item["label_name"], + "color": item["color"], + "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"], + } + for item in label_distribution_data + ] + + # Get the assignee distribution + 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_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", + 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)) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + # Assignee distribution serialized + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), + "avatar_url": item.get("avatar_url"), + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + + # Get the label distribution + 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, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + # Label distribution serialization + label_distribution_data = [ + { + "label_name": item["label_name"], + "color": item["color"], + "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"], + } + for item in label_distribution + ] + + # Generate completion chart + completion_chart = burndown_plot( + queryset=old_cycle, + slug=slug, + project_id=project_id, + plot_type="issues", + cycle_id=cycle_id, + ) + + # Get the current cycle and save progress snapshot + current_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ).first() + + current_cycle.progress_snapshot = { + "total_issues": old_cycle.total_issues, + "completed_issues": old_cycle.completed_issues, + "cancelled_issues": old_cycle.cancelled_issues, + "started_issues": old_cycle.started_issues, + "unstarted_issues": old_cycle.unstarted_issues, + "backlog_issues": old_cycle.backlog_issues, + "distribution": { + "labels": label_distribution_data, + "assignees": assignee_distribution_data, + "completion_chart": completion_chart, + }, + "estimate_distribution": ( + {} + if not estimate_type + else { + "labels": label_estimate_distribution, + "assignees": assignee_estimate_distribution, + "completion_chart": estimate_completion_chart, + } + ), + } + current_cycle.save(update_fields=["progress_snapshot"]) + + # Get issues to transfer (only incomplete issues) + cycle_issues = CycleIssue.objects.filter( + cycle_id=cycle_id, + project_id=project_id, + workspace__slug=slug, + issue__archived_at__isnull=True, + issue__is_draft=False, + issue__state__group__in=["backlog", "unstarted", "started"], + ) + + updated_cycles = [] + update_cycle_issue_activity = [] + for cycle_issue in cycle_issues: + cycle_issue.cycle_id = new_cycle_id + updated_cycles.append(cycle_issue) + update_cycle_issue_activity.append( + { + "old_cycle_id": str(cycle_id), + "new_cycle_id": str(new_cycle_id), + "issue_id": str(cycle_issue.issue_id), + } + ) + + # Bulk update cycle issues + cycle_issues = CycleIssue.objects.bulk_update( + updated_cycles, ["cycle_id"], batch_size=100 + ) + + # Capture Issue Activity + issue_activity.delay( + type="cycle.activity.created", + requested_data=json.dumps({"cycles_list": []}), + actor_id=str(user_id), + issue_id=None, + project_id=str(project_id), + current_instance=json.dumps( + { + "updated_cycle_issues": update_cycle_issue_activity, + "created_cycle_issues": [], + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + + return {"success": True} diff --git a/apps/api/plane/utils/date_utils.py b/apps/api/plane/utils/date_utils.py new file mode 100644 index 00000000..f15e7f11 --- /dev/null +++ b/apps/api/plane/utils/date_utils.py @@ -0,0 +1,187 @@ +from datetime import datetime, timedelta, date +from django.utils import timezone +from typing import Dict, Optional, List, Union, Tuple, Any + +from plane.db.models import User + + +def get_analytics_date_range( + date_filter: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, +) -> Optional[Dict[str, Dict[str, datetime]]]: + """ + Get date range for analytics with current and previous periods for comparison. + Returns a dictionary with current and previous date ranges. + + Args: + date_filter (str): The type of date filter to apply + start_date (str): Start date for custom range (format: YYYY-MM-DD) + end_date (str): End date for custom range (format: YYYY-MM-DD) + + Returns: + dict: Dictionary containing current and previous date ranges + """ + if not date_filter: + return None + + today = timezone.now().date() + + if date_filter == "yesterday": + yesterday = today - timedelta(days=1) + return { + "current": { + "gte": datetime.combine(yesterday, datetime.min.time()), + "lte": datetime.combine(yesterday, datetime.max.time()), + } + } + elif date_filter == "last_7_days": + return { + "current": { + "gte": datetime.combine(today - timedelta(days=7), datetime.min.time()), + "lte": datetime.combine(today, datetime.max.time()), + }, + "previous": { + "gte": datetime.combine(today - timedelta(days=14), datetime.min.time()), + "lte": datetime.combine(today - timedelta(days=8), datetime.max.time()), + }, + } + elif date_filter == "last_30_days": + return { + "current": { + "gte": datetime.combine(today - timedelta(days=30), datetime.min.time()), + "lte": datetime.combine(today, datetime.max.time()), + }, + "previous": { + "gte": datetime.combine(today - timedelta(days=60), datetime.min.time()), + "lte": datetime.combine(today - timedelta(days=31), datetime.max.time()), + }, + } + elif date_filter == "last_3_months": + return { + "current": { + "gte": datetime.combine(today - timedelta(days=90), datetime.min.time()), + "lte": datetime.combine(today, datetime.max.time()), + }, + "previous": { + "gte": datetime.combine(today - timedelta(days=180), datetime.min.time()), + "lte": datetime.combine(today - timedelta(days=91), datetime.max.time()), + }, + } + elif date_filter == "custom" and start_date and end_date: + try: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() + return { + "current": { + "gte": datetime.combine(start, datetime.min.time()), + "lte": datetime.combine(end, datetime.max.time()), + } + } + except (ValueError, TypeError): + return None + return None + + +def get_chart_period_range( + date_filter: Optional[str] = None, +) -> Optional[Tuple[date, date]]: + """ + Get date range for chart visualization. + Returns a tuple of (start_date, end_date) for the specified period. + + Args: + date_filter (str): The type of date filter to apply. Options are: + - "yesterday": Yesterday's date + - "last_7_days": Last 7 days + - "last_30_days": Last 30 days + - "last_3_months": Last 90 days + Defaults to "last_7_days" if not specified or invalid. + + Returns: + tuple: A tuple containing (start_date, end_date) as date objects + """ + if not date_filter: + return None + + today = timezone.now().date() + period_ranges = { + "yesterday": ( + today - timedelta(days=1), + today - timedelta(days=1), + ), + "last_7_days": (today - timedelta(days=7), today), + "last_30_days": (today - timedelta(days=30), today), + "last_3_months": (today - timedelta(days=90), today), + } + + return period_ranges.get(date_filter, None) + + +def get_analytics_filters( + slug: str, + user: User, + type: str, + date_filter: Optional[str] = None, + project_ids: Optional[Union[str, List[str]]] = None, +) -> Dict[str, Any]: + """ + Get combined project and date filters for analytics endpoints + + Args: + slug: The workspace slug + user: The current user + type: The type of filter ("analytics" or "chart") + date_filter: Optional date filter string + project_ids: Optional list of project IDs or comma-separated string of project IDs + + Returns: + dict: A dictionary containing: + - base_filters: Base filters for the workspace and user + - project_filters: Project-specific filters + - analytics_date_range: Date range filters for analytics comparison + - chart_period_range: Date range for chart visualization + """ + # Get project IDs from request + if project_ids and isinstance(project_ids, str): + project_ids = [str(project_id) for project_id in project_ids.split(",")] + + # Base filters for workspace and user + base_filters = { + "workspace__slug": slug, + "project__project_projectmember__member": user, + "project__project_projectmember__is_active": True, + "project__deleted_at__isnull": True, + "project__archived_at__isnull": True, + } + + # Project filters + project_filters = { + "workspace__slug": slug, + "project_projectmember__member": user, + "project_projectmember__is_active": True, + "deleted_at__isnull": True, + "archived_at__isnull": True, + } + + # Add project IDs to filters if provided + if project_ids: + base_filters["project_id__in"] = project_ids + project_filters["id__in"] = project_ids + + # Initialize date range variables + analytics_date_range = None + chart_period_range = None + + # Get date range filters based on type + if type == "analytics": + analytics_date_range = get_analytics_date_range(date_filter) + elif type == "chart": + chart_period_range = get_chart_period_range(date_filter) + + return { + "base_filters": base_filters, + "project_filters": project_filters, + "analytics_date_range": analytics_date_range, + "chart_period_range": chart_period_range, + } diff --git a/apps/api/plane/utils/error_codes.py b/apps/api/plane/utils/error_codes.py new file mode 100644 index 00000000..15d38f6b --- /dev/null +++ b/apps/api/plane/utils/error_codes.py @@ -0,0 +1,10 @@ +ERROR_CODES = { + # issues + "INVALID_ARCHIVE_STATE_GROUP": 4091, + "INVALID_ISSUE_DATES": 4100, + "INVALID_ISSUE_START_DATE": 4101, + "INVALID_ISSUE_TARGET_DATE": 4102, + # pages + "PAGE_LOCKED": 4701, + "PAGE_ARCHIVED": 4702, +} diff --git a/apps/api/plane/utils/exception_logger.py b/apps/api/plane/utils/exception_logger.py new file mode 100644 index 00000000..b0a6f8c3 --- /dev/null +++ b/apps/api/plane/utils/exception_logger.py @@ -0,0 +1,20 @@ +# Python imports +import logging +import traceback + +# Django imports +from django.conf import settings + + +def log_exception(e, warning=False): + # Log the error + logger = logging.getLogger("plane.exception") + + if warning: + logger.warning(str(e)) + else: + logger.exception(e) + + if settings.DEBUG: + logger.debug(traceback.format_exc()) + return diff --git a/apps/api/plane/utils/exporters/README.md b/apps/api/plane/utils/exporters/README.md new file mode 100644 index 00000000..cbecaaa4 --- /dev/null +++ b/apps/api/plane/utils/exporters/README.md @@ -0,0 +1,496 @@ +# 📊 Exporters + +A flexible and extensible data export utility for exporting Django model data in multiple formats (CSV, JSON, XLSX). + +## 🎯 Overview + +The exporters module provides a schema-based approach to exporting data with support for: + +- **📄 Multiple formats**: CSV, JSON, and XLSX (Excel) +- **🔒 Type-safe field definitions**: StringField, NumberField, DateField, DateTimeField, BooleanField, ListField, JSONField +- **⚡ Custom transformations**: Field-level transformations and custom preparer methods +- **🔗 Dotted path notation**: Easy access to nested attributes and related models +- **🎨 Format-specific handling**: Automatic formatting based on export format (e.g., lists as arrays in JSON, comma-separated in CSV) + +## 🚀 Quick Start + +### Basic Usage + +```python +from plane.utils.exporters import Exporter, ExportSchema, StringField, NumberField + +# Define a schema +class UserExportSchema(ExportSchema): + name = StringField(source="username", label="User Name") + email = StringField(source="email", label="Email Address") + posts_count = NumberField(label="Total Posts") + + def prepare_posts_count(self, obj): + return obj.posts.count() + +# Export data - just pass the queryset! +users = User.objects.all() +exporter = Exporter(format_type="csv", schema_class=UserExportSchema) +filename, content = exporter.export("users_export", users) +``` + +### Exporting Issues + +```python +from plane.utils.exporters import Exporter, IssueExportSchema + +# Get issues with prefetched relations +issues = Issue.objects.filter(project_id=project_id).prefetch_related( + 'assignee_details', + 'label_details', + 'issue_module', + # ... other relations +) + +# Export as XLSX - pass the queryset directly! +exporter = Exporter(format_type="xlsx", schema_class=IssueExportSchema) +filename, content = exporter.export("issues", issues) + +# Export with custom fields only +exporter = Exporter(format_type="json", schema_class=IssueExportSchema) +filename, content = exporter.export("issues_filtered", issues, fields=["id", "name", "state_name", "assignees"]) +``` + +### Exporting Multiple Projects Separately + +```python +# Export each project to a separate file +for project_id in project_ids: + project_issues = issues.filter(project_id=project_id) + exporter = Exporter(format_type="csv", schema_class=IssueExportSchema) + filename, content = exporter.export(f"issues-{project_id}", project_issues) + # Save or upload the file +``` + +## 📝 Schema Definition + +### Field Types + +#### 📝 StringField + +Converts values to strings. + +```python +name = StringField(source="name", label="Name", default="N/A") +``` + +#### 🔢 NumberField + +Handles numeric values (int, float). + +```python +count = NumberField(source="items_count", label="Count", default=0) +``` + +#### 📅 DateField + +Formats date objects as `%a, %d %b %Y` (e.g., "Mon, 01 Jan 2024"). + +```python +start_date = DateField(source="start_date", label="Start Date") +``` + +#### ⏰ DateTimeField + +Formats datetime objects as `%a, %d %b %Y %I:%M:%S %Z%z`. + +```python +created_at = DateTimeField(source="created_at", label="Created At") +``` + +#### ✅ BooleanField + +Converts values to boolean. + +```python +is_active = BooleanField(source="is_active", label="Active", default=False) +``` + +#### 📋 ListField + +Handles list/array values. In CSV/XLSX, lists are joined with a separator (default: `", "`). In JSON, they remain as arrays. + +```python +tags = ListField(source="tags", label="Tags") +assignees = ListField(label="Assignees") # Custom preparer can populate this +``` + +#### 🗂️ JSONField + +Handles complex JSON-serializable objects (dicts, lists of dicts). In CSV/XLSX, they're serialized as JSON strings. In JSON, they remain as objects. + +```python +metadata = JSONField(source="metadata", label="Metadata") +comments = JSONField(label="Comments") +``` + +### ⚙️ Field Parameters + +All field types support these parameters: + +- **`source`**: Dotted path string to the attribute (e.g., `"project.name"`) +- **`default`**: Default value when field is None +- **`label`**: Display name in export headers + +### 🔗 Dotted Path Notation + +Access nested attributes using dot notation: + +```python +project_name = StringField(source="project.name", label="Project") +owner_email = StringField(source="created_by.email", label="Owner Email") +``` + +### 🎯 Custom Preparers + +For complex logic, define `prepare_{field_name}` methods: + +```python +class MySchema(ExportSchema): + assignees = ListField(label="Assignees") + + def prepare_assignees(self, obj): + return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details] +``` + +Preparers take precedence over field definitions. + +### ⚡ Custom Transformations with Preparer Methods + +For any custom logic or transformations, use `prepare_` methods: + +```python +class MySchema(ExportSchema): + name = StringField(source="name", label="Name (Uppercase)") + status = StringField(label="Status") + + def prepare_name(self, obj): + """Transform the name field to uppercase.""" + return obj.name.upper() if obj.name else "" + + def prepare_status(self, obj): + """Compute status based on model state.""" + return "Active" if obj.is_active else "Inactive" +``` + +## 📦 Export Formats + +### 📊 CSV Format + +- Fields are quoted with `QUOTE_ALL` +- Lists are joined with `", "` (customizable with `list_joiner` option) +- JSON objects are serialized as JSON strings +- File extension: `.csv` + +```python +exporter = Exporter( + format_type="csv", + schema_class=MySchema, + options={"list_joiner": "; "} # Custom separator +) +``` + +### 📋 JSON Format + +- Lists remain as arrays +- Objects remain as nested structures +- Preserves data types +- File extension: `.json` + +```python +exporter = Exporter(format_type="json", schema_class=MySchema) +filename, content = exporter.export("data", records) +# content is a JSON string: '[{"field": "value"}, ...]' +``` + +### 📗 XLSX Format + +- Creates Excel-compatible files using openpyxl +- Lists are joined with `", "` (customizable with `list_joiner` option) +- JSON objects are serialized as JSON strings +- File extension: `.xlsx` +- Returns binary content (bytes) + +```python +exporter = Exporter(format_type="xlsx", schema_class=MySchema) +filename, content = exporter.export("data", records) +# content is bytes +``` + +## 🔧 Advanced Usage + +### 📦 Using Context for Pre-fetched Data + +Pass context data to schemas to avoid N+1 queries. Override `get_context_data()` in your schema: + +```python +class MySchema(ExportSchema): + attachment_count = NumberField(label="Attachments") + + def prepare_attachment_count(self, obj): + attachments_dict = self.context.get("attachments_dict", {}) + return len(attachments_dict.get(obj.id, [])) + + @classmethod + def get_context_data(cls, queryset): + """Pre-fetch all attachments in one query.""" + attachments_dict = get_attachments_dict(queryset) + return {"attachments_dict": attachments_dict} + +# The Exporter automatically uses get_context_data() when serializing +queryset = MyModel.objects.all() +exporter = Exporter(format_type="csv", schema_class=MySchema) +filename, content = exporter.export("data", queryset) +``` + +### 🔌 Registering Custom Formatters + +Add support for new export formats: + +```python +from plane.utils.exporters import Exporter, BaseFormatter + +class XMLFormatter(BaseFormatter): + def format(self, filename, records, schema_class, options=None): + # Implementation + return (f"{filename}.xml", xml_content) + +# Register the formatter +Exporter.register_formatter("xml", XMLFormatter) + +# Use it +exporter = Exporter(format_type="xml", schema_class=MySchema) +``` + +### ✅ Checking Available Formats + +```python +formats = Exporter.get_available_formats() +# Returns: ['csv', 'json', 'xlsx'] +``` + +### 🔍 Filtering Fields + +Pass a `fields` parameter to export only specific fields: + +```python +# Export only specific fields +exporter = Exporter(format_type="csv", schema_class=MySchema) +filename, content = exporter.export( + "filtered_data", + queryset, + fields=["id", "name", "email"] +) +``` + +### 🎯 Extending Schemas + +Create extended schemas by inheriting from existing ones and overriding `get_context_data()`: + +```python +class ExtendedIssueExportSchema(IssueExportSchema): + custom_field = JSONField(label="Custom Data") + + def prepare_custom_field(self, obj): + # Use pre-fetched data from context + return self.context.get("custom_data", {}).get(obj.id, {}) + + @classmethod + def get_context_data(cls, queryset): + # Get parent context (attachments, etc.) + context = super().get_context_data(queryset) + + # Add your custom pre-fetched data + context["custom_data"] = fetch_custom_data(queryset) + + return context +``` + +### 💾 Manual Serialization + +If you need to serialize data without exporting, you can use the schema directly: + +```python +# Serialize a queryset to a list of dicts +data = MySchema.serialize_queryset(queryset, fields=["id", "name"]) + +# Or serialize a single object +schema = MySchema() +obj_data = schema.serialize(obj) +``` + +## 💡 Example: IssueExportSchema + +The `IssueExportSchema` demonstrates a complete implementation: + +```python +from plane.utils.exporters import Exporter, IssueExportSchema + +# Simple export - just pass the queryset! +issues = Issue.objects.filter(project_id=project_id) +exporter = Exporter(format_type="csv", schema_class=IssueExportSchema) +filename, content = exporter.export("issues", issues) + +# Export specific fields only +filename, content = exporter.export( + "issues_filtered", + issues, + fields=["id", "name", "state_name", "assignees", "labels"] +) + +# Export multiple projects to separate files +for project_id in project_ids: + project_issues = issues.filter(project_id=project_id) + filename, content = exporter.export(f"issues-{project_id}", project_issues) + # Save or upload each file +``` + +Key features: + +- 🔗 Access to related models via dotted paths +- 🎯 Custom preparers for complex fields +- 📎 Context-based attachment handling via `get_context_data()` +- 📋 List and JSON field handling +- 📅 Date/datetime formatting + +## ✨ Best Practices + +1. **🚄 Avoid N+1 Queries**: Override `get_context_data()` to pre-fetch related data: + + ```python + @classmethod + def get_context_data(cls, queryset): + return { + "attachments": get_attachments_dict(queryset), + "comments": get_comments_dict(queryset), + } + ``` + +2. **🏷️ Use Labels**: Provide descriptive labels for better export headers: + + ```python + created_at = DateTimeField(source="created_at", label="Created At") + ``` + +3. **🛡️ Handle None Values**: Set appropriate defaults for fields that might be None: + + ```python + count = NumberField(source="count", default=0) + ``` + +4. **🎯 Use Preparers for Complex Logic**: Keep field definitions simple and use preparers for complex transformations: + + ```python + def prepare_assignees(self, obj): + return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details] + ``` + +5. **⚡ Pass QuerySets Directly**: Let the Exporter handle serialization: + + ```python + # Good - Exporter handles serialization + exporter.export("data", queryset) + + # Avoid - Manual serialization unless needed + data = MySchema.serialize_queryset(queryset) + exporter.export("data", data) + ``` + +6. **📦 Filter QuerySets, Not Data**: For multiple exports, filter the queryset instead of the serialized data: + + ```python + # Good - efficient, only serializes what's needed + for project_id in project_ids: + project_issues = issues.filter(project_id=project_id) + exporter.export(f"project-{project_id}", project_issues) + + # Avoid - serializes all data upfront + all_data = MySchema.serialize_queryset(issues) + for project_id in project_ids: + project_data = [d for d in all_data if d['project_id'] == project_id] + exporter.export(f"project-{project_id}", project_data) + ``` + +## 📚 API Reference + +### 📊 Exporter + +**`__init__(format_type, schema_class, options=None)`** + +- `format_type`: Export format ('csv', 'json', 'xlsx') +- `schema_class`: Schema class defining fields +- `options`: Optional dict of format-specific options + +**`export(filename, data, fields=None)`** + +- `filename`: Filename without extension +- `data`: Django QuerySet or list of dicts +- `fields`: Optional list of field names to include +- Returns: `(filename_with_extension, content)` +- `content` is str for CSV/JSON, bytes for XLSX + +**`get_available_formats()`** (class method) + +- Returns: List of available format types + +**`register_formatter(format_type, formatter_class)`** (class method) + +- Register a custom formatter + +### 📝 ExportSchema + +**`__init__(context=None)`** + +- `context`: Optional dict accessible in preparer methods via `self.context` for pre-fetched data + +**`serialize(obj, fields=None)`** + +- Returns: Dict of serialized field values for a single object + +**`serialize_queryset(queryset, fields=None)`** (class method) + +- `queryset`: QuerySet of objects to serialize +- `fields`: Optional list of field names to include +- Returns: List of dicts with serialized data + +**`get_context_data(queryset)`** (class method) + +- Override to pre-fetch related data for the queryset +- Returns: Dict of context data + +### 🔧 ExportField + +Base class for all field types. Subclass to create custom field types. + +**`get_value(obj, context)`** + +- Returns: Formatted value for the field + +**`_format_value(raw)`** + +- Override in subclasses for type-specific formatting + +## 🧪 Testing + +```python +# Test exporting a queryset +queryset = MyModel.objects.all() +exporter = Exporter(format_type="json", schema_class=MySchema) +filename, content = exporter.export("test", queryset) +assert filename == "test.json" +assert isinstance(content, str) + +# Test with field filtering +filename, content = exporter.export("test", queryset, fields=["id", "name"]) +data = json.loads(content) +assert all(set(item.keys()) == {"id", "name"} for item in data) + +# Test manual serialization +data = MySchema.serialize_queryset(queryset) +assert len(data) == queryset.count() +``` diff --git a/apps/api/plane/utils/exporters/__init__.py b/apps/api/plane/utils/exporters/__init__.py new file mode 100644 index 00000000..9e7b1a9d --- /dev/null +++ b/apps/api/plane/utils/exporters/__init__.py @@ -0,0 +1,38 @@ +"""Export utilities for various data formats.""" + +from .exporter import Exporter +from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter +from .schemas import ( + BooleanField, + DateField, + DateTimeField, + ExportField, + ExportSchema, + IssueExportSchema, + JSONField, + ListField, + NumberField, + StringField, +) + +__all__ = [ + # Core Exporter + "Exporter", + # Schemas + "ExportSchema", + "ExportField", + "StringField", + "NumberField", + "DateField", + "DateTimeField", + "BooleanField", + "ListField", + "JSONField", + # Formatters + "BaseFormatter", + "CSVFormatter", + "JSONFormatter", + "XLSXFormatter", + # Issue Schema + "IssueExportSchema", +] diff --git a/apps/api/plane/utils/exporters/exporter.py b/apps/api/plane/utils/exporters/exporter.py new file mode 100644 index 00000000..75b396cb --- /dev/null +++ b/apps/api/plane/utils/exporters/exporter.py @@ -0,0 +1,72 @@ +from typing import Any, Dict, List, Type, Union + +from django.db.models import QuerySet + +from .formatters import CSVFormatter, JSONFormatter, XLSXFormatter + + +class Exporter: + """Generic exporter class that handles data exports using different formatters.""" + + # Available formatters + FORMATTERS = { + "csv": CSVFormatter, + "json": JSONFormatter, + "xlsx": XLSXFormatter, + } + + def __init__(self, format_type: str, schema_class: Type, options: Dict[str, Any] = None): + """Initialize exporter with specified format type and schema. + + Args: + format_type: The export format (csv, json, xlsx) + schema_class: The schema class to use for field definitions + options: Optional formatting options + """ + if format_type not in self.FORMATTERS: + raise ValueError(f"Unsupported format: {format_type}. Available: {list(self.FORMATTERS.keys())}") + + self.format_type = format_type + self.schema_class = schema_class + self.formatter = self.FORMATTERS[format_type]() + self.options = options or {} + + def export( + self, + filename: str, + data: Union[QuerySet, List[dict]], + fields: List[str] = None, + ) -> tuple[str, str | bytes]: + """Export data using the configured formatter and return (filename, content). + + Args: + filename: The filename for the export (without extension) + data: Either a Django QuerySet or a list of already-serialized dicts + fields: Optional list of field names to include in export + + Returns: + Tuple of (filename_with_extension, content) + """ + # Serialize the queryset if needed + if isinstance(data, QuerySet): + records = self.schema_class.serialize_queryset(data, fields=fields) + else: + # Already serialized data + records = data + + # Merge fields into options for the formatter + format_options = {**self.options} + if fields: + format_options["fields"] = fields + + return self.formatter.format(filename, records, self.schema_class, format_options) + + @classmethod + def get_available_formats(cls) -> List[str]: + """Get list of available export formats.""" + return list(cls.FORMATTERS.keys()) + + @classmethod + def register_formatter(cls, format_type: str, formatter_class: type) -> None: + """Register a new formatter for a format type.""" + cls.FORMATTERS[format_type] = formatter_class diff --git a/apps/api/plane/utils/exporters/formatters.py b/apps/api/plane/utils/exporters/formatters.py new file mode 100644 index 00000000..fc7c2352 --- /dev/null +++ b/apps/api/plane/utils/exporters/formatters.py @@ -0,0 +1,199 @@ +import csv +import io +import json +from typing import Any, Dict, List, Type + +from openpyxl import Workbook + + +class BaseFormatter: + """Base class for export formatters.""" + + def format( + self, + filename: str, + records: List[dict], + schema_class: Type, + options: Dict[str, Any] | None = None, + ) -> tuple[str, str | bytes]: + """Format records for export. + + Args: + filename: The filename for the export (without extension) + records: List of records to export + schema_class: Schema class to extract field order and labels + options: Optional formatting options + + Returns: + Tuple of (filename_with_extension, content) + """ + raise NotImplementedError + + @staticmethod + def _get_field_info(schema_class: Type) -> tuple[List[str], Dict[str, str]]: + """Extract field order and labels from schema. + + Args: + schema_class: Schema class with field definitions + + Returns: + Tuple of (field_order, field_labels) + """ + if not hasattr(schema_class, "_declared_fields"): + raise ValueError(f"Schema class {schema_class.__name__} must have _declared_fields attribute") + + # Get order and labels from schema + field_order = list(schema_class._declared_fields.keys()) + field_labels = { + name: field.label if field.label else name.replace("_", " ").title() + for name, field in schema_class._declared_fields.items() + } + + return field_order, field_labels + + +class CSVFormatter(BaseFormatter): + """Formatter for CSV exports.""" + + @staticmethod + def _format_field_value(value: Any, list_joiner: str = ", ") -> str: + """Format a field value for CSV output.""" + if value is None: + return "" + if isinstance(value, list): + return list_joiner.join(str(v) for v in value) + if isinstance(value, dict): + # For complex objects, serialize as JSON + return json.dumps(value) + return str(value) + + def _generate_table_row( + self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None + ) -> List[str]: + """Generate a CSV row from a record.""" + opts = options or {} + list_joiner = opts.get("list_joiner", ", ") + return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order] + + def _create_csv_file(self, data: List[List[str]]) -> str: + """Create CSV file content from row data.""" + buf = io.StringIO() + writer = csv.writer(buf, delimiter=",", quoting=csv.QUOTE_ALL) + for row in data: + writer.writerow(row) + buf.seek(0) + return buf.getvalue() + + def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]: + if not records: + return (f"{filename}.csv", "") + + # Get field order and labels from schema + field_order, field_labels = self._get_field_info(schema_class) + + # Filter to requested fields if specified + opts = options or {} + requested_fields = opts.get("fields") + if requested_fields: + field_order = [f for f in field_order if f in requested_fields] + + header = [field_labels[field] for field in field_order] + + rows = [header] + for record in records: + row = self._generate_table_row(record, field_order, options) + rows.append(row) + content = self._create_csv_file(rows) + return (f"{filename}.csv", content) + + +class JSONFormatter(BaseFormatter): + """Formatter for JSON exports.""" + + def _generate_json_row( + self, record: dict, field_labels: Dict[str, str], field_order: List[str], options: Dict[str, Any] | None = None + ) -> dict: + """Generate a JSON object from a record. + + Preserves data types - lists stay as arrays, dicts stay as objects. + """ + return {field_labels[field]: record.get(field) for field in field_order if field in record} + + def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]: + if not records: + return (f"{filename}.json", "[]") + + # Get field order and labels from schema + field_order, field_labels = self._get_field_info(schema_class) + + # Filter to requested fields if specified + opts = options or {} + requested_fields = opts.get("fields") + if requested_fields: + field_order = [f for f in field_order if f in requested_fields] + + rows: List[dict] = [] + for record in records: + row = self._generate_json_row(record, field_labels, field_order, options) + rows.append(row) + content = json.dumps(rows) + return (f"{filename}.json", content) + + +class XLSXFormatter(BaseFormatter): + """Formatter for XLSX (Excel) exports.""" + + @staticmethod + def _format_field_value(value: Any, list_joiner: str = ", ") -> str: + """Format a field value for XLSX output.""" + if value is None: + return "" + if isinstance(value, list): + return list_joiner.join(str(v) for v in value) + if isinstance(value, dict): + # For complex objects, serialize as JSON + return json.dumps(value) + return str(value) + + def _generate_table_row( + self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None + ) -> List[str]: + """Generate an XLSX row from a record.""" + opts = options or {} + list_joiner = opts.get("list_joiner", ", ") + return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order] + + def _create_xlsx_file(self, data: List[List[str]]) -> bytes: + """Create XLSX file content from row data.""" + wb = Workbook() + sh = wb.active + for row in data: + sh.append(row) + out = io.BytesIO() + wb.save(out) + out.seek(0) + return out.getvalue() + + def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, bytes]: + if not records: + # Create empty workbook + content = self._create_xlsx_file([]) + return (f"{filename}.xlsx", content) + + # Get field order and labels from schema + field_order, field_labels = self._get_field_info(schema_class) + + # Filter to requested fields if specified + opts = options or {} + requested_fields = opts.get("fields") + if requested_fields: + field_order = [f for f in field_order if f in requested_fields] + + header = [field_labels[field] for field in field_order] + + rows = [header] + for record in records: + row = self._generate_table_row(record, field_order, options) + rows.append(row) + content = self._create_xlsx_file(rows) + return (f"{filename}.xlsx", content) diff --git a/apps/api/plane/utils/exporters/schemas/__init__.py b/apps/api/plane/utils/exporters/schemas/__init__.py new file mode 100644 index 00000000..98b2623a --- /dev/null +++ b/apps/api/plane/utils/exporters/schemas/__init__.py @@ -0,0 +1,30 @@ +"""Export schemas for various data types.""" + +from .base import ( + BooleanField, + DateField, + DateTimeField, + ExportField, + ExportSchema, + JSONField, + ListField, + NumberField, + StringField, +) +from .issue import IssueExportSchema + +__all__ = [ + # Base field types + "ExportField", + "StringField", + "NumberField", + "DateField", + "DateTimeField", + "BooleanField", + "ListField", + "JSONField", + # Base schema + "ExportSchema", + # Issue schema + "IssueExportSchema", +] diff --git a/apps/api/plane/utils/exporters/schemas/base.py b/apps/api/plane/utils/exporters/schemas/base.py new file mode 100644 index 00000000..4e67c698 --- /dev/null +++ b/apps/api/plane/utils/exporters/schemas/base.py @@ -0,0 +1,234 @@ +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from django.db.models import QuerySet + + +@dataclass +class ExportField: + """Base export field class for generic fields.""" + + source: Optional[str] = None + default: Any = "" + label: Optional[str] = None # Display name for export headers + + def get_value(self, obj: Any, context: Dict[str, Any]) -> Any: + raw: Any + if self.source: + raw = self._resolve_dotted_path(obj, self.source) + else: + raw = obj + + return self._format_value(raw) + + def _format_value(self, raw: Any) -> Any: + """Format the raw value. Override in subclasses for type-specific formatting.""" + return raw if raw is not None else self.default + + def _resolve_dotted_path(self, obj: Any, path: str) -> Any: + current = obj + for part in path.split("."): + if current is None: + return None + if hasattr(current, part): + current = getattr(current, part) + elif isinstance(current, dict): + current = current.get(part) + else: + return None + return current + + +@dataclass +class StringField(ExportField): + """Export field for string values.""" + + default: str = "" + + def _format_value(self, raw: Any) -> str: + if raw is None: + return self.default + return str(raw) + + +@dataclass +class DateField(ExportField): + """Export field for date values with automatic conversion.""" + + default: str = "" + + def _format_value(self, raw: Any) -> str: + if raw is None: + return self.default + # Convert date to formatted string + if hasattr(raw, "strftime"): + return raw.strftime("%a, %d %b %Y") + return str(raw) + + +@dataclass +class DateTimeField(ExportField): + """Export field for datetime values with automatic conversion.""" + + default: str = "" + + def _format_value(self, raw: Any) -> str: + if raw is None: + return self.default + # Convert datetime to formatted string + if hasattr(raw, "strftime"): + return raw.strftime("%a, %d %b %Y %I:%M:%S %Z%z") + return str(raw) + + +@dataclass +class NumberField(ExportField): + """Export field for numeric values.""" + + default: Any = "" + + def _format_value(self, raw: Any) -> Any: + if raw is None: + return self.default + return raw + + +@dataclass +class BooleanField(ExportField): + """Export field for boolean values.""" + + default: bool = False + + def _format_value(self, raw: Any) -> bool: + if raw is None: + return self.default + return bool(raw) + + +@dataclass +class ListField(ExportField): + """Export field for list/array values. + + Returns the list as-is by default. The formatter will handle conversion to strings + when needed (e.g., CSV/XLSX will join with separator, JSON will keep as array). + """ + + default: Optional[List] = field(default_factory=list) + + def _format_value(self, raw: Any) -> List[Any]: + if raw is None: + return self.default if self.default is not None else [] + if isinstance(raw, (list, tuple)): + return list(raw) + return [raw] # Wrap single items in a list + + +@dataclass +class JSONField(ExportField): + """Export field for complex JSON-serializable values (dicts, lists of dicts, etc). + + Preserves the structure as-is for JSON exports. For CSV/XLSX, the formatter + will handle serialization (e.g., JSON stringify). + """ + + default: Any = field(default_factory=dict) + + def _format_value(self, raw: Any) -> Any: + if raw is None: + return self.default + # Return as-is - should be JSON-serializable + return raw + + +class ExportSchemaMeta(type): + def __new__(mcls, name, bases, attrs): + declared: Dict[str, ExportField] = { + key: value for key, value in list(attrs.items()) if isinstance(value, ExportField) + } + for key in declared.keys(): + attrs.pop(key) + cls = super().__new__(mcls, name, bases, attrs) + base_fields: Dict[str, ExportField] = {} + for base in bases: + if hasattr(base, "_declared_fields"): + base_fields.update(base._declared_fields) + base_fields.update(declared) + cls._declared_fields = base_fields + return cls + + +class ExportSchema(metaclass=ExportSchemaMeta): + """Base schema for exporting data in various formats. + + Subclasses should define fields as class attributes and can override: + - prepare_ methods for custom field serialization + - get_context_data() class method to pre-fetch related data for the queryset + """ + + def __init__(self, context: Optional[Dict[str, Any]] = None) -> None: + self.context = context or {} + + def serialize(self, obj: Any, fields: Optional[List[str]] = None) -> Dict[str, Any]: + """Serialize a single object. + + Args: + obj: The object to serialize + fields: Optional list of field names to include. If None, all fields are serialized. + + Returns: + Dictionary of serialized data + """ + output: Dict[str, Any] = {} + # Determine which fields to process + fields_to_process = fields if fields else list(self._declared_fields.keys()) + + for field_name in fields_to_process: + # Skip if field doesn't exist in schema + if field_name not in self._declared_fields: + continue + + export_field = self._declared_fields[field_name] + + # Prefer explicit preparer methods if present + preparer = getattr(self, f"prepare_{field_name}", None) + if callable(preparer): + output[field_name] = preparer(obj) + continue + + output[field_name] = export_field.get_value(obj, self.context) + return output + + @classmethod + def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]: + """Get context data for serialization. Override in subclasses to pre-fetch related data. + + Args: + queryset: QuerySet of objects to be serialized + + Returns: + Dictionary of context data to be passed to the schema instance + """ + return {} + + @classmethod + def serialize_queryset(cls, queryset: QuerySet, fields: List[str] = None) -> List[Dict[str, Any]]: + """Serialize a queryset of objects to export data. + + Args: + queryset: QuerySet of objects to serialize + fields: Optional list of field names to include. Defaults to all fields. + + Returns: + List of dictionaries containing serialized data + """ + # Get context data (can be extended by subclasses) + context = cls.get_context_data(queryset) + + # Serialize each object, passing fields to only process requested fields + schema = cls(context=context) + data = [] + for obj in queryset: + obj_data = schema.serialize(obj, fields=fields) + data.append(obj_data) + + return data diff --git a/apps/api/plane/utils/exporters/schemas/issue.py b/apps/api/plane/utils/exporters/schemas/issue.py new file mode 100644 index 00000000..744e3305 --- /dev/null +++ b/apps/api/plane/utils/exporters/schemas/issue.py @@ -0,0 +1,210 @@ +from collections import defaultdict +from typing import Any, Dict, List, Optional + +from django.db.models import F, QuerySet + +from plane.db.models import CycleIssue, FileAsset + +from .base import ( + DateField, + DateTimeField, + ExportSchema, + JSONField, + ListField, + NumberField, + StringField, +) + + +def get_issue_attachments_dict(issues_queryset: QuerySet) -> Dict[str, List[str]]: + """Get attachments dictionary for the given issues queryset. + + Args: + issues_queryset: Queryset of Issue objects + + Returns: + Dictionary mapping issue IDs to lists of attachment IDs + """ + file_assets = FileAsset.objects.filter( + issue_id__in=issues_queryset.values_list("id", flat=True), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ).annotate(work_item_id=F("issue_id"), asset_id=F("id")) + + attachment_dict = defaultdict(list) + for asset in file_assets: + attachment_dict[asset.work_item_id].append(asset.asset_id) + + return attachment_dict + + +def get_issue_last_cycles_dict(issues_queryset: QuerySet) -> Dict[str, Optional[CycleIssue]]: + """Get the last cycle for each issue in the given queryset. + + Args: + issues_queryset: Queryset of Issue objects + + Returns: + Dictionary mapping issue IDs to their last CycleIssue object + """ + # Fetch all cycle issues for the given issues, ordered by created_at descending + # select_related is used to fetch cycle data in the same query + cycle_issues = ( + CycleIssue.objects.filter(issue_id__in=issues_queryset.values_list("id", flat=True)) + .select_related("cycle") + .order_by("issue_id", "-created_at") + ) + + # Keep only the last (most recent) cycle for each issue + last_cycles_dict = {} + for cycle_issue in cycle_issues: + if cycle_issue.issue_id not in last_cycles_dict: + last_cycles_dict[cycle_issue.issue_id] = cycle_issue + + return last_cycles_dict + + +class IssueExportSchema(ExportSchema): + """Schema for exporting issue data in various formats.""" + + @staticmethod + def _get_created_by(obj) -> str: + """Get the created by user for the given object.""" + try: + if getattr(obj, "created_by", None): + return f"{obj.created_by.first_name} {obj.created_by.last_name}" + except Exception: + pass + return "" + + @staticmethod + def _format_date(date_obj) -> str: + """Format date object to string.""" + if date_obj and hasattr(date_obj, "strftime"): + return date_obj.strftime("%a, %d %b %Y") + return "" + + # Field definitions with display labels + id = StringField(label="ID") + project_identifier = StringField(source="project.identifier", label="Project Identifier") + project_name = StringField(source="project.name", label="Project") + project_id = StringField(source="project.id", label="Project ID") + sequence_id = NumberField(source="sequence_id", label="Sequence ID") + name = StringField(source="name", label="Name") + description = StringField(source="description_stripped", label="Description") + priority = StringField(source="priority", label="Priority") + start_date = DateField(source="start_date", label="Start Date") + target_date = DateField(source="target_date", label="Target Date") + state_name = StringField(label="State") + created_at = DateTimeField(source="created_at", label="Created At") + updated_at = DateTimeField(source="updated_at", label="Updated At") + completed_at = DateTimeField(source="completed_at", label="Completed At") + archived_at = DateTimeField(source="archived_at", label="Archived At") + module_name = ListField(label="Module Name") + created_by = StringField(label="Created By") + labels = ListField(label="Labels") + comments = JSONField(label="Comments") + estimate = StringField(label="Estimate") + link = ListField(label="Link") + assignees = ListField(label="Assignees") + subscribers_count = NumberField(label="Subscribers Count") + attachment_count = NumberField(label="Attachment Count") + attachment_links = ListField(label="Attachment Links") + cycle_name = StringField(label="Cycle Name") + cycle_start_date = DateField(label="Cycle Start Date") + cycle_end_date = DateField(label="Cycle End Date") + parent = StringField(label="Parent") + relations = JSONField(label="Relations") + + def prepare_id(self, i): + return f"{i.project.identifier}-{i.sequence_id}" + + def prepare_state_name(self, i): + return i.state.name if i.state else None + + def prepare_module_name(self, i): + return [m.module.name for m in i.issue_module.all()] + + def prepare_created_by(self, i): + return self._get_created_by(i) + + def prepare_labels(self, i): + return [label.name for label in i.labels.all()] + + def prepare_comments(self, i): + return [ + { + "comment": comment.comment_stripped, + "created_at": self._format_date(comment.created_at), + "created_by": self._get_created_by(comment), + } + for comment in i.issue_comments.all() + ] + + def prepare_estimate(self, i): + return i.estimate_point.value if i.estimate_point and i.estimate_point.value else "" + + def prepare_link(self, i): + return [link.url for link in i.issue_link.all()] + + def prepare_assignees(self, i): + return [f"{u.first_name} {u.last_name}" for u in i.assignees.all()] + + def prepare_subscribers_count(self, i): + return i.issue_subscribers.count() + + def prepare_attachment_count(self, i): + return len((self.context.get("attachments_dict") or {}).get(i.id, [])) + + def prepare_attachment_links(self, i): + return [ + f"/api/assets/v2/workspaces/{i.workspace.slug}/projects/{i.project_id}/issues/{i.id}/attachments/{asset}/" + for asset in (self.context.get("attachments_dict") or {}).get(i.id, []) + ] + + def prepare_cycle_name(self, i): + cycles_dict = self.context.get("cycles_dict") or {} + last_cycle = cycles_dict.get(i.id) + return last_cycle.cycle.name if last_cycle else "" + + def prepare_cycle_start_date(self, i): + cycles_dict = self.context.get("cycles_dict") or {} + last_cycle = cycles_dict.get(i.id) + if last_cycle and last_cycle.cycle.start_date: + return self._format_date(last_cycle.cycle.start_date) + return "" + + def prepare_cycle_end_date(self, i): + cycles_dict = self.context.get("cycles_dict") or {} + last_cycle = cycles_dict.get(i.id) + if last_cycle and last_cycle.cycle.end_date: + return self._format_date(last_cycle.cycle.end_date) + return "" + + def prepare_parent(self, i): + if not i.parent: + return "" + return f"{i.parent.project.identifier}-{i.parent.sequence_id}" + + def prepare_relations(self, i): + # Should show reverse relation as well + from plane.db.models.issue import IssueRelationChoices + + relations = { + r.relation_type: f"{r.related_issue.project.identifier}-{r.related_issue.sequence_id}" + for r in i.issue_relation.all() + } + reverse_relations = {} + for relation in i.issue_related.all(): + reverse_relations[IssueRelationChoices._REVERSE_MAPPING[relation.relation_type]] = ( + f"{relation.issue.project.identifier}-{relation.issue.sequence_id}" + ) + relations.update(reverse_relations) + return relations + + @classmethod + def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]: + """Get context data for issue serialization.""" + return { + "attachments_dict": get_issue_attachments_dict(queryset), + "cycles_dict": get_issue_last_cycles_dict(queryset), + } diff --git a/apps/api/plane/utils/filters/__init__.py b/apps/api/plane/utils/filters/__init__.py new file mode 100644 index 00000000..76a96c82 --- /dev/null +++ b/apps/api/plane/utils/filters/__init__.py @@ -0,0 +1,10 @@ +# Filters module for handling complex filtering operations + +# Import all utilities from base modules +from .filter_backend import ComplexFilterBackend +from .converters import LegacyToRichFiltersConverter +from .filterset import BaseFilterSet, IssueFilterSet + + +# Public API exports +__all__ = ["ComplexFilterBackend", "LegacyToRichFiltersConverter", "BaseFilterSet", "IssueFilterSet"] diff --git a/apps/api/plane/utils/filters/converters.py b/apps/api/plane/utils/filters/converters.py new file mode 100644 index 00000000..f7693b40 --- /dev/null +++ b/apps/api/plane/utils/filters/converters.py @@ -0,0 +1,420 @@ +import re +import uuid +from datetime import datetime +from typing import Any, Dict, List, Union + +from dateutil.parser import parse as dateutil_parse + + +class LegacyToRichFiltersConverter: + # Default mapping from legacy filter names to new rich filter field names + DEFAULT_FIELD_MAPPINGS = { + "state": "state_id", + "labels": "label_id", + "cycle": "cycle_id", + "module": "module_id", + "assignees": "assignee_id", + "mentions": "mention_id", + "created_by": "created_by_id", + "state_group": "state_group", + "priority": "priority", + "project": "project_id", + "start_date": "start_date", + "target_date": "target_date", + } + + # Default fields that expect UUID values + DEFAULT_UUID_FIELDS = { + "state_id", + "label_id", + "cycle_id", + "module_id", + "assignee_id", + "mention_id", + "created_by_id", + "project_id", + } + + # Default valid choices for choice fields + DEFAULT_VALID_CHOICES = { + "state_group": ["backlog", "unstarted", "started", "completed", "cancelled"], + "priority": ["urgent", "high", "medium", "low", "none"], + } + + # Default date fields + DEFAULT_DATE_FIELDS = {"start_date", "target_date"} + + # Pattern for relative date strings like "2_weeks" or "3_months" + DATE_PATTERN = re.compile(r"(\d+)_(weeks|months)$") + + def __init__( + self, + field_mappings: Dict[str, str] = None, + uuid_fields: set = None, + valid_choices: Dict[str, List[str]] = None, + date_fields: set = None, + extend_defaults: bool = True, + ): + """ + Initialize the converter with optional custom configurations. + + Args: + field_mappings: Custom field mappings (legacy_key -> rich_field_name) + uuid_fields: Set of field names that should be validated as UUIDs + valid_choices: Dict of valid choices for choice fields + date_fields: Set of field names that should be treated as dates + extend_defaults: If True, merge with defaults; if False, replace defaults + + Examples: + # Use defaults + converter = LegacyToRichFiltersConverter() + + # Add custom field mapping + converter = LegacyToRichFiltersConverter( + field_mappings={"custom_field": "custom_field_id"} + ) + + # Override priority choices + converter = LegacyToRichFiltersConverter( + valid_choices={"priority": ["critical", "high", "medium", "low"]} + ) + + # Complete replacement (not extending defaults) + converter = LegacyToRichFiltersConverter( + field_mappings={"state": "status_id"}, + extend_defaults=False + ) + """ + if extend_defaults: + # Merge with defaults + self.FIELD_MAPPINGS = {**self.DEFAULT_FIELD_MAPPINGS} + if field_mappings: + self.FIELD_MAPPINGS.update(field_mappings) + + self.UUID_FIELDS = {*self.DEFAULT_UUID_FIELDS} + if uuid_fields: + self.UUID_FIELDS.update(uuid_fields) + + self.VALID_CHOICES = {**self.DEFAULT_VALID_CHOICES} + if valid_choices: + self.VALID_CHOICES.update(valid_choices) + + self.DATE_FIELDS = {*self.DEFAULT_DATE_FIELDS} + if date_fields: + self.DATE_FIELDS.update(date_fields) + else: + # Replace defaults entirely + self.FIELD_MAPPINGS = field_mappings or {} + self.UUID_FIELDS = uuid_fields or set() + self.VALID_CHOICES = valid_choices or {} + self.DATE_FIELDS = date_fields or set() + + def add_field_mapping(self, legacy_key: str, rich_field_name: str) -> None: + """Add or update a single field mapping.""" + self.FIELD_MAPPINGS[legacy_key] = rich_field_name + + def add_uuid_field(self, field_name: str) -> None: + """Add a field that should be validated as UUID.""" + self.UUID_FIELDS.add(field_name) + + def add_choice_field(self, field_name: str, choices: List[str]) -> None: + """Add or update valid choices for a choice field.""" + self.VALID_CHOICES[field_name] = choices + + def add_date_field(self, field_name: str) -> None: + """Add a field that should be treated as a date field.""" + self.DATE_FIELDS.add(field_name) + + def update_mappings( + self, + field_mappings: Dict[str, str] = None, + uuid_fields: set = None, + valid_choices: Dict[str, List[str]] = None, + date_fields: set = None, + ) -> None: + """ + Update multiple configurations at once. + + Args: + field_mappings: Additional field mappings to add/update + uuid_fields: Additional UUID fields to add + valid_choices: Additional choice fields to add/update + date_fields: Additional date fields to add + """ + if field_mappings: + self.FIELD_MAPPINGS.update(field_mappings) + if uuid_fields: + self.UUID_FIELDS.update(uuid_fields) + if valid_choices: + self.VALID_CHOICES.update(valid_choices) + if date_fields: + self.DATE_FIELDS.update(date_fields) + + def _validate_uuid(self, value: str) -> bool: + """Validate if a string is a valid UUID""" + try: + uuid.UUID(str(value)) + return True + except (ValueError, TypeError): + return False + + def _validate_choice(self, field_name: str, value: str) -> bool: + """Validate if a value is valid for a choice field""" + if field_name not in self.VALID_CHOICES: + return True # No validation needed for this field + return value in self.VALID_CHOICES[field_name] + + def _validate_date(self, value: Union[str, datetime]) -> bool: + """Validate if a value is a valid date using dateutil parser""" + if isinstance(value, datetime): + return True + if isinstance(value, str): + try: + # Use dateutil for flexible date parsing + dateutil_parse(value) + return True + except (ValueError, TypeError): + return False + return False + + def _validate_value(self, rich_field_name: str, value: Any) -> bool: + """Validate a single value based on field type""" + if rich_field_name in self.UUID_FIELDS: + return self._validate_uuid(value) + elif rich_field_name in self.VALID_CHOICES: + return self._validate_choice(rich_field_name, value) + elif rich_field_name in self.DATE_FIELDS: + return self._validate_date(value) + return True # No specific validation needed + + def _filter_valid_values(self, rich_field_name: str, values: List[Any]) -> List[Any]: + """Filter out invalid values from a list and return only valid ones""" + valid_values = [] + for value in values: + if self._validate_value(rich_field_name, value): + valid_values.append(value) + return valid_values + + def _add_validation_error(self, strict: bool, validation_errors: List[str], message: str) -> None: + """Add validation error if in strict mode.""" + if strict: + validation_errors.append(message) + + def _add_rich_filter(self, rich_filters: Dict[str, Any], field_name: str, operator: str, value: Any) -> None: + """Add a rich filter with proper field name formatting.""" + # Convert lists to comma-separated strings for 'in' and 'range' operations + if operator in ("in", "range") and isinstance(value, list): + value = ",".join(str(v) for v in value) + rich_filters[f"{field_name}__{operator}"] = value + + def _handle_value_error(self, e: ValueError, strict: bool, validation_errors: List[str]) -> None: + """Handle ValueError with consistent strict/non-strict behavior.""" + if strict: + validation_errors.append(str(e)) + # In non-strict mode, we just skip (no action needed) + + def _process_date_field( + self, + rich_field_name: str, + values: List[str], + strict: bool, + validation_errors: List[str], + rich_filters: Dict[str, Any], + ) -> bool: + """Process date field with basic functionality (exact, range).""" + if rich_field_name not in self.DATE_FIELDS: + return False + + try: + date_filter_result = self._convert_date_value(rich_field_name, values, strict) + if date_filter_result: + rich_filters.update(date_filter_result) + return True + except ValueError as e: + self._handle_value_error(e, strict, validation_errors) + return True + + def _convert_date_value(self, field_name: str, values: List[str], strict: bool = False) -> Dict[str, Any]: + """ + Convert legacy date values to rich filter format - basic implementation. + + Supports: + - Simple dates: "2023-01-01" -> __exact + - Basic ranges: ["2023-01-01;after", "2023-12-31;before"] -> __range + - Skips complex or relative date patterns + + Args: + field_name: Name of the rich filter field + values: List of legacy date values + strict: If True, raise errors for validation failures + + Raises: + ValueError: For malformed date patterns (strict mode) + """ + # Check for relative dates and skip the entire field if found + for value in values: + if ";" in value: + parts = value.split(";") + if len(parts) > 0 and self.DATE_PATTERN.match(parts[0]): + # Skip relative date patterns entirely + return {} + + # Skip complex conditions (more than 2 values) + if len(values) > 2: + return {} + + # Process each date value + exact_dates = [] + after_dates = [] + before_dates = [] + + for value in values: + if ";" not in value: + # Simple date string + if not self._validate_date(value): + if strict: + raise ValueError(f"Invalid date format: {value}") + continue + exact_dates.append(value) + else: + # Directional date - only handle basic after/before + parts = value.split(";") + if len(parts) < 2: + if strict: + raise ValueError(f"Invalid date format: {value}") + continue + + date_part = parts[0] + direction = parts[1] + + if not self._validate_date(date_part): + if strict: + raise ValueError(f"Invalid date format: {date_part}") + continue + + if direction == "after": + after_dates.append(date_part) + elif direction == "before": + before_dates.append(date_part) + # Skip unsupported directions + + # Determine return format + result = {} + if len(after_dates) == 1 and len(before_dates) == 1 and len(exact_dates) == 0: + # Simple range: one after and one before + start_date = min(after_dates[0], before_dates[0]) + end_date = max(after_dates[0], before_dates[0]) + self._add_rich_filter(result, field_name, "range", [start_date, end_date]) + elif len(exact_dates) == 1 and len(after_dates) == 0 and len(before_dates) == 0: + # Single exact date + self._add_rich_filter(result, field_name, "exact", exact_dates[0]) + # Skip all other combinations + + return result + + def convert(self, legacy_filters: dict, strict: bool = False) -> Dict[str, Any]: + """ + Convert legacy filters to rich filters format with validation + + Args: + legacy_filters: Dictionary of legacy filters + strict: If True, raise exception on validation errors. + If False, skip invalid values (default behavior) + + Returns: + Dictionary of rich filters + + Raises: + ValueError: If strict=True and validation fails + """ + rich_filters = {} + validation_errors = [] + + for legacy_key, value in legacy_filters.items(): + # Skip if value is None or empty + if value is None or (isinstance(value, list) and len(value) == 0): + continue + + # Skip if legacy key is not in our mappings (not supported in filterset) + if legacy_key not in self.FIELD_MAPPINGS: + self._add_validation_error(strict, validation_errors, f"Unsupported filter key: {legacy_key}") + continue + + # Get the new field name + rich_field_name = self.FIELD_MAPPINGS[legacy_key] + + # Handle list values + if isinstance(value, list): + # Process date fields with helper method + if self._process_date_field(rich_field_name, value, strict, validation_errors, rich_filters): + continue + + # Regular non-date field processing + # Filter out invalid values + valid_values = self._filter_valid_values(rich_field_name, value) + + if not valid_values: + self._add_validation_error( + strict, + validation_errors, + f"No valid values found for {legacy_key}: {value}", + ) + continue + + # Check for invalid values if in strict mode + if strict and len(valid_values) != len(value): + invalid_values = [v for v in value if v not in valid_values] + self._add_validation_error( + strict, + validation_errors, + f"Invalid values for {legacy_key}: {invalid_values}", + ) + + # For list values, always use __in operator for non-date fields + self._add_rich_filter(rich_filters, rich_field_name, "in", valid_values) + + else: + # Handle single values + # Process date fields with helper method + if self._process_date_field(rich_field_name, [value], strict, validation_errors, rich_filters): + continue + + # For non-list values, use __exact operator for non-date fields + if self._validate_value(rich_field_name, value): + self._add_rich_filter(rich_filters, rich_field_name, "exact", value) + else: + error_msg = f"Invalid value for {legacy_key}: {value}" + self._add_validation_error(strict, validation_errors, error_msg) + + # Raise validation errors if in strict mode + if strict and validation_errors: + error_message = f"Filter validation errors: {'; '.join(validation_errors)}" + raise ValueError(error_message) + + # Convert flat dict to rich filter format + return self._format_as_rich_filter(rich_filters) + + def _format_as_rich_filter(self, flat_filters: Dict[str, Any]) -> Dict[str, Any]: + """ + Convert a flat dictionary of filters to the proper rich filter format. + + Args: + flat_filters: Dictionary with field__lookup keys and values + + Returns: + Rich filter format using logical operators (and/or/not) + """ + if not flat_filters: + return {} + + # If only one filter, return as leaf node + if len(flat_filters) == 1: + key, value = next(iter(flat_filters.items())) + return {key: value} + + # Multiple filters: wrap in 'and' operator + filter_conditions = [] + for key, value in flat_filters.items(): + filter_conditions.append({key: value}) + + return {"and": filter_conditions} diff --git a/apps/api/plane/utils/filters/filter_backend.py b/apps/api/plane/utils/filters/filter_backend.py new file mode 100644 index 00000000..2f7a27d3 --- /dev/null +++ b/apps/api/plane/utils/filters/filter_backend.py @@ -0,0 +1,313 @@ +import json + +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.http import QueryDict +from django_filters.utils import translate_validation +from rest_framework import filters + + +class ComplexFilterBackend(filters.BaseFilterBackend): + """ + Filter backend that supports complex JSON filtering. + + For full, up-to-date examples and usage, see the package README + at `plane/utils/filters/README.md`. + """ + + filter_param = "filters" + default_max_depth = 5 + + def filter_queryset(self, request, queryset, view, filter_data=None): + """Normalize filter input and apply JSON-based filtering. + + Accepts explicit `filter_data` (dict or JSON string) or reads the + `filter` query parameter. Enforces JSON-only filtering. + """ + try: + if filter_data is not None: + normalized = self._normalize_filter_data(filter_data, "filter_data") + return self._apply_json_filter(queryset, normalized, view) + + filter_string = request.query_params.get(self.filter_param, None) + if not filter_string: + return queryset + + normalized = self._normalize_filter_data(filter_string, "filter") + return self._apply_json_filter(queryset, normalized, view) + except ValidationError: + # Propagate validation errors unchanged + raise + except Exception as e: + # Convert unexpected errors to ValidationError to keep response consistent + raise ValidationError(f"Filter error: {str(e)}") + + def _normalize_filter_data(self, raw_filter, source_label): + """Return a dict from raw filter input or raise a ValidationError. + + - raw_filter may be a dict or a JSON string + - source_label is used in error messages (e.g., 'filter_data' or 'filter') + """ + try: + if isinstance(raw_filter, str): + return json.loads(raw_filter) + if isinstance(raw_filter, dict): + return raw_filter + raise ValidationError(f"'{source_label}' must be a dict or a JSON string.") + except json.JSONDecodeError: + raise ValidationError(f"Invalid JSON for '{source_label}'. Expected a valid JSON object.") + + def _apply_json_filter(self, queryset, filter_data, view): + """Process a JSON filter structure using Q object composition.""" + if not filter_data: + return queryset + + # Validate structure and depth before field allowlist checks + max_depth = self._get_max_depth(view) + self._validate_structure(filter_data, max_depth=max_depth, current_depth=1) + + # Validate against the view's FilterSet (only declared filters are allowed) + self._validate_fields(filter_data, view) + + # Build combined Q object from the filter tree + combined_q = self._evaluate_node(filter_data, view, queryset) + if combined_q is None: + return queryset + + # Apply the combined Q object to the queryset once + return queryset.filter(combined_q) + + def _validate_fields(self, filter_data, view): + """Validate that filtered fields are defined in the view's FilterSet.""" + filterset_class = getattr(view, "filterset_class", None) + allowed_fields = set(filterset_class.base_filters.keys()) if filterset_class else None + if not allowed_fields: + # If no FilterSet is configured, reject filtering to avoid unintended exposure # noqa: E501 + raise ValidationError("Filtering is not enabled for this endpoint (missing filterset_class)") + + # Extract field names from the filter data + fields = self._extract_field_names(filter_data) + + # Check if all fields are allowed + for field in fields: + # Field keys must match FilterSet filter names (including any lookups) + # Example: 'sequence_id__gte' should be declared in base_filters + # Special-case __range: require the '__range' filter itself + if field not in allowed_fields: + raise ValidationError(f"Filtering on field '{field}' is not allowed") + + def _extract_field_names(self, filter_data): + """Extract all field names from a nested filter structure""" + if isinstance(filter_data, dict): + fields = [] + for key, value in filter_data.items(): + if key.lower() in ("or", "and", "not"): + # This is a logical operator, process its children + if key.lower() == "not": + # 'not' has a dict as its value, not a list + if isinstance(value, dict): + fields.extend(self._extract_field_names(value)) + else: + # 'or' and 'and' have lists as their values + for item in value: + fields.extend(self._extract_field_names(item)) + else: + # This is a field name + fields.append(key) + return fields + return [] + + def _evaluate_node(self, node, view, queryset): + """ + Recursively evaluate a JSON node into a combined Q object. + + Rules: + - leaf dict → evaluated through FilterSet to produce a Q object + - {"or": [...]} → Q() | Q() | ... (OR of children) + - {"and": [...]} → Q() & Q() & ... (AND of children) + - {"not": {...}} → ~Q() (negation of child) + + Returns a Q object that can be applied to a queryset. + """ + if not isinstance(node, dict): + return None + + # 'or' combination - OR of child Q objects + if "or" in node: + children = node["or"] + if not isinstance(children, list) or not children: + return None + combined_q = Q() + for child in children: + child_q = self._evaluate_node(child, view, queryset) + if child_q is None: + continue + combined_q |= child_q + return combined_q + + # 'and' combination - AND of child Q objects + if "and" in node: + children = node["and"] + if not isinstance(children, list) or not children: + return None + combined_q = Q() + for child in children: + child_q = self._evaluate_node(child, view, queryset) + if child_q is None: + continue + combined_q &= child_q + return combined_q + + # 'not' negation - negate the child Q object + if "not" in node: + child = node["not"] + if not isinstance(child, dict): + return None + child_q = self._evaluate_node(child, view, queryset) + if child_q is None: + return None + return ~child_q + + # Leaf dict: evaluate via FilterSet to get a Q object + return self._build_leaf_q(node, view, queryset) + + def _build_leaf_q(self, leaf_conditions, view, queryset): + """Build a Q object from leaf filter conditions using the view's FilterSet. + + We serialize the leaf dict into a QueryDict and let the view's + filterset_class perform validation and build a combined Q object + from all the field filters. + + Returns a Q object representing all the field conditions in the leaf. + """ + if not leaf_conditions: + return Q() + + # Get the filterset class from the view + filterset_class = getattr(view, "filterset_class", None) + if not filterset_class: + raise ValidationError("Filtering requires a filterset_class to be defined on the view") + + # Build a QueryDict from the leaf conditions + qd = QueryDict(mutable=True) + for key, value in leaf_conditions.items(): + # Default serialization to string; QueryDict expects strings + if isinstance(value, list): + # Repeat key for list values (e.g., __in) + qd.setlist(key, [str(v) for v in value]) + else: + qd[key] = "" if value is None else str(value) + + qd = qd.copy() + qd._mutable = False + + # Instantiate the filterset with the actual queryset + # Custom filter methods may need access to the queryset for filtering + fs = filterset_class(data=qd, queryset=queryset) + + if not fs.is_valid(): + raise translate_validation(fs.errors) + + # Build and return the combined Q object + if not hasattr(fs, "build_combined_q"): + raise ValidationError("FilterSet must have build_combined_q method for complex filtering") + + return fs.build_combined_q() + + def _get_max_depth(self, view): + """Return the maximum allowed nesting depth for complex filters. + + Falls back to class default if the view does not specify it or has + an invalid value. + """ + value = getattr(view, "complex_filter_max_depth", self.default_max_depth) + try: + value_int = int(value) + if value_int <= 0: + return self.default_max_depth + return value_int + except Exception: + return self.default_max_depth + + def _validate_structure(self, node, max_depth, current_depth): + """Validate JSON structure and enforce nesting depth. + + Rules: + - Each object may contain only one logical operator: + or/and/not (case-insensitive) + - Logical operator objects cannot contain field keys alongside the + operator + - or/and values must be non-empty lists of dicts + - not value must be a dict + - Leaf objects must only contain field keys and acceptable values + - Depth must not exceed max_depth + """ + if current_depth > max_depth: + raise ValidationError(f"Filter nesting is too deep (max {max_depth}); found depth {current_depth}") + + if not isinstance(node, dict): + raise ValidationError("Each filter node must be a JSON object") + + if not node: + raise ValidationError("Filter objects must not be empty") + + logical_keys = [k for k in node.keys() if isinstance(k, str) and k.lower() in ("or", "and", "not")] + + if len(logical_keys) > 1: + raise ValidationError("A filter object cannot contain multiple logical operators at the same level") + + if len(logical_keys) == 1: + op_key = logical_keys[0] + # must not mix operator with other keys + if len(node) != 1: + raise ValidationError(f"Cannot mix logical operator '{op_key}' with field keys at the same level") + + op = op_key.lower() + value = node[op_key] + + if op in ("or", "and"): + if not isinstance(value, list) or len(value) == 0: + raise ValidationError(f"'{op}' must be a non-empty list of filter objects") + for child in value: + if not isinstance(child, dict): + raise ValidationError(f"All children of '{op}' must be JSON objects") + self._validate_structure( + child, + max_depth=max_depth, + current_depth=current_depth + 1, + ) + return + + if op == "not": + if not isinstance(value, dict): + raise ValidationError("'not' must be a single JSON object") + self._validate_structure(value, max_depth=max_depth, current_depth=current_depth + 1) + return + + # Leaf node: validate fields and values + self._validate_leaf(node) + + def _validate_leaf(self, leaf): + """Validate a leaf dict containing field lookups and values.""" + if not isinstance(leaf, dict) or not leaf: + raise ValidationError("Leaf filter must be a non-empty JSON object") + + for key, value in leaf.items(): + if isinstance(key, str) and key.lower() in ("or", "and", "not"): + raise ValidationError("Logical operators cannot appear in a leaf filter object") + + # Lists/Tuples must contain only scalar values + if isinstance(value, (list, tuple)): + if len(value) == 0: + raise ValidationError(f"List value for '{key}' must not be empty") + for item in value: + if not self._is_scalar(item): + raise ValidationError(f"List value for '{key}' must contain only scalar items") + continue + + # Scalars and None are allowed + if not self._is_scalar(value): + raise ValidationError(f"Value for '{key}' must be a scalar, null, or list/tuple of scalars") + + def _is_scalar(self, value): + return value is None or isinstance(value, (str, int, float, bool)) diff --git a/apps/api/plane/utils/filters/filter_migrations.py b/apps/api/plane/utils/filters/filter_migrations.py new file mode 100644 index 00000000..3e424b6e --- /dev/null +++ b/apps/api/plane/utils/filters/filter_migrations.py @@ -0,0 +1,133 @@ +""" +Utilities for migrating legacy filters to rich filters format. + +This module contains helper functions for data migrations that convert +filters fields to rich_filters fields using the LegacyToRichFiltersConverter. +""" + +import logging +from typing import Any, Dict, Tuple + +from .converters import LegacyToRichFiltersConverter + + +logger = logging.getLogger("plane.api.filters.migration") + + +def migrate_single_model_filters( + model_class, model_name: str, converter: LegacyToRichFiltersConverter +) -> Tuple[int, int]: + """ + Migrate filters to rich_filters for a single model. + + Args: + model_class: Django model class + model_name: Human-readable name for logging + converter: Instance of LegacyToRichFiltersConverter + + Returns: + Tuple of (updated_count, error_count) + """ + # Find records that need migration - have filters but empty rich_filters + records_to_migrate = model_class.objects.exclude(filters={}).filter(rich_filters={}) + + if records_to_migrate.count() == 0: + logger.info(f"No {model_name} records need migration") + return 0, 0 + + logger.info(f"Found {records_to_migrate.count()} {model_name} records to migrate") + + updated_records = [] + conversion_errors = 0 + + for record in records_to_migrate: + try: + if record.filters: # Double check that filters is not empty + rich_filters = converter.convert(record.filters, strict=False) + record.rich_filters = rich_filters + updated_records.append(record) + + except Exception as e: + logger.warning(f"Failed to convert filters for {model_name} ID {record.id}: {str(e)}") + conversion_errors += 1 + continue + + # Bulk update all successfully converted records + if updated_records: + model_class.objects.bulk_update(updated_records, ["rich_filters"], batch_size=1000) + logger.info(f"Successfully updated {len(updated_records)} {model_name} records") + + return len(updated_records), conversion_errors + + +def migrate_models_filters_to_rich_filters( + models_to_migrate: Dict[str, Any], + converter: LegacyToRichFiltersConverter, +) -> Dict[str, Tuple[int, int]]: + """ + Migrate legacy filters to rich_filters format for provided models. + + Args: + models_to_migrate: Dict mapping model names to model classes + + Returns: + Dictionary mapping model names to (updated_count, error_count) tuples + """ + # Initialize the converter with default settings + + logger.info("Starting filters to rich_filters migration for all models") + + results = {} + total_updated = 0 + total_errors = 0 + + for model_name, model_class in models_to_migrate.items(): + try: + updated_count, error_count = migrate_single_model_filters(model_class, model_name, converter) + + results[model_name] = (updated_count, error_count) + total_updated += updated_count + total_errors += error_count + + except Exception as e: + logger.error(f"Failed to migrate {model_name}: {str(e)}") + results[model_name] = (0, 1) + total_errors += 1 + continue + + # Log final summary + logger.info(f"Migration completed for all models. Total updated: {total_updated}, Total errors: {total_errors}") + + return results + + +def clear_models_rich_filters(models_to_clear: Dict[str, Any]) -> Dict[str, int]: + """ + Clear rich_filters field for provided models (for reverse migration). + + Args: + models_to_clear: Dictionary mapping model names to model classes + + Returns: + Dictionary mapping model names to count of cleared records + """ + logger.info("Starting reverse migration - clearing rich_filters for all models") + + results = {} + total_cleared = 0 + + for model_name, model_class in models_to_clear.items(): + try: + # Clear rich_filters for all records that have them + updated_count = model_class.objects.exclude(rich_filters={}).update(rich_filters={}) + results[model_name] = updated_count + total_cleared += updated_count + logger.info(f"Cleared rich_filters for {updated_count} {model_name} records") + + except Exception as e: + logger.error(f"Failed to clear rich_filters for {model_name}: {str(e)}") + results[model_name] = 0 + continue + + logger.info(f"Reverse migration completed. Total cleared: {total_cleared}") + return results diff --git a/apps/api/plane/utils/filters/filterset.py b/apps/api/plane/utils/filters/filterset.py new file mode 100644 index 00000000..0099b83d --- /dev/null +++ b/apps/api/plane/utils/filters/filterset.py @@ -0,0 +1,262 @@ +import copy + +from django.db import models +from django.db.models import Q +from django_filters import FilterSet, filters + +from plane.db.models import Issue + + +class UUIDInFilter(filters.BaseInFilter, filters.UUIDFilter): + pass + + +class CharInFilter(filters.BaseInFilter, filters.CharFilter): + pass + + +class BaseFilterSet(FilterSet): + @classmethod + def get_filters(cls): + """ + Get all filters for the filterset, including dynamically created __exact filters. + """ + # Get the standard filters first + filters = super().get_filters() + + # Add __exact versions for filters that have 'exact' lookup + exact_filters = {} + for filter_name, filter_obj in filters.items(): + if hasattr(filter_obj, "lookup_expr") and filter_obj.lookup_expr == "exact": + exact_field_name = f"{filter_name}__exact" + if exact_field_name not in filters: + # Copy the filter object as-is and assign it to the new name + exact_filters[exact_field_name] = copy.deepcopy(filter_obj) + + # Add the exact filters to the main filters dict + filters.update(exact_filters) + return filters + + def build_combined_q(self): + """ + Build a combined Q object from all bound filters. + + For filters with custom methods, we call them and expect Q objects (or wrap + QuerySets as subqueries for backward compatibility). + For standard field filters, we build Q objects directly from field lookups. + + Returns: + Q object representing all filter conditions combined. + """ + # Ensure form validation has occurred + self.errors + + combined_q = Q() + + # Handle case where cleaned_data might be None or empty + if not self.form.cleaned_data: + return combined_q + + # Only process filters that were actually provided in the request data + # This avoids processing all declared filters with None/empty default values + provided_filters = set(self.data.keys()) if self.data else set() + + for name, value in self.form.cleaned_data.items(): + # Skip filters that weren't provided in the request + if name not in provided_filters: + continue + + f = self.filters[name] + + # Build the Q object for this filter + if f.method is not None: + # Custom filter method - call it to get Q object + res = f.filter(self.queryset, value) + if isinstance(res, Q): + q_piece = res + elif isinstance(res, models.QuerySet): + # Backward compatibility: wrap QuerySet as subquery + q_piece = Q(pk__in=res.values("pk")) + else: + raise TypeError( + f"Filter method '{name}' must return Q object or QuerySet, got {type(res).__name__}" + ) + else: + # Standard field filter - build Q object directly + lookup = f"{f.field_name}__{f.lookup_expr}" + q_piece = Q(**{lookup: value}) + + # Apply exclude/include logic + if getattr(f, "exclude", False): + combined_q &= ~q_piece + else: + combined_q &= q_piece + + return combined_q + + def filter_queryset(self, queryset): + """ + Override to use Q-based filtering for compatibility with DjangoFilterBackend. + + This allows the same filterset to work with both ComplexFilterBackend + (which calls build_combined_q directly) and DjangoFilterBackend + (which calls this method). + """ + # Ensure form validation + self.errors + + # Build combined Q and apply to queryset + combined_q = self.build_combined_q() + qs = queryset.filter(combined_q) + + # Apply distinct if any filter requires it (typically for many-to-many relations) + for f in self.filters.values(): + if getattr(f, "distinct", False): + return qs.distinct() + + return qs + + +class IssueFilterSet(BaseFilterSet): + # Custom filter methods to handle soft delete exclusion for relations + + assignee_id = filters.UUIDFilter(method="filter_assignee_id") + assignee_id__in = UUIDInFilter(method="filter_assignee_id_in", lookup_expr="in") + + cycle_id = filters.UUIDFilter(method="filter_cycle_id") + cycle_id__in = UUIDInFilter(method="filter_cycle_id_in", lookup_expr="in") + + module_id = filters.UUIDFilter(method="filter_module_id") + module_id__in = UUIDInFilter(method="filter_module_id_in", lookup_expr="in") + + mention_id = filters.UUIDFilter(method="filter_mention_id") + mention_id__in = UUIDInFilter(method="filter_mention_id_in", lookup_expr="in") + + label_id = filters.UUIDFilter(method="filter_label_id") + label_id__in = UUIDInFilter(method="filter_label_id_in", lookup_expr="in") + + # Direct field lookups remain the same + created_by_id = filters.UUIDFilter(field_name="created_by_id") + created_by_id__in = UUIDInFilter(field_name="created_by_id", lookup_expr="in") + + is_archived = filters.BooleanFilter(method="filter_is_archived") + + state_group = filters.CharFilter(field_name="state__group") + state_group__in = CharInFilter(field_name="state__group", lookup_expr="in") + + state_id = filters.UUIDFilter(field_name="state_id") + state_id__in = UUIDInFilter(field_name="state_id", lookup_expr="in") + + project_id = filters.UUIDFilter(field_name="project_id") + project_id__in = UUIDInFilter(field_name="project_id", lookup_expr="in") + + subscriber_id = filters.UUIDFilter(method="filter_subscriber_id") + subscriber_id__in = UUIDInFilter(method="filter_subscriber_id_in", lookup_expr="in") + + class Meta: + model = Issue + fields = { + "start_date": ["exact", "range"], + "target_date": ["exact", "range"], + "created_at": ["exact", "range"], + "updated_at": ["exact", "range"], + "is_draft": ["exact"], + "priority": ["exact", "in"], + } + + def filter_is_archived(self, queryset, name, value): + """ + Convenience filter: archived=true -> archived_at is not null, + archived=false -> archived_at is null + """ + if value in (True, "true", "True", 1, "1"): + return Q(archived_at__isnull=False) + if value in (False, "false", "False", 0, "0"): + return Q(archived_at__isnull=True) + return Q() # No filter + + # Filter methods with soft delete exclusion for relations + + def filter_assignee_id(self, queryset, name, value): + """Filter by assignee ID, excluding soft deleted users""" + return Q( + issue_assignee__assignee_id=value, + issue_assignee__deleted_at__isnull=True, + ) + + def filter_assignee_id_in(self, queryset, name, value): + """Filter by assignee IDs (in), excluding soft deleted users""" + return Q( + issue_assignee__assignee_id__in=value, + issue_assignee__deleted_at__isnull=True, + ) + + def filter_cycle_id(self, queryset, name, value): + """Filter by cycle ID, excluding soft deleted cycles""" + return Q( + issue_cycle__cycle_id=value, + issue_cycle__deleted_at__isnull=True, + ) + + def filter_cycle_id_in(self, queryset, name, value): + """Filter by cycle IDs (in), excluding soft deleted cycles""" + return Q( + issue_cycle__cycle_id__in=value, + issue_cycle__deleted_at__isnull=True, + ) + + def filter_module_id(self, queryset, name, value): + """Filter by module ID, excluding soft deleted modules""" + return Q( + issue_module__module_id=value, + issue_module__deleted_at__isnull=True, + ) + + def filter_module_id_in(self, queryset, name, value): + """Filter by module IDs (in), excluding soft deleted modules""" + return Q( + issue_module__module_id__in=value, + issue_module__deleted_at__isnull=True, + ) + + def filter_mention_id(self, queryset, name, value): + """Filter by mention ID, excluding soft deleted users""" + return Q( + issue_mention__mention_id=value, + issue_mention__deleted_at__isnull=True, + ) + + def filter_mention_id_in(self, queryset, name, value): + """Filter by mention IDs (in), excluding soft deleted users""" + return Q( + issue_mention__mention_id__in=value, + issue_mention__deleted_at__isnull=True, + ) + + def filter_label_id(self, queryset, name, value): + """Filter by label ID, excluding soft deleted labels""" + return Q( + label_issue__label_id=value, + label_issue__deleted_at__isnull=True, + ) + + def filter_label_id_in(self, queryset, name, value): + """Filter by label IDs (in), excluding soft deleted labels""" + return Q( + label_issue__label_id__in=value, + label_issue__deleted_at__isnull=True, + ) + + def filter_subscriber_id(self, queryset, name, value): + """Filter by subscriber ID, excluding soft deleted users""" + return Q( + issue_subscribers__subscriber_id=value, + issue_subscribers__deleted_at__isnull=True, + ) + + def filter_subscriber_id_in(self, queryset, name, value): + """Filter by subscriber IDs (in), excluding soft deleted users""" + return Q( + issue_subscribers__subscriber_id__in=value, + issue_subscribers__deleted_at__isnull=True, + ) diff --git a/apps/api/plane/utils/global_paginator.py b/apps/api/plane/utils/global_paginator.py new file mode 100644 index 00000000..1b7f908c --- /dev/null +++ b/apps/api/plane/utils/global_paginator.py @@ -0,0 +1,83 @@ +# python imports +from math import ceil + +# constants +PAGINATOR_MAX_LIMIT = 1000 + + +class PaginateCursor: + def __init__(self, current_page_size: int, current_page: int, offset: int): + self.current_page_size = current_page_size + self.current_page = current_page + self.offset = offset + + def __str__(self): + return f"{self.current_page_size}:{self.current_page}:{self.offset}" + + @classmethod + def from_string(self, value): + """Return the cursor value from string format""" + try: + bits = value.split(":") + if len(bits) != 3: + 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}") + + +def paginate(base_queryset, queryset, cursor, on_result): + # validating for cursor + if cursor is None: + cursor_object = PaginateCursor(PAGINATOR_MAX_LIMIT, 0, 0) + else: + cursor_object = PaginateCursor.from_string(cursor) + + # getting the issues count + total_results = base_queryset.count() + page_size = min(cursor_object.current_page_size, PAGINATOR_MAX_LIMIT) + + # getting the total pages available based on the page size + total_pages = ceil(total_results / page_size) + + # Calculate the start and end index for the paginated data + start_index = 0 + if cursor_object.current_page > 0: + start_index = cursor_object.current_page * page_size + end_index = min(start_index + page_size, total_results) + + # Get the paginated data + paginated_data = queryset[start_index:end_index] + + # Create the pagination info object + prev_cursor = f"{page_size}:{cursor_object.current_page - 1}:0" + cursor = f"{page_size}:{cursor_object.current_page}:0" + next_cursor = None + if end_index < total_results: + next_cursor = f"{page_size}:{cursor_object.current_page + 1}:0" + + prev_page_results = False + if cursor_object.current_page > 0: + prev_page_results = True + + next_page_results = False + if next_cursor: + next_page_results = True + + if on_result: + paginated_data = on_result(paginated_data) + + # returning the result + paginated_data = { + "prev_cursor": prev_cursor, + "cursor": cursor, + "next_cursor": next_cursor, + "prev_page_results": prev_page_results, + "next_page_results": next_page_results, + "page_count": len(paginated_data), + "total_results": total_results, + "total_pages": total_pages, + "results": paginated_data, + } + + return paginated_data diff --git a/apps/api/plane/utils/grouper.py b/apps/api/plane/utils/grouper.py new file mode 100644 index 00000000..1ec004e9 --- /dev/null +++ b/apps/api/plane/utils/grouper.py @@ -0,0 +1,213 @@ +# Django imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Q, UUIDField, Value, QuerySet, OuterRef, Subquery +from django.db.models.functions import Coalesce + +# Module imports +from plane.db.models import ( + Cycle, + Issue, + Label, + Module, + Project, + ProjectMember, + State, + WorkspaceMember, + IssueAssignee, + ModuleIssue, + IssueLabel, +) +from typing import Optional, Dict, Tuple, Any, Union, List + + +def issue_queryset_grouper( + queryset: QuerySet[Issue], + group_by: Optional[str], + sub_group_by: Optional[str], +) -> QuerySet[Issue]: + FIELD_MAPPER: Dict[str, str] = { + "label_ids": "labels__id", + "assignee_ids": "assignees__id", + "module_ids": "issue_module__module_id", + } + + GROUP_FILTER_MAPPER: Dict[str, Q] = { + "assignees__id": Q(issue_assignee__deleted_at__isnull=True), + "labels__id": Q(label_issue__deleted_at__isnull=True), + "issue_module__module_id": Q(issue_module__deleted_at__isnull=True), + } + + for group_key in [group_by, sub_group_by]: + if group_key in GROUP_FILTER_MAPPER: + queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key]) + + issue_assignee_subquery = Subquery( + IssueAssignee.objects.filter( + issue_id=OuterRef("pk"), + deleted_at__isnull=True, + ) + .values("issue_id") + .annotate(arr=ArrayAgg("assignee_id", distinct=True)) + .values("arr") + ) + + issue_module_subquery = Subquery( + ModuleIssue.objects.filter( + issue_id=OuterRef("pk"), + deleted_at__isnull=True, + module__archived_at__isnull=True, + ) + .values("issue_id") + .annotate(arr=ArrayAgg("module_id", distinct=True)) + .values("arr") + ) + + issue_label_subquery = Subquery( + IssueLabel.objects.filter(issue_id=OuterRef("pk"), deleted_at__isnull=True) + .values("issue_id") + .annotate(arr=ArrayAgg("label_id", distinct=True)) + .values("arr") + ) + + annotations_map: Dict[str, Tuple[str, Q]] = { + "assignee_ids": Coalesce(issue_assignee_subquery, Value([], output_field=ArrayField(UUIDField()))), + "label_ids": Coalesce(issue_label_subquery, Value([], output_field=ArrayField(UUIDField()))), + "module_ids": Coalesce(issue_module_subquery, Value([], output_field=ArrayField(UUIDField()))), + } + + default_annotations: Dict[str, Any] = {} + + for key, expression in annotations_map.items(): + if FIELD_MAPPER.get(key) in {group_by, sub_group_by}: + continue + default_annotations[key] = expression + + return queryset.annotate(**default_annotations) + + +def issue_on_results( + issues: QuerySet[Issue], + group_by: Optional[str], + sub_group_by: Optional[str], +) -> List[Dict[str, Any]]: + FIELD_MAPPER: Dict[str, str] = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "issue_module__module_id": "module_ids", + } + + original_list: List[str] = ["assignee_ids", "label_ids", "module_ids"] + + required_fields: List[str] = [ + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + "state__group", + ] + + if group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[group_by]) + original_list.append(group_by) + + if sub_group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[sub_group_by]) + original_list.append(sub_group_by) + + required_fields.extend(original_list) + return list(issues.values(*required_fields)) + + +def issue_group_values( + field: str, + slug: str, + project_id: Optional[str] = None, + filters: Dict[str, Any] = {}, + queryset: Optional[QuerySet] = None, +) -> List[Union[str, Any]]: + if field == "state_id": + queryset = State.objects.filter(is_triage=False, workspace__slug=slug).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + return list(queryset) + + if field == "labels__id": + queryset = Label.objects.filter(workspace__slug=slug).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + return list(queryset) + ["None"] + + if field == "assignees__id": + if project_id: + return list( + ProjectMember.objects.filter(workspace__slug=slug, project_id=project_id, is_active=True).values_list( + "member_id", flat=True + ) + ) + return list( + WorkspaceMember.objects.filter(workspace__slug=slug, is_active=True).values_list("member_id", flat=True) + ) + + if field == "issue_module__module_id": + queryset = Module.objects.filter(workspace__slug=slug).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + return list(queryset) + ["None"] + + if field == "cycle_id": + queryset = Cycle.objects.filter(workspace__slug=slug).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + return list(queryset) + ["None"] + + if field == "project_id": + queryset = Project.objects.filter(workspace__slug=slug).values_list("id", flat=True) + return list(queryset) + + if field == "priority": + return ["low", "medium", "high", "urgent", "none"] + + if field == "state__group": + return ["backlog", "unstarted", "started", "completed", "cancelled"] + + if field == "target_date": + queryset = queryset.values_list("target_date", flat=True).distinct() + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + + if field == "start_date": + queryset = queryset.values_list("start_date", flat=True).distinct() + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + + if field == "created_by": + queryset = queryset.values_list("created_by", flat=True).distinct() + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + + return [] diff --git a/apps/api/plane/utils/host.py b/apps/api/plane/utils/host.py new file mode 100644 index 00000000..860e19e0 --- /dev/null +++ b/apps/api/plane/utils/host.py @@ -0,0 +1,67 @@ +# Django imports +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.http import HttpRequest + +# Third party imports +from rest_framework.request import Request + +# Module imports +from plane.utils.ip_address import get_client_ip + + +def base_host( + request: Request | HttpRequest, + is_admin: bool = False, + is_space: bool = False, + is_app: bool = False, +) -> str: + """Utility function to return host / origin from the request""" + # Calculate the base origin from request + base_origin = settings.WEB_URL or settings.APP_BASE_URL + + if not base_origin: + raise ImproperlyConfigured("APP_BASE_URL or WEB_URL is not set") + + # Admin redirection + if is_admin: + admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None) + if not isinstance(admin_base_path, str): + admin_base_path = "/god-mode/" + if not admin_base_path.startswith("/"): + admin_base_path = "/" + admin_base_path + if not admin_base_path.endswith("/"): + admin_base_path += "/" + + if settings.ADMIN_BASE_URL: + return settings.ADMIN_BASE_URL + admin_base_path + else: + return base_origin + admin_base_path + + # Space redirection + if is_space: + space_base_path = getattr(settings, "SPACE_BASE_PATH", None) + if not isinstance(space_base_path, str): + space_base_path = "/spaces/" + if not space_base_path.startswith("/"): + space_base_path = "/" + space_base_path + if not space_base_path.endswith("/"): + space_base_path += "/" + + if settings.SPACE_BASE_URL: + return settings.SPACE_BASE_URL + space_base_path + else: + return base_origin + space_base_path + + # App Redirection + if is_app: + if settings.APP_BASE_URL: + return settings.APP_BASE_URL + else: + return base_origin + + return base_origin + + +def user_ip(request: Request | HttpRequest) -> str: + return get_client_ip(request=request) diff --git a/apps/api/plane/utils/html_processor.py b/apps/api/plane/utils/html_processor.py new file mode 100644 index 00000000..18d103b6 --- /dev/null +++ b/apps/api/plane/utils/html_processor.py @@ -0,0 +1,27 @@ +from io import StringIO +from html.parser import HTMLParser + + +class MLStripper(HTMLParser): + """ + Markup Language Stripper + """ + + def __init__(self): + super().__init__() + self.reset() + self.strict = False + self.convert_charrefs = True + self.text = StringIO() + + def handle_data(self, d): + self.text.write(d) + + def get_data(self): + return self.text.getvalue() + + +def strip_tags(html): + s = MLStripper() + s.feed(html) + return s.get_data() diff --git a/apps/api/plane/utils/imports.py b/apps/api/plane/utils/imports.py new file mode 100644 index 00000000..81de0203 --- /dev/null +++ b/apps/api/plane/utils/imports.py @@ -0,0 +1,17 @@ +import pkgutil +import six + + +def import_submodules(context, root_module, path): + """ + Import all submodules and register them in the ``context`` namespace. + >>> import_submodules(locals(), __name__, __path__) + """ + for loader, module_name, is_pkg in pkgutil.walk_packages(path, root_module + "."): + # this causes a Runtime error with model conflicts + # module = loader.find_module(module_name).load_module(module_name) + module = __import__(module_name, globals(), locals(), ["__name__"]) + for k, v in six.iteritems(vars(module)): + if not k.startswith("_"): + context[k] = v + context[module_name] = module diff --git a/apps/api/plane/utils/ip_address.py b/apps/api/plane/utils/ip_address.py new file mode 100644 index 00000000..01789c43 --- /dev/null +++ b/apps/api/plane/utils/ip_address.py @@ -0,0 +1,7 @@ +def get_client_ip(request): + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded_for: + ip = x_forwarded_for.split(",")[0] + else: + ip = request.META.get("REMOTE_ADDR") + return ip diff --git a/apps/api/plane/utils/issue_filters.py b/apps/api/plane/utils/issue_filters.py new file mode 100644 index 00000000..8d56bc38 --- /dev/null +++ b/apps/api/plane/utils/issue_filters.py @@ -0,0 +1,459 @@ +import re +import uuid +from datetime import timedelta + +from django.utils import timezone + +# The date from pattern +pattern = re.compile(r"\d+_(weeks|months)$") + + +# check the valid uuids +def filter_valid_uuids(uuid_list): + valid_uuids = [] + for uuid_str in uuid_list: + try: + uuid_obj = uuid.UUID(uuid_str) + valid_uuids.append(uuid_obj) + except ValueError: + # ignore the invalid uuids + pass + return valid_uuids + + +# Get the 2_weeks, 3_months +def string_date_filter(issue_filter, duration, subsequent, term, date_filter, offset): + now = timezone.now().date() + if term == "months": + if subsequent == "after": + if offset == "fromnow": + issue_filter[f"{date_filter}__gte"] = now + timedelta(days=duration * 30) + else: + issue_filter[f"{date_filter}__gte"] = now - timedelta(days=duration * 30) + else: + if offset == "fromnow": + issue_filter[f"{date_filter}__lte"] = now + timedelta(days=duration * 30) + else: + issue_filter[f"{date_filter}__lte"] = now - timedelta(days=duration * 30) + if term == "weeks": + if subsequent == "after": + if offset == "fromnow": + issue_filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration) + else: + issue_filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration) + else: + if offset == "fromnow": + issue_filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration) + else: + issue_filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration) + + +def date_filter(issue_filter, date_term, queries): + """ + Handle all date filters + """ + for query in queries: + date_query = query.split(";") + if date_query: + if len(date_query) >= 2: + match = pattern.match(date_query[0]) + if match: + if len(date_query) == 3: + digit, term = date_query[0].split("_") + string_date_filter( + issue_filter=issue_filter, + duration=int(digit), + subsequent=date_query[1], + term=term, + date_filter=date_term, + offset=date_query[2], + ) + else: + if "after" in date_query: + issue_filter[f"{date_term}__gte"] = date_query[0] + else: + issue_filter[f"{date_term}__lte"] = date_query[0] + else: + issue_filter[f"{date_term}__contains"] = date_query[0] + + +def filter_state(params, issue_filter, method, prefix=""): + if method == "GET": + 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 + else: + if params.get("state", None) and len(params.get("state")) and params.get("state") != "null": + issue_filter[f"{prefix}state__in"] = params.get("state") + return issue_filter + + +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"] + if len(state_group) and "" not in state_group: + issue_filter[f"{prefix}state__group__in"] = state_group + else: + if params.get("state_group", None) and len(params.get("state_group")) and params.get("state_group") != "null": + issue_filter[f"{prefix}state__group__in"] = params.get("state_group") + return issue_filter + + +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"] + if len(estimate_points) and "" not in estimate_points: + issue_filter[f"{prefix}estimate_point__in"] = estimate_points + else: + if ( + params.get("estimate_point", None) + and len(params.get("estimate_point")) + and params.get("estimate_point") != "null" + ): + 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"] + if len(priorities) and "" not in priorities: + issue_filter[f"{prefix}priority__in"] = priorities + else: + if params.get("priority", None) and len(params.get("priority")) and params.get("priority") != "null": + issue_filter[f"{prefix}priority__in"] = params.get("priority") + return issue_filter + + +def filter_parent(params, issue_filter, method, prefix=""): + if method == "GET": + 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) + if len(parents) and "" not in parents: + issue_filter[f"{prefix}parent__in"] = parents + else: + if params.get("parent", None) and len(params.get("parent")) and params.get("parent") != "null": + issue_filter[f"{prefix}parent__in"] = params.get("parent") + return issue_filter + + +def filter_labels(params, issue_filter, method, prefix=""): + if method == "GET": + 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) + if len(labels) and "" not in labels: + issue_filter[f"{prefix}labels__in"] = labels + else: + if params.get("labels", None) and len(params.get("labels")) 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"] + if "None" in assignees: + issue_filter[f"{prefix}assignees__isnull"] = True + assignees = filter_valid_uuids(assignees) + if len(assignees) and "" not in assignees: + issue_filter[f"{prefix}assignees__in"] = assignees + else: + if params.get("assignees", None) and len(params.get("assignees")) 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"] + mentions = filter_valid_uuids(mentions) + if len(mentions) and "" not in mentions: + issue_filter[f"{prefix}issue_mention__mention__id__in"] = mentions + else: + if params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != "null": + issue_filter[f"{prefix}issue_mention__mention__id__in"] = params.get("mentions") + return issue_filter + + +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"] + if "None" in created_bys: + issue_filter[f"{prefix}created_by__isnull"] = True + created_bys = filter_valid_uuids(created_bys) + if len(created_bys) and "" not in created_bys: + issue_filter[f"{prefix}created_by__in"] = created_bys + else: + if params.get("created_by", None) and len(params.get("created_by")) and params.get("created_by") != "null": + issue_filter[f"{prefix}created_by__in"] = params.get("created_by") + return issue_filter + + +def filter_name(params, issue_filter, method, prefix=""): + if params.get("name", "") != "": + issue_filter[f"{prefix}name__icontains"] = params.get("name") + return issue_filter + + +def filter_created_at(params, issue_filter, method, prefix=""): + if method == "GET": + created_ats = params.get("created_at").split(",") + if len(created_ats) and "" not in created_ats: + date_filter( + issue_filter=issue_filter, + date_term=f"{prefix}created_at__date", + queries=created_ats, + ) + else: + if params.get("created_at", None) and len(params.get("created_at")): + date_filter( + issue_filter=issue_filter, + date_term=f"{prefix}created_at__date", + queries=params.get("created_at", []), + ) + return issue_filter + + +def filter_updated_at(params, issue_filter, method, prefix=""): + if method == "GET": + updated_ats = params.get("updated_at").split(",") + if len(updated_ats) and "" not in updated_ats: + date_filter( + issue_filter=issue_filter, + date_term=f"{prefix}created_at__date", + queries=updated_ats, + ) + else: + if params.get("updated_at", None) and len(params.get("updated_at")): + date_filter( + issue_filter=issue_filter, + date_term=f"{prefix}created_at__date", + queries=params.get("updated_at", []), + ) + return issue_filter + + +def filter_start_date(params, issue_filter, method, prefix=""): + if method == "GET": + start_dates = params.get("start_date").split(",") + if len(start_dates) and "" not in start_dates: + date_filter( + issue_filter=issue_filter, + date_term=f"{prefix}start_date", + queries=start_dates, + ) + else: + if params.get("start_date", None) and len(params.get("start_date")): + issue_filter[f"{prefix}start_date"] = params.get("start_date") + return issue_filter + + +def filter_target_date(params, issue_filter, method, prefix=""): + if method == "GET": + target_dates = params.get("target_date").split(",") + if len(target_dates) and "" not in target_dates: + date_filter( + issue_filter=issue_filter, + date_term=f"{prefix}target_date", + queries=target_dates, + ) + else: + if params.get("target_date", None) and len(params.get("target_date")): + issue_filter[f"{prefix}target_date"] = params.get("target_date") + return issue_filter + + +def filter_completed_at(params, issue_filter, method, prefix=""): + if method == "GET": + completed_ats = params.get("completed_at").split(",") + if len(completed_ats) and "" not in completed_ats: + date_filter( + issue_filter=issue_filter, + date_term=f"{prefix}completed_at__date", + queries=completed_ats, + ) + else: + 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", + queries=params.get("completed_at", []), + ) + return issue_filter + + +def filter_issue_state_type(params, issue_filter, method, prefix=""): + type = params.get("type", "all") + group = ["backlog", "unstarted", "started", "completed", "cancelled"] + if type == "backlog": + group = ["backlog"] + if type == "active": + group = ["unstarted", "started"] + + issue_filter[f"{prefix}state__group__in"] = group + return issue_filter + + +def filter_project(params, issue_filter, method, prefix=""): + if method == "GET": + 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 + else: + if params.get("project", None) and len(params.get("project")) and params.get("project") != "null": + issue_filter[f"{prefix}project__in"] = params.get("project") + return issue_filter + + +def filter_cycle(params, issue_filter, method, prefix=""): + if method == "GET": + 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) + if len(cycles) and "" not in cycles: + issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles + else: + if params.get("cycle", None) and len(params.get("cycle")) 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"] + if "None" in modules: + issue_filter[f"{prefix}issue_module__module_id__isnull"] = True + modules = filter_valid_uuids(modules) + if len(modules) and "" not in modules: + issue_filter[f"{prefix}issue_module__module_id__in"] = modules + else: + if params.get("module", None) 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__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 + + +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"] + if len(status) and "" not 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_intake__status__in"] = params.get("inbox_status") + return issue_filter + + +def filter_sub_issue_toggle(params, issue_filter, method, prefix=""): + if method == "GET": + sub_issue = params.get("sub_issue", "false") + if sub_issue == "false": + issue_filter[f"{prefix}parent__isnull"] = True + else: + sub_issue = params.get("sub_issue", "false") + if sub_issue == "false": + issue_filter[f"{prefix}parent__isnull"] = True + return issue_filter + + +def filter_subscribed_issues(params, issue_filter, method, prefix=""): + if method == "GET": + subscribers = [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 + 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__deleted_at__isnull"] = True + + return issue_filter + + +def filter_start_target_date_issues(params, issue_filter, method, prefix=""): + start_target_date = params.get("start_target_date", "false") + if start_target_date == "true": + issue_filter[f"{prefix}target_date__isnull"] = False + issue_filter[f"{prefix}start_date__isnull"] = False + return issue_filter + + +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"] + if "None" in logged_bys: + issue_filter[f"{prefix}logged_by__isnull"] = True + logged_bys = filter_valid_uuids(logged_bys) + if len(logged_bys) and "" not in logged_bys: + issue_filter[f"{prefix}logged_by__in"] = logged_bys + else: + if params.get("logged_by", None) and len(params.get("logged_by")) and params.get("logged_by") != "null": + issue_filter[f"{prefix}logged_by__in"] = params.get("logged_by") + return issue_filter + + +def issue_filters(query_params, method, prefix=""): + issue_filter = {} + + ISSUE_FILTER = { + "state": filter_state, + "state_group": filter_state_group, + "estimate_point": filter_estimate_point, + "priority": filter_priority, + "parent": filter_parent, + "labels": filter_labels, + "assignees": filter_assignees, + "mentions": filter_mentions, + "created_by": filter_created_by, + "logged_by": filter_logged_by, + "name": filter_name, + "created_at": filter_created_at, + "updated_at": filter_updated_at, + "start_date": filter_start_date, + "target_date": filter_target_date, + "completed_at": filter_completed_at, + "type": filter_issue_state_type, + "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, + "start_target_date": filter_start_target_date_issues, + } + + for key, value in ISSUE_FILTER.items(): + if key in query_params: + func = value + func(query_params, issue_filter, method, prefix) + return issue_filter diff --git a/apps/api/plane/utils/issue_relation_mapper.py b/apps/api/plane/utils/issue_relation_mapper.py new file mode 100644 index 00000000..19d65c11 --- /dev/null +++ b/apps/api/plane/utils/issue_relation_mapper.py @@ -0,0 +1,28 @@ +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", + "implemented_by": "implements", + "implements": "implemented_by", + } + 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 stored 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", + "implemented_by": "implemented_by", + "implements": "implemented_by", + } + + return actual_relation.get(relation_type, relation_type) diff --git a/apps/api/plane/utils/issue_search.py b/apps/api/plane/utils/issue_search.py new file mode 100644 index 00000000..1e7543d8 --- /dev/null +++ b/apps/api/plane/utils/issue_search.py @@ -0,0 +1,20 @@ +# Python imports +import re + +# Django imports +from django.db.models import Q + +# Module imports + + +def search_issues(query, queryset): + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + for field in fields: + if field == "sequence_id" and len(query) <= 20: + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + return queryset.filter(q).distinct() diff --git a/apps/api/plane/utils/logging.py b/apps/api/plane/utils/logging.py new file mode 100644 index 00000000..083132f1 --- /dev/null +++ b/apps/api/plane/utils/logging.py @@ -0,0 +1,44 @@ +import logging.handlers as handlers +import time + + +class SizedTimedRotatingFileHandler(handlers.TimedRotatingFileHandler): + """ + Handler for logging to a set of files, which switches from one file + to the next when the current file reaches a certain size, or at certain + timed intervals + """ + + def __init__( + self, + filename, + maxBytes=0, + backupCount=0, + encoding=None, + delay=0, + when="h", + interval=1, + utc=False, + ): + handlers.TimedRotatingFileHandler.__init__(self, filename, when, interval, backupCount, encoding, delay, utc) + self.maxBytes = maxBytes + + def shouldRollover(self, record): + """ + Determine if rollover should occur. + + Basically, see if the supplied record would cause the file to exceed + the size limit we have. + """ + if self.stream is None: # delay was set... + self.stream = self._open() + if self.maxBytes > 0: # are we rolling over? + msg = "%s\n" % self.format(record) + # due to non-posix-compliant Windows feature + self.stream.seek(0, 2) + if self.stream.tell() + len(msg) >= self.maxBytes: + return 1 + t = int(time.time()) + if t >= self.rolloverAt: + return 1 + return 0 diff --git a/apps/api/plane/utils/markdown.py b/apps/api/plane/utils/markdown.py new file mode 100644 index 00000000..188c54fe --- /dev/null +++ b/apps/api/plane/utils/markdown.py @@ -0,0 +1,3 @@ +import mistune + +markdown = mistune.Markdown() diff --git a/apps/api/plane/utils/openapi/README.md b/apps/api/plane/utils/openapi/README.md new file mode 100644 index 00000000..9ac82cdd --- /dev/null +++ b/apps/api/plane/utils/openapi/README.md @@ -0,0 +1,102 @@ +# OpenAPI Utilities Module + +This module provides a well-organized structure for OpenAPI/drf-spectacular utilities, replacing the monolithic `openapi_spec_helpers.py` file with a more maintainable modular approach. + +## Structure + +``` +plane/utils/openapi/ +├── __init__.py # Main module that re-exports everything +├── auth.py # Authentication extensions +├── parameters.py # Common OpenAPI parameters +├── responses.py # Common OpenAPI responses +├── examples.py # Common OpenAPI examples +├── decorators.py # Helper decorators for different endpoint types +└── hooks.py # Schema processing hooks (pre/post processing) +``` + +## Usage + +### Import Everything (Recommended for backwards compatibility) +```python +from plane.utils.openapi import ( + asset_docs, + ASSET_ID_PARAMETER, + UNAUTHORIZED_RESPONSE, + # ... other imports +) +``` + +### Import from Specific Modules (Recommended for new code) +```python +from plane.utils.openapi.decorators import asset_docs +from plane.utils.openapi.parameters import ASSET_ID_PARAMETER +from plane.utils.openapi.responses import UNAUTHORIZED_RESPONSE +``` + +## Module Contents + +### auth.py +- `APIKeyAuthenticationExtension` - X-API-Key authentication +- `APITokenAuthenticationExtension` - Bearer token authentication + +### parameters.py +- Path parameters: `WORKSPACE_SLUG_PARAMETER`, `PROJECT_ID_PARAMETER`, `ISSUE_ID_PARAMETER`, `ASSET_ID_PARAMETER` +- Query parameters: `CURSOR_PARAMETER`, `PER_PAGE_PARAMETER` + +### responses.py +- Auth responses: `UNAUTHORIZED_RESPONSE`, `FORBIDDEN_RESPONSE` +- Resource responses: `NOT_FOUND_RESPONSE`, `VALIDATION_ERROR_RESPONSE` +- Asset responses: `PRESIGNED_URL_SUCCESS_RESPONSE`, `ASSET_UPDATED_RESPONSE`, etc. +- Generic asset responses: `GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE`, `ASSET_DOWNLOAD_SUCCESS_RESPONSE`, etc. + +### examples.py +- `FILE_UPLOAD_EXAMPLE`, `WORKSPACE_EXAMPLE`, `PROJECT_EXAMPLE`, `ISSUE_EXAMPLE` + +### decorators.py +- `workspace_docs()` - For workspace endpoints +- `project_docs()` - For project endpoints +- `issue_docs()` - For issue/work item endpoints +- `asset_docs()` - For asset endpoints + +### hooks.py +- `preprocess_filter_api_v1_paths()` - Filters API v1 paths +- `postprocess_assign_tags()` - Assigns tags based on URL patterns +- `generate_operation_summary()` - Generates operation summaries + +## Migration Status + +✅ **FULLY COMPLETE** - All components from the legacy `openapi_spec_helpers.py` have been successfully migrated to this modular structure and the old file has been completely removed. All imports have been updated to use the new modular structure. + +### What was migrated: +- ✅ All authentication extensions +- ✅ All common parameters and responses +- ✅ All helper decorators +- ✅ All schema processing hooks +- ✅ All examples and reusable components +- ✅ All asset view decorators converted to use new helpers +- ✅ All view imports updated to new module paths +- ✅ Legacy file completely removed + +### Files updated: +- `plane/api/views/asset.py` - All methods use new `@asset_docs` helpers +- `plane/api/views/project.py` - Import updated +- `plane/api/views/user.py` - Import updated +- `plane/api/views/state.py` - Import updated +- `plane/api/views/intake.py` - Import updated +- `plane/api/views/member.py` - Import updated +- `plane/api/views/module.py` - Import updated +- `plane/api/views/cycle.py` - Import updated +- `plane/api/views/issue.py` - Import updated +- `plane/settings/common.py` - Hook paths updated +- `plane/api/apps.py` - Auth extension import updated + +## Benefits + +1. **Better Organization**: Related functionality is grouped together +2. **Easier Maintenance**: Changes to specific areas only affect relevant files +3. **Improved Discoverability**: Clear module names make it easy to find what you need +4. **Backwards Compatibility**: All existing imports continue to work +5. **Reduced Coupling**: Import only what you need from specific modules +6. **Consistent Documentation**: All endpoints now use standardized helpers +7. **Massive Code Reduction**: ~80% reduction in decorator bloat using reusable components \ No newline at end of file diff --git a/apps/api/plane/utils/openapi/__init__.py b/apps/api/plane/utils/openapi/__init__.py new file mode 100644 index 00000000..bf682125 --- /dev/null +++ b/apps/api/plane/utils/openapi/__init__.py @@ -0,0 +1,315 @@ +""" +OpenAPI utilities for drf-spectacular integration. + +This module provides reusable components for API documentation: +- Authentication extensions +- Common parameters and responses +- Helper decorators +- Schema preprocessing hooks +- Examples +""" + +# Authentication extensions +from .auth import APIKeyAuthenticationExtension + +# Parameters +from .parameters import ( + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + PROJECT_PK_PARAMETER, + PROJECT_IDENTIFIER_PARAMETER, + ISSUE_IDENTIFIER_PARAMETER, + ASSET_ID_PARAMETER, + CYCLE_ID_PARAMETER, + MODULE_ID_PARAMETER, + MODULE_PK_PARAMETER, + ISSUE_ID_PARAMETER, + STATE_ID_PARAMETER, + LABEL_ID_PARAMETER, + COMMENT_ID_PARAMETER, + LINK_ID_PARAMETER, + ATTACHMENT_ID_PARAMETER, + ACTIVITY_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + EXTERNAL_ID_PARAMETER, + EXTERNAL_SOURCE_PARAMETER, + ORDER_BY_PARAMETER, + SEARCH_PARAMETER, + SEARCH_PARAMETER_REQUIRED, + LIMIT_PARAMETER, + WORKSPACE_SEARCH_PARAMETER, + PROJECT_ID_QUERY_PARAMETER, + CYCLE_VIEW_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, +) + +# Responses +from .responses import ( + UNAUTHORIZED_RESPONSE, + FORBIDDEN_RESPONSE, + NOT_FOUND_RESPONSE, + VALIDATION_ERROR_RESPONSE, + DELETED_RESPONSE, + ARCHIVED_RESPONSE, + UNARCHIVED_RESPONSE, + INVALID_REQUEST_RESPONSE, + CONFLICT_RESPONSE, + ADMIN_ONLY_RESPONSE, + CANNOT_DELETE_RESPONSE, + CANNOT_ARCHIVE_RESPONSE, + REQUIRED_FIELDS_RESPONSE, + PROJECT_NOT_FOUND_RESPONSE, + WORKSPACE_NOT_FOUND_RESPONSE, + PROJECT_NAME_TAKEN_RESPONSE, + ISSUE_NOT_FOUND_RESPONSE, + WORK_ITEM_NOT_FOUND_RESPONSE, + EXTERNAL_ID_EXISTS_RESPONSE, + LABEL_NOT_FOUND_RESPONSE, + LABEL_NAME_EXISTS_RESPONSE, + MODULE_NOT_FOUND_RESPONSE, + MODULE_ISSUE_NOT_FOUND_RESPONSE, + CYCLE_CANNOT_ARCHIVE_RESPONSE, + STATE_NAME_EXISTS_RESPONSE, + STATE_CANNOT_DELETE_RESPONSE, + COMMENT_NOT_FOUND_RESPONSE, + LINK_NOT_FOUND_RESPONSE, + ATTACHMENT_NOT_FOUND_RESPONSE, + BAD_SEARCH_REQUEST_RESPONSE, + PRESIGNED_URL_SUCCESS_RESPONSE, + GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE, + GENERIC_ASSET_VALIDATION_ERROR_RESPONSE, + ASSET_CONFLICT_RESPONSE, + ASSET_DOWNLOAD_SUCCESS_RESPONSE, + ASSET_DOWNLOAD_ERROR_RESPONSE, + ASSET_UPDATED_RESPONSE, + ASSET_DELETED_RESPONSE, + ASSET_NOT_FOUND_RESPONSE, + create_paginated_response, +) + +# Examples +from .examples import ( + FILE_UPLOAD_EXAMPLE, + WORKSPACE_EXAMPLE, + PROJECT_EXAMPLE, + ISSUE_EXAMPLE, + USER_EXAMPLE, + get_sample_for_schema, + # Request Examples + ISSUE_CREATE_EXAMPLE, + ISSUE_UPDATE_EXAMPLE, + ISSUE_UPSERT_EXAMPLE, + LABEL_CREATE_EXAMPLE, + LABEL_UPDATE_EXAMPLE, + ISSUE_LINK_CREATE_EXAMPLE, + ISSUE_LINK_UPDATE_EXAMPLE, + ISSUE_COMMENT_CREATE_EXAMPLE, + ISSUE_COMMENT_UPDATE_EXAMPLE, + ISSUE_ATTACHMENT_UPLOAD_EXAMPLE, + ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE, + CYCLE_CREATE_EXAMPLE, + CYCLE_UPDATE_EXAMPLE, + CYCLE_ISSUE_REQUEST_EXAMPLE, + TRANSFER_CYCLE_ISSUE_EXAMPLE, + MODULE_CREATE_EXAMPLE, + MODULE_UPDATE_EXAMPLE, + MODULE_ISSUE_REQUEST_EXAMPLE, + PROJECT_CREATE_EXAMPLE, + PROJECT_UPDATE_EXAMPLE, + STATE_CREATE_EXAMPLE, + STATE_UPDATE_EXAMPLE, + INTAKE_ISSUE_CREATE_EXAMPLE, + INTAKE_ISSUE_UPDATE_EXAMPLE, + # Response Examples + CYCLE_EXAMPLE, + TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE, + TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE, + TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE, + MODULE_EXAMPLE, + STATE_EXAMPLE, + LABEL_EXAMPLE, + ISSUE_LINK_EXAMPLE, + ISSUE_COMMENT_EXAMPLE, + ISSUE_ATTACHMENT_EXAMPLE, + ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE, + INTAKE_ISSUE_EXAMPLE, + MODULE_ISSUE_EXAMPLE, + ISSUE_SEARCH_EXAMPLE, + WORKSPACE_MEMBER_EXAMPLE, + PROJECT_MEMBER_EXAMPLE, + CYCLE_ISSUE_EXAMPLE, +) + +# Helper decorators +from .decorators import ( + workspace_docs, + project_docs, + issue_docs, + intake_docs, + asset_docs, + user_docs, + cycle_docs, + work_item_docs, + label_docs, + issue_link_docs, + issue_comment_docs, + issue_activity_docs, + issue_attachment_docs, + module_docs, + module_issue_docs, + state_docs, +) + +# Schema processing hooks +from .hooks import ( + preprocess_filter_api_v1_paths, + generate_operation_summary, +) + +__all__ = [ + # Authentication + "APIKeyAuthenticationExtension", + # Parameters + "WORKSPACE_SLUG_PARAMETER", + "PROJECT_ID_PARAMETER", + "PROJECT_PK_PARAMETER", + "PROJECT_IDENTIFIER_PARAMETER", + "ISSUE_IDENTIFIER_PARAMETER", + "ASSET_ID_PARAMETER", + "CYCLE_ID_PARAMETER", + "MODULE_ID_PARAMETER", + "MODULE_PK_PARAMETER", + "ISSUE_ID_PARAMETER", + "STATE_ID_PARAMETER", + "LABEL_ID_PARAMETER", + "COMMENT_ID_PARAMETER", + "LINK_ID_PARAMETER", + "ATTACHMENT_ID_PARAMETER", + "ACTIVITY_ID_PARAMETER", + "CURSOR_PARAMETER", + "PER_PAGE_PARAMETER", + "EXTERNAL_ID_PARAMETER", + "EXTERNAL_SOURCE_PARAMETER", + "ORDER_BY_PARAMETER", + "SEARCH_PARAMETER", + "SEARCH_PARAMETER_REQUIRED", + "LIMIT_PARAMETER", + "WORKSPACE_SEARCH_PARAMETER", + "PROJECT_ID_QUERY_PARAMETER", + "CYCLE_VIEW_PARAMETER", + "FIELDS_PARAMETER", + "EXPAND_PARAMETER", + # Responses + "UNAUTHORIZED_RESPONSE", + "FORBIDDEN_RESPONSE", + "NOT_FOUND_RESPONSE", + "VALIDATION_ERROR_RESPONSE", + "DELETED_RESPONSE", + "ARCHIVED_RESPONSE", + "UNARCHIVED_RESPONSE", + "INVALID_REQUEST_RESPONSE", + "CONFLICT_RESPONSE", + "ADMIN_ONLY_RESPONSE", + "CANNOT_DELETE_RESPONSE", + "CANNOT_ARCHIVE_RESPONSE", + "REQUIRED_FIELDS_RESPONSE", + "PROJECT_NOT_FOUND_RESPONSE", + "WORKSPACE_NOT_FOUND_RESPONSE", + "PROJECT_NAME_TAKEN_RESPONSE", + "ISSUE_NOT_FOUND_RESPONSE", + "WORK_ITEM_NOT_FOUND_RESPONSE", + "EXTERNAL_ID_EXISTS_RESPONSE", + "LABEL_NOT_FOUND_RESPONSE", + "LABEL_NAME_EXISTS_RESPONSE", + "MODULE_NOT_FOUND_RESPONSE", + "MODULE_ISSUE_NOT_FOUND_RESPONSE", + "CYCLE_CANNOT_ARCHIVE_RESPONSE", + "STATE_NAME_EXISTS_RESPONSE", + "STATE_CANNOT_DELETE_RESPONSE", + "COMMENT_NOT_FOUND_RESPONSE", + "LINK_NOT_FOUND_RESPONSE", + "ATTACHMENT_NOT_FOUND_RESPONSE", + "BAD_SEARCH_REQUEST_RESPONSE", + "create_paginated_response", + "PRESIGNED_URL_SUCCESS_RESPONSE", + "GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE", + "GENERIC_ASSET_VALIDATION_ERROR_RESPONSE", + "ASSET_CONFLICT_RESPONSE", + "ASSET_DOWNLOAD_SUCCESS_RESPONSE", + "ASSET_DOWNLOAD_ERROR_RESPONSE", + "ASSET_UPDATED_RESPONSE", + "ASSET_DELETED_RESPONSE", + "ASSET_NOT_FOUND_RESPONSE", + # Examples + "FILE_UPLOAD_EXAMPLE", + "WORKSPACE_EXAMPLE", + "PROJECT_EXAMPLE", + "ISSUE_EXAMPLE", + "USER_EXAMPLE", + "get_sample_for_schema", + # Request Examples + "ISSUE_CREATE_EXAMPLE", + "ISSUE_UPDATE_EXAMPLE", + "ISSUE_UPSERT_EXAMPLE", + "LABEL_CREATE_EXAMPLE", + "LABEL_UPDATE_EXAMPLE", + "ISSUE_LINK_CREATE_EXAMPLE", + "ISSUE_LINK_UPDATE_EXAMPLE", + "ISSUE_COMMENT_CREATE_EXAMPLE", + "ISSUE_COMMENT_UPDATE_EXAMPLE", + "ISSUE_ATTACHMENT_UPLOAD_EXAMPLE", + "ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE", + "CYCLE_CREATE_EXAMPLE", + "CYCLE_UPDATE_EXAMPLE", + "CYCLE_ISSUE_REQUEST_EXAMPLE", + "TRANSFER_CYCLE_ISSUE_EXAMPLE", + "MODULE_CREATE_EXAMPLE", + "MODULE_UPDATE_EXAMPLE", + "MODULE_ISSUE_REQUEST_EXAMPLE", + "PROJECT_CREATE_EXAMPLE", + "PROJECT_UPDATE_EXAMPLE", + "STATE_CREATE_EXAMPLE", + "STATE_UPDATE_EXAMPLE", + "INTAKE_ISSUE_CREATE_EXAMPLE", + "INTAKE_ISSUE_UPDATE_EXAMPLE", + # Response Examples + "CYCLE_EXAMPLE", + "TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE", + "TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE", + "TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE", + "MODULE_EXAMPLE", + "STATE_EXAMPLE", + "LABEL_EXAMPLE", + "ISSUE_LINK_EXAMPLE", + "ISSUE_COMMENT_EXAMPLE", + "ISSUE_ATTACHMENT_EXAMPLE", + "ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE", + "INTAKE_ISSUE_EXAMPLE", + "MODULE_ISSUE_EXAMPLE", + "ISSUE_SEARCH_EXAMPLE", + "WORKSPACE_MEMBER_EXAMPLE", + "PROJECT_MEMBER_EXAMPLE", + "CYCLE_ISSUE_EXAMPLE", + # Decorators + "workspace_docs", + "project_docs", + "issue_docs", + "intake_docs", + "asset_docs", + "user_docs", + "cycle_docs", + "work_item_docs", + "label_docs", + "issue_link_docs", + "issue_comment_docs", + "issue_activity_docs", + "issue_attachment_docs", + "module_docs", + "module_issue_docs", + "state_docs", + # Hooks + "preprocess_filter_api_v1_paths", + "generate_operation_summary", +] diff --git a/apps/api/plane/utils/openapi/auth.py b/apps/api/plane/utils/openapi/auth.py new file mode 100644 index 00000000..9434956f --- /dev/null +++ b/apps/api/plane/utils/openapi/auth.py @@ -0,0 +1,30 @@ +""" +OpenAPI authentication extensions for drf-spectacular. + +This module provides authentication extensions that automatically register +custom authentication classes with the OpenAPI schema generator. +""" + +from drf_spectacular.extensions import OpenApiAuthenticationExtension + + +class APIKeyAuthenticationExtension(OpenApiAuthenticationExtension): + """ + OpenAPI authentication extension for + plane.api.middleware.api_authentication.APIKeyAuthentication + """ + + target_class = "plane.api.middleware.api_authentication.APIKeyAuthentication" + name = "ApiKeyAuthentication" + priority = 1 + + def get_security_definition(self, auto_schema): + """ + Return the security definition for API key authentication. + """ + return { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API key authentication. Provide your API key in the X-API-Key header.", # noqa: E501 + } diff --git a/apps/api/plane/utils/openapi/decorators.py b/apps/api/plane/utils/openapi/decorators.py new file mode 100644 index 00000000..e4a86839 --- /dev/null +++ b/apps/api/plane/utils/openapi/decorators.py @@ -0,0 +1,264 @@ +""" +Helper decorators for drf-spectacular OpenAPI documentation. + +This module provides domain-specific decorators that apply common +parameters, responses, and tags to API endpoints based on their context. +""" + +from drf_spectacular.utils import extend_schema +from .parameters import WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER +from .responses import UNAUTHORIZED_RESPONSE, FORBIDDEN_RESPONSE, NOT_FOUND_RESPONSE + + +def _merge_schema_options(defaults, kwargs): + """Helper function to merge responses and parameters from kwargs into defaults""" + # Merge responses + if "responses" in kwargs: + defaults["responses"].update(kwargs["responses"]) + kwargs = {k: v for k, v in kwargs.items() if k != "responses"} + + # Merge parameters + if "parameters" in kwargs: + defaults["parameters"].extend(kwargs["parameters"]) + kwargs = {k: v for k, v in kwargs.items() if k != "parameters"} + + defaults.update(kwargs) + return defaults + + +def user_docs(**kwargs): + """Decorator for user-related endpoints""" + defaults = { + "tags": ["Users"], + "parameters": [], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def workspace_docs(**kwargs): + """Decorator for workspace-related endpoints""" + defaults = { + "tags": ["Workspaces"], + "parameters": [WORKSPACE_SLUG_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def project_docs(**kwargs): + """Decorator for project-related endpoints""" + defaults = { + "tags": ["Projects"], + "parameters": [WORKSPACE_SLUG_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def cycle_docs(**kwargs): + """Decorator for cycle-related endpoints""" + defaults = { + "tags": ["Cycles"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def issue_docs(**kwargs): + """Decorator for issue-related endpoints""" + defaults = { + "tags": ["Work Items"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def intake_docs(**kwargs): + """Decorator for intake-related endpoints""" + defaults = { + "tags": ["Intake"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def asset_docs(**kwargs): + """Decorator for asset-related endpoints with common defaults""" + defaults = { + "tags": ["Assets"], + "parameters": [], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +# Issue-related decorators for specific tags +def work_item_docs(**kwargs): + """Decorator for work item endpoints (main issue operations)""" + defaults = { + "tags": ["Work Items"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def label_docs(**kwargs): + """Decorator for label management endpoints""" + defaults = { + "tags": ["Labels"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def issue_link_docs(**kwargs): + """Decorator for issue link endpoints""" + defaults = { + "tags": ["Work Item Links"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def issue_comment_docs(**kwargs): + """Decorator for issue comment endpoints""" + defaults = { + "tags": ["Work Item Comments"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def issue_activity_docs(**kwargs): + """Decorator for issue activity/search endpoints""" + defaults = { + "tags": ["Work Item Activity"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def issue_attachment_docs(**kwargs): + """Decorator for issue attachment endpoints""" + defaults = { + "tags": ["Work Item Attachments"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def module_docs(**kwargs): + """Decorator for module management endpoints""" + defaults = { + "tags": ["Modules"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def module_issue_docs(**kwargs): + """Decorator for module issue management endpoints""" + defaults = { + "tags": ["Modules"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def state_docs(**kwargs): + """Decorator for state management endpoints""" + defaults = { + "tags": ["States"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) diff --git a/apps/api/plane/utils/openapi/examples.py b/apps/api/plane/utils/openapi/examples.py new file mode 100644 index 00000000..db7ee50c --- /dev/null +++ b/apps/api/plane/utils/openapi/examples.py @@ -0,0 +1,816 @@ +""" +Common OpenAPI examples for drf-spectacular. + +This module provides reusable example data for API responses and requests +to make the generated documentation more helpful and realistic. +""" + +from drf_spectacular.utils import OpenApiExample + + +# File Upload Examples +FILE_UPLOAD_EXAMPLE = OpenApiExample( + name="File Upload Success", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "asset": "uploads/workspace_1/file_example.pdf", + "attributes": { + "name": "example-document.pdf", + "size": 1024000, + "mimetype": "application/pdf", + }, + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + }, +) + + +# Workspace Examples +WORKSPACE_EXAMPLE = OpenApiExample( + name="Workspace", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "My Workspace", + "slug": "my-workspace", + "organization_size": "1-10", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + }, +) + + +# Project Examples +PROJECT_EXAMPLE = OpenApiExample( + name="Project", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Mobile App Development", + "description": "Development of the mobile application", + "identifier": "MAD", + "network": 2, + "project_lead": "550e8400-e29b-41d4-a716-446655440001", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + }, +) + + +# Issue Examples +ISSUE_EXAMPLE = OpenApiExample( + name="Issue", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Implement user authentication", + "description": "Add OAuth 2.0 authentication flow", + "sequence_id": 1, + "priority": "high", + "assignees": ["550e8400-e29b-41d4-a716-446655440001"], + "labels": ["550e8400-e29b-41d4-a716-446655440002"], + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + }, +) + + +# User Examples +USER_EXAMPLE = OpenApiExample( + name="User", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + "avatar": "https://example.com/avatar.jpg", + "avatar_url": "https://example.com/avatar.jpg", + "display_name": "John Doe", + }, +) + + +# ============================================================================ +# REQUEST EXAMPLES - Centralized examples for API requests +# ============================================================================ + +# Work Item / Issue Examples +ISSUE_CREATE_EXAMPLE = OpenApiExample( + "IssueCreateSerializer", + value={ + "name": "New Issue", + "description": "New issue description", + "priority": "medium", + "state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + "assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"], + "labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"], + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating a work item", +) + +ISSUE_UPDATE_EXAMPLE = OpenApiExample( + "IssueUpdateSerializer", + value={ + "name": "Updated Issue", + "description": "Updated issue description", + "priority": "medium", + "state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + "assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"], + "labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"], + }, + description="Example request for updating a work item", +) + +ISSUE_UPSERT_EXAMPLE = OpenApiExample( + "IssueUpsertSerializer", + value={ + "name": "Updated Issue via External ID", + "description": "Updated issue description", + "priority": "high", + "state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + "assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"], + "labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"], + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for upserting a work item via external ID", +) + +# Label Examples +LABEL_CREATE_EXAMPLE = OpenApiExample( + "LabelCreateUpdateSerializer", + value={ + "name": "New Label", + "color": "#ff0000", + "description": "New label description", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating a label", +) + +LABEL_UPDATE_EXAMPLE = OpenApiExample( + "LabelCreateUpdateSerializer", + value={ + "name": "Updated Label", + "color": "#00ff00", + "description": "Updated label description", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for updating a label", +) + +# Issue Link Examples +ISSUE_LINK_CREATE_EXAMPLE = OpenApiExample( + "IssueLinkCreateSerializer", + value={ + "url": "https://example.com", + "title": "Example Link", + }, + description="Example request for creating an issue link", +) + +ISSUE_LINK_UPDATE_EXAMPLE = OpenApiExample( + "IssueLinkUpdateSerializer", + value={ + "url": "https://example.com", + "title": "Updated Link", + }, + description="Example request for updating an issue link", +) + +# Issue Comment Examples +ISSUE_COMMENT_CREATE_EXAMPLE = OpenApiExample( + "IssueCommentCreateSerializer", + value={ + "comment_html": "

    New comment content

    ", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating an issue comment", +) + +ISSUE_COMMENT_UPDATE_EXAMPLE = OpenApiExample( + "IssueCommentCreateSerializer", + value={ + "comment_html": "

    Updated comment content

    ", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for updating an issue comment", +) + +# Issue Attachment Examples +ISSUE_ATTACHMENT_UPLOAD_EXAMPLE = OpenApiExample( + "IssueAttachmentUploadSerializer", + value={ + "name": "document.pdf", + "type": "application/pdf", + "size": 1024000, + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating an issue attachment", +) + +ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE = OpenApiExample( + "ConfirmUpload", + value={"is_uploaded": True}, + description="Confirm that the attachment has been successfully uploaded", +) + +# Cycle Examples +CYCLE_CREATE_EXAMPLE = OpenApiExample( + "CycleCreateSerializer", + value={ + "name": "Cycle 1", + "description": "Cycle 1 description", + "start_date": "2021-01-01", + "end_date": "2021-01-31", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating a cycle", +) + +CYCLE_UPDATE_EXAMPLE = OpenApiExample( + "CycleUpdateSerializer", + value={ + "name": "Updated Cycle", + "description": "Updated cycle description", + "start_date": "2021-01-01", + "end_date": "2021-01-31", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for updating a cycle", +) + +CYCLE_ISSUE_REQUEST_EXAMPLE = OpenApiExample( + "CycleIssueRequestSerializer", + value={ + "issues": [ + "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + "0ec6cfa4-e906-4aad-9390-2df0303a41ce", + ], + }, + description="Example request for adding cycle issues", +) + +TRANSFER_CYCLE_ISSUE_EXAMPLE = OpenApiExample( + "TransferCycleIssueRequestSerializer", + value={ + "new_cycle_id": "0ec6cfa4-e906-4aad-9390-2df0303a41ce", + }, + description="Example request for transferring cycle issues", +) + +# Module Examples +MODULE_CREATE_EXAMPLE = OpenApiExample( + "ModuleCreateSerializer", + value={ + "name": "New Module", + "description": "New module description", + "start_date": "2021-01-01", + "end_date": "2021-01-31", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating a module", +) + +MODULE_UPDATE_EXAMPLE = OpenApiExample( + "ModuleUpdateSerializer", + value={ + "name": "Updated Module", + "description": "Updated module description", + "start_date": "2021-01-01", + "end_date": "2021-01-31", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for updating a module", +) + +MODULE_ISSUE_REQUEST_EXAMPLE = OpenApiExample( + "ModuleIssueRequestSerializer", + value={ + "issues": [ + "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + "0ec6cfa4-e906-4aad-9390-2df0303a41ce", + ], + }, + description="Example request for adding module issues", +) + +# Project Examples +PROJECT_CREATE_EXAMPLE = OpenApiExample( + "ProjectCreateSerializer", + value={ + "name": "New Project", + "description": "New project description", + "identifier": "new-project", + "project_lead": "0ec6cfa4-e906-4aad-9390-2df0303a41ce", + }, + description="Example request for creating a project", +) + +PROJECT_UPDATE_EXAMPLE = OpenApiExample( + "ProjectUpdateSerializer", + value={ + "name": "Updated Project", + "description": "Updated project description", + "identifier": "updated-project", + "project_lead": "0ec6cfa4-e906-4aad-9390-2df0303a41ce", + }, + description="Example request for updating a project", +) + +# State Examples +STATE_CREATE_EXAMPLE = OpenApiExample( + "StateCreateSerializer", + value={ + "name": "New State", + "color": "#ff0000", + "group": "backlog", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating a state", +) + +STATE_UPDATE_EXAMPLE = OpenApiExample( + "StateUpdateSerializer", + value={ + "name": "Updated State", + "color": "#00ff00", + "group": "backlog", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for updating a state", +) + +# Intake Examples +INTAKE_ISSUE_CREATE_EXAMPLE = OpenApiExample( + "IntakeIssueCreateSerializer", + value={ + "issue": { + "name": "New Issue", + "description": "New issue description", + "priority": "medium", + } + }, + description="Example request for creating an intake issue", +) + +INTAKE_ISSUE_UPDATE_EXAMPLE = OpenApiExample( + "IntakeIssueUpdateSerializer", + value={ + "status": 1, + "issue": { + "name": "Updated Issue", + "description": "Updated issue description", + "priority": "high", + }, + }, + description="Example request for updating an intake issue", +) + + +# ============================================================================ +# RESPONSE EXAMPLES - Centralized examples for API responses +# ============================================================================ + +# Cycle Response Examples +CYCLE_EXAMPLE = OpenApiExample( + name="Cycle", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Sprint 1 - Q1 2024", + "description": "First sprint of the quarter focusing on core features", + "start_date": "2024-01-01", + "end_date": "2024-01-14", + "status": "current", + "total_issues": 15, + "completed_issues": 8, + "cancelled_issues": 1, + "started_issues": 4, + "unstarted_issues": 2, + "backlog_issues": 0, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Transfer Cycle Issue Response Examples +TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE = OpenApiExample( + name="Transfer Cycle Issue Success", + value={ + "message": "Success", + }, + description="Successful transfer of cycle issues to new cycle", +) + +TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE = OpenApiExample( + name="Transfer Cycle Issue Error", + value={ + "error": "New Cycle Id is required", + }, + description="Error when required cycle ID is missing", +) + +TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE = OpenApiExample( + name="Transfer to Completed Cycle Error", + value={ + "error": "The cycle where the issues are transferred is already completed", + }, + description="Error when trying to transfer to a completed cycle", +) + +# Module Response Examples +MODULE_EXAMPLE = OpenApiExample( + name="Module", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Authentication Module", + "description": "User authentication and authorization features", + "start_date": "2024-01-01", + "target_date": "2024-02-15", + "status": "in-progress", + "total_issues": 12, + "completed_issues": 5, + "cancelled_issues": 0, + "started_issues": 4, + "unstarted_issues": 3, + "backlog_issues": 0, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# State Response Examples +STATE_EXAMPLE = OpenApiExample( + name="State", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "In Progress", + "color": "#f39c12", + "group": "started", + "sequence": 2, + "default": False, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Label Response Examples +LABEL_EXAMPLE = OpenApiExample( + name="Label", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "bug", + "color": "#ff4444", + "description": "Issues that represent bugs in the system", + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Issue Link Response Examples +ISSUE_LINK_EXAMPLE = OpenApiExample( + name="IssueLink", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "url": "https://github.com/example/repo/pull/123", + "title": "Fix authentication bug", + "metadata": { + "title": "Fix authentication bug", + "description": "Pull request to fix authentication timeout issue", + "image": "https://github.com/example/repo/avatar.png", + }, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Issue Comment Response Examples +ISSUE_COMMENT_EXAMPLE = OpenApiExample( + name="IssueComment", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "comment_html": "

    This issue has been resolved by implementing OAuth 2.0 flow.

    ", # noqa: E501 + "comment_json": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "This issue has been resolved by implementing OAuth 2.0 flow.", # noqa: E501 + } + ], + } + ], + }, + "actor": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "first_name": "John", + "last_name": "Doe", + "display_name": "John Doe", + "avatar": "https://example.com/avatar.jpg", + }, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Issue Attachment Response Examples +ISSUE_ATTACHMENT_EXAMPLE = OpenApiExample( + name="IssueAttachment", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "screenshot.png", + "size": 1024000, + "asset_url": "https://s3.amazonaws.com/bucket/screenshot.png?signed-url", + "attributes": { + "name": "screenshot.png", + "type": "image/png", + "size": 1024000, + }, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Issue Attachment Error Response Examples +ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE = OpenApiExample( + name="Issue Attachment Not Uploaded", + value={ + "error": "The asset is not uploaded.", + "status": False, + }, + description="Error when trying to download an attachment that hasn't been uploaded yet", # noqa: E501 +) + +# Intake Issue Response Examples +INTAKE_ISSUE_EXAMPLE = OpenApiExample( + name="IntakeIssue", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": 0, # Pending + "source": "in_app", + "issue": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "name": "Feature request: Dark mode", + "description": "Add dark mode support to the application", + "priority": "medium", + "sequence_id": 124, + }, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Module Issue Response Examples +MODULE_ISSUE_EXAMPLE = OpenApiExample( + name="ModuleIssue", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "module": "550e8400-e29b-41d4-a716-446655440001", + "issue": "550e8400-e29b-41d4-a716-446655440002", + "sub_issues_count": 2, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Issue Search Response Examples +ISSUE_SEARCH_EXAMPLE = OpenApiExample( + name="IssueSearchResults", + value={ + "issues": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Fix authentication bug in user login", + "sequence_id": 123, + "project__identifier": "MAB", + "project_id": "550e8400-e29b-41d4-a716-446655440001", + "workspace__slug": "my-workspace", + }, + { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Add authentication middleware", + "sequence_id": 124, + "project__identifier": "MAB", + "project_id": "550e8400-e29b-41d4-a716-446655440001", + "workspace__slug": "my-workspace", + }, + ] + }, +) + +# Workspace Member Response Examples +WORKSPACE_MEMBER_EXAMPLE = OpenApiExample( + name="WorkspaceMembers", + value=[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "first_name": "John", + "last_name": "Doe", + "display_name": "John Doe", + "email": "john.doe@example.com", + "avatar": "https://example.com/avatar.jpg", + "role": 20, + }, + { + "id": "550e8400-e29b-41d4-a716-446655440001", + "first_name": "Jane", + "last_name": "Smith", + "display_name": "Jane Smith", + "email": "jane.smith@example.com", + "avatar": "https://example.com/avatar2.jpg", + "role": 15, + }, + ], +) + +# Project Member Response Examples +PROJECT_MEMBER_EXAMPLE = OpenApiExample( + name="ProjectMembers", + value=[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "first_name": "John", + "last_name": "Doe", + "display_name": "John Doe", + "email": "john.doe@example.com", + "avatar": "https://example.com/avatar.jpg", + }, + { + "id": "550e8400-e29b-41d4-a716-446655440001", + "first_name": "Jane", + "last_name": "Smith", + "display_name": "Jane Smith", + "email": "jane.smith@example.com", + "avatar": "https://example.com/avatar2.jpg", + }, + ], +) + +# Cycle Issue Response Examples +CYCLE_ISSUE_EXAMPLE = OpenApiExample( + name="CycleIssue", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "cycle": "550e8400-e29b-41d4-a716-446655440001", + "issue": "550e8400-e29b-41d4-a716-446655440002", + "sub_issues_count": 3, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + + +# Sample data for different entity types +SAMPLE_ISSUE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Fix authentication bug in user login", + "description": "Users are unable to log in due to authentication service timeout", + "priority": "high", + "sequence_id": 123, + "state": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "name": "In Progress", + "group": "started", + }, + "assignees": [], + "labels": [], + "created_at": "2024-01-15T10:30:00Z", +} + +SAMPLE_LABEL = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "bug", + "color": "#ff4444", + "description": "Issues that represent bugs in the system", +} + +SAMPLE_CYCLE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Sprint 1 - Q1 2024", + "description": "First sprint of the quarter focusing on core features", + "start_date": "2024-01-01", + "end_date": "2024-01-14", + "status": "current", +} + +SAMPLE_MODULE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Authentication Module", + "description": "User authentication and authorization features", + "start_date": "2024-01-01", + "target_date": "2024-02-15", + "status": "in_progress", +} + +SAMPLE_PROJECT = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Mobile App Backend", + "description": "Backend services for the mobile application", + "identifier": "MAB", + "network": 2, +} + +SAMPLE_STATE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "In Progress", + "color": "#ffa500", + "group": "started", + "sequence": 2, +} + +SAMPLE_COMMENT = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "comment_html": "

    This issue needs more investigation. I'll look into the database connection timeout.

    ", # noqa: E501 + "created_at": "2024-01-15T14:20:00Z", + "actor": {"id": "550e8400-e29b-41d4-a716-446655440002", "display_name": "John Doe"}, +} + +SAMPLE_LINK = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "url": "https://github.com/example/repo/pull/123", + "title": "Fix authentication timeout issue", + "metadata": {}, +} + +SAMPLE_ACTIVITY = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "field": "priority", + "old_value": "medium", + "new_value": "high", + "created_at": "2024-01-15T11:45:00Z", + "actor": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "display_name": "Jane Smith", + }, +} + +SAMPLE_INTAKE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": 0, + "issue": { + "id": "550e8400-e29b-41d4-a716-446655440003", + "name": "Feature request: Dark mode support", + }, + "created_at": "2024-01-15T09:15:00Z", +} + +SAMPLE_GENERIC = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Sample Item", + "created_at": "2024-01-15T12:00:00Z", +} + +SAMPLE_CYCLE_ISSUE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "cycle": "550e8400-e29b-41d4-a716-446655440001", + "issue": "550e8400-e29b-41d4-a716-446655440002", + "sub_issues_count": 3, + "created_at": "2024-01-01T10:30:00Z", +} + +# Mapping of schema types to sample data +SCHEMA_EXAMPLES = { + "Issue": SAMPLE_ISSUE, + "WorkItem": SAMPLE_ISSUE, + "Label": SAMPLE_LABEL, + "Cycle": SAMPLE_CYCLE, + "Module": SAMPLE_MODULE, + "Project": SAMPLE_PROJECT, + "State": SAMPLE_STATE, + "Comment": SAMPLE_COMMENT, + "Link": SAMPLE_LINK, + "Activity": SAMPLE_ACTIVITY, + "Intake": SAMPLE_INTAKE, + "CycleIssue": SAMPLE_CYCLE_ISSUE, +} + + +def get_sample_for_schema(schema_name): + """ + Get appropriate sample data for a schema type. + + Args: + schema_name (str): Name of the schema (e.g., "PaginatedIssueResponse") + + Returns: + dict: Sample data for the schema type + """ + # Extract base schema name from paginated responses + if schema_name.startswith("Paginated"): + base_name = schema_name.replace("Paginated", "").replace("Response", "") + return SCHEMA_EXAMPLES.get(base_name, SAMPLE_GENERIC) + + return SCHEMA_EXAMPLES.get(schema_name, SAMPLE_GENERIC) diff --git a/apps/api/plane/utils/openapi/hooks.py b/apps/api/plane/utils/openapi/hooks.py new file mode 100644 index 00000000..f136324c --- /dev/null +++ b/apps/api/plane/utils/openapi/hooks.py @@ -0,0 +1,52 @@ +""" +Schema processing hooks for drf-spectacular OpenAPI generation. + +This module provides preprocessing and postprocessing functions that modify +the generated OpenAPI schema to apply custom filtering, tagging, and other +transformations. +""" + + +def preprocess_filter_api_v1_paths(endpoints): + """ + Filter OpenAPI endpoints to only include /api/v1/ paths and exclude PUT methods. + """ + filtered = [] + for path, path_regex, method, callback in endpoints: + # Only include paths that start with /api/v1/ and exclude PUT methods + if path.startswith("/api/v1/") and method.upper() != "PUT" and "server" not in path.lower(): + filtered.append((path, path_regex, method, callback)) + return filtered + + +def generate_operation_summary(method, path, tag): + """ + Generate a human-readable summary for an operation. + """ + # Extract the main resource from the path + path_parts = [part for part in path.split("/") if part and not part.startswith("{")] + + if len(path_parts) > 0: + resource = path_parts[-1].replace("-", " ").title() + else: + resource = tag + + # Generate summary based on method + method_summaries = { + "GET": f"Retrieve {resource}", + "POST": f"Create {resource}", + "PATCH": f"Update {resource}", + "DELETE": f"Delete {resource}", + } + + # Handle specific cases + if "archive" in path.lower(): + if method == "POST": + return f"Archive {tag.rstrip('s')}" + elif method == "DELETE": + return f"Unarchive {tag.rstrip('s')}" + + if "transfer" in path.lower(): + return f"Transfer {tag.rstrip('s')}" + + return method_summaries.get(method, f"{method} {resource}") diff --git a/apps/api/plane/utils/openapi/parameters.py b/apps/api/plane/utils/openapi/parameters.py new file mode 100644 index 00000000..47db747a --- /dev/null +++ b/apps/api/plane/utils/openapi/parameters.py @@ -0,0 +1,493 @@ +""" +Common OpenAPI parameters for drf-spectacular. + +This module provides reusable parameter definitions that can be shared +across multiple API endpoints to ensure consistency. +""" + +from drf_spectacular.utils import OpenApiParameter, OpenApiExample +from drf_spectacular.types import OpenApiTypes + + +# Path Parameters +WORKSPACE_SLUG_PARAMETER = OpenApiParameter( + name="slug", + description="Workspace slug", + required=True, + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example workspace", + value="my-workspace", + description="A typical workspace slug", + ) + ], +) + +PROJECT_ID_PARAMETER = OpenApiParameter( + name="project_id", + description="Project ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example project ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical project UUID", + ) + ], +) + +PROJECT_PK_PARAMETER = OpenApiParameter( + name="pk", + description="Project ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example project ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical project UUID", + ) + ], +) + +PROJECT_IDENTIFIER_PARAMETER = OpenApiParameter( + name="project_identifier", + description="Project identifier (unique string within workspace)", + required=True, + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example project identifier", + value="PROJ", + description="A typical project identifier", + ) + ], +) + +ISSUE_IDENTIFIER_PARAMETER = OpenApiParameter( + name="issue_identifier", + description="Issue sequence ID (numeric identifier within project)", + required=True, + type=OpenApiTypes.INT, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example issue identifier", + value=123, + description="A typical issue sequence ID", + ) + ], +) + +ASSET_ID_PARAMETER = OpenApiParameter( + name="asset_id", + description="Asset ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example asset ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical asset UUID", + ) + ], +) + +CYCLE_ID_PARAMETER = OpenApiParameter( + name="cycle_id", + description="Cycle ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example cycle ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical cycle UUID", + ) + ], +) + +MODULE_ID_PARAMETER = OpenApiParameter( + name="module_id", + description="Module ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example module ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical module UUID", + ) + ], +) + +MODULE_PK_PARAMETER = OpenApiParameter( + name="pk", + description="Module ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example module ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical module UUID", + ) + ], +) + +ISSUE_ID_PARAMETER = OpenApiParameter( + name="issue_id", + description="Issue ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example issue ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical issue UUID", + ) + ], +) + +STATE_ID_PARAMETER = OpenApiParameter( + name="state_id", + description="State ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example state ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical state UUID", + ) + ], +) + +# Additional Path Parameters +LABEL_ID_PARAMETER = OpenApiParameter( + name="pk", + description="Label ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example label ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical label UUID", + ) + ], +) + +COMMENT_ID_PARAMETER = OpenApiParameter( + name="pk", + description="Comment ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example comment ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical comment UUID", + ) + ], +) + +LINK_ID_PARAMETER = OpenApiParameter( + name="pk", + description="Link ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example link ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical link UUID", + ) + ], +) + +ATTACHMENT_ID_PARAMETER = OpenApiParameter( + name="pk", + description="Attachment ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example attachment ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical attachment UUID", + ) + ], +) + +ACTIVITY_ID_PARAMETER = OpenApiParameter( + name="pk", + description="Activity ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example activity ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical activity UUID", + ) + ], +) + +# Query Parameters +CURSOR_PARAMETER = OpenApiParameter( + name="cursor", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Pagination cursor for getting next set of results", + required=False, + examples=[ + OpenApiExample( + name="Next page cursor", + value="20:1:0", + description="Cursor format: 'page_size:page_number:offset'", + ) + ], +) + +PER_PAGE_PARAMETER = OpenApiParameter( + name="per_page", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Number of results per page (default: 20, max: 100)", + required=False, + examples=[ + OpenApiExample(name="Default", value=20), + OpenApiExample(name="Maximum", value=100), + ], +) + +# External Integration Parameters +EXTERNAL_ID_PARAMETER = OpenApiParameter( + name="external_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="External system identifier for filtering or lookup", + required=False, + examples=[ + OpenApiExample( + name="GitHub Issue", + value="1234567890", + description="GitHub issue number", + ) + ], +) + +EXTERNAL_SOURCE_PARAMETER = OpenApiParameter( + name="external_source", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="External system source name for filtering or lookup", + required=False, + examples=[ + OpenApiExample( + name="GitHub", + value="github", + description="GitHub integration source", + ), + OpenApiExample( + name="Jira", + value="jira", + description="Jira integration source", + ), + ], +) + +# Ordering Parameters +ORDER_BY_PARAMETER = OpenApiParameter( + name="order_by", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Field to order results by. Prefix with '-' for descending order", + required=False, + examples=[ + OpenApiExample( + name="Created date descending", + value="-created_at", + description="Most recent items first", + ), + OpenApiExample( + name="Priority ascending", + value="priority", + description="Order by priority (urgent, high, medium, low, none)", + ), + OpenApiExample( + name="State group", + value="state__group", + description="Order by state group (backlog, unstarted, started, completed, cancelled)", # noqa: E501 + ), + OpenApiExample( + name="Assignee name", + value="assignees__first_name", + description="Order by assignee first name", + ), + ], +) + +# Search Parameters +SEARCH_PARAMETER = OpenApiParameter( + name="search", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Search query to filter results by name, description, or identifier", + required=False, + examples=[ + OpenApiExample( + name="Name search", + value="bug fix", + description="Search for items containing 'bug fix'", + ), + OpenApiExample( + name="Sequence ID", + value="123", + description="Search by sequence ID number", + ), + ], +) + +SEARCH_PARAMETER_REQUIRED = OpenApiParameter( + name="search", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Search query to filter results by name, description, or identifier", + required=True, + examples=[ + OpenApiExample( + name="Name search", + value="bug fix", + description="Search for items containing 'bug fix'", + ), + OpenApiExample( + name="Sequence ID", + value="123", + description="Search by sequence ID number", + ), + ], +) + +LIMIT_PARAMETER = OpenApiParameter( + name="limit", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Maximum number of results to return", + required=False, + examples=[ + OpenApiExample(name="Default", value=10), + OpenApiExample(name="More results", value=50), + ], +) + +WORKSPACE_SEARCH_PARAMETER = OpenApiParameter( + name="workspace_search", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Whether to search across entire workspace or within specific project", + required=False, + examples=[ + OpenApiExample( + name="Project only", + value="false", + description="Search within specific project only", + ), + OpenApiExample( + name="Workspace wide", + value="true", + description="Search across entire workspace", + ), + ], +) + +PROJECT_ID_QUERY_PARAMETER = OpenApiParameter( + name="project_id", + description="Project ID for filtering results within a specific project", + required=False, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + examples=[ + OpenApiExample( + name="Example project ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="Filter results for this project", + ) + ], +) + +# Cycle View Parameter +CYCLE_VIEW_PARAMETER = OpenApiParameter( + name="cycle_view", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Filter cycles by status", + required=False, + examples=[ + OpenApiExample(name="All cycles", value="all"), + OpenApiExample(name="Current cycles", value="current"), + OpenApiExample(name="Upcoming cycles", value="upcoming"), + OpenApiExample(name="Completed cycles", value="completed"), + OpenApiExample(name="Draft cycles", value="draft"), + OpenApiExample(name="Incomplete cycles", value="incomplete"), + ], +) + +# Field Selection Parameters +FIELDS_PARAMETER = OpenApiParameter( + name="fields", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Comma-separated list of fields to include in response", + required=False, + examples=[ + OpenApiExample( + name="Basic fields", + value="id,name,description", + description="Include only basic fields", + ), + OpenApiExample( + name="With relations", + value="id,name,assignees,state", + description="Include fields with relationships", + ), + ], +) + +EXPAND_PARAMETER = OpenApiParameter( + name="expand", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Comma-separated list of related fields to expand in response", + required=False, + examples=[ + OpenApiExample( + name="Expand assignees", + value="assignees", + description="Include full assignee details", + ), + OpenApiExample( + name="Multiple expansions", + value="assignees,labels,state", + description="Include details for multiple relations", + ), + ], +) diff --git a/apps/api/plane/utils/openapi/responses.py b/apps/api/plane/utils/openapi/responses.py new file mode 100644 index 00000000..2a569e37 --- /dev/null +++ b/apps/api/plane/utils/openapi/responses.py @@ -0,0 +1,486 @@ +""" +Common OpenAPI responses for drf-spectacular. + +This module provides reusable response definitions for common HTTP status codes +and scenarios that occur across multiple API endpoints. +""" + +from drf_spectacular.utils import OpenApiResponse, OpenApiExample, inline_serializer +from rest_framework import serializers +from .examples import get_sample_for_schema + + +# Authentication & Authorization Responses +UNAUTHORIZED_RESPONSE = OpenApiResponse( + description="Authentication credentials were not provided or are invalid.", + examples=[ + OpenApiExample( + name="Unauthorized", + value={ + "error": "Authentication credentials were not provided", + "error_code": "AUTHENTICATION_REQUIRED", + }, + ) + ], +) + +FORBIDDEN_RESPONSE = OpenApiResponse( + description="Permission denied. User lacks required permissions.", + examples=[ + OpenApiExample( + name="Forbidden", + value={ + "error": "You do not have permission to perform this action", + "error_code": "PERMISSION_DENIED", + }, + ) + ], +) + + +# Resource Responses +NOT_FOUND_RESPONSE = OpenApiResponse( + description="The requested resource was not found.", + examples=[ + OpenApiExample( + name="Not Found", + value={"error": "Not found", "error_code": "RESOURCE_NOT_FOUND"}, + ) + ], +) + +VALIDATION_ERROR_RESPONSE = OpenApiResponse( + description="Validation error occurred with the provided data.", + examples=[ + OpenApiExample( + name="Validation Error", + value={ + "error": "Validation failed", + "details": {"field_name": ["This field is required."]}, + }, + ) + ], +) + +# Generic Success Responses +DELETED_RESPONSE = OpenApiResponse( + description="Resource deleted successfully", + examples=[ + OpenApiExample( + name="Deleted Successfully", + value={"message": "Resource deleted successfully"}, + ) + ], +) + +ARCHIVED_RESPONSE = OpenApiResponse( + description="Resource archived successfully", + examples=[ + OpenApiExample( + name="Archived Successfully", + value={"message": "Resource archived successfully"}, + ) + ], +) + +UNARCHIVED_RESPONSE = OpenApiResponse( + description="Resource unarchived successfully", + examples=[ + OpenApiExample( + name="Unarchived Successfully", + value={"message": "Resource unarchived successfully"}, + ) + ], +) + +# Specific Error Responses +INVALID_REQUEST_RESPONSE = OpenApiResponse( + description="Invalid request data provided", + examples=[ + OpenApiExample( + name="Invalid Request", + value={ + "error": "Invalid request data", + "details": "Specific validation errors", + }, + ) + ], +) + +CONFLICT_RESPONSE = OpenApiResponse( + description="Resource conflict - duplicate or constraint violation", + examples=[ + OpenApiExample( + name="Resource Conflict", + value={ + "error": "Resource with the same identifier already exists", + "id": "550e8400-e29b-41d4-a716-446655440000", + }, + ) + ], +) + +ADMIN_ONLY_RESPONSE = OpenApiResponse( + description="Only admin or creator can perform this action", + examples=[ + OpenApiExample( + name="Admin Only", + value={"error": "Only admin or creator can perform this action"}, + ) + ], +) + +CANNOT_DELETE_RESPONSE = OpenApiResponse( + description="Resource cannot be deleted due to constraints", + examples=[ + OpenApiExample( + name="Cannot Delete", + value={"error": "Resource cannot be deleted", "reason": "Has dependencies"}, + ) + ], +) + +CANNOT_ARCHIVE_RESPONSE = OpenApiResponse( + description="Resource cannot be archived in current state", + examples=[ + OpenApiExample( + name="Cannot Archive", + value={ + "error": "Resource cannot be archived", + "reason": "Not in valid state", + }, + ) + ], +) + +REQUIRED_FIELDS_RESPONSE = OpenApiResponse( + description="Required fields are missing", + examples=[ + OpenApiExample( + name="Required Fields Missing", + value={"error": "Required fields are missing", "fields": ["name", "type"]}, + ) + ], +) + +# Project-specific Responses +PROJECT_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Project not found", + examples=[ + OpenApiExample( + name="Project Not Found", + value={"error": "Project not found"}, + ) + ], +) + +WORKSPACE_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Workspace not found", + examples=[ + OpenApiExample( + name="Workspace Not Found", + value={"error": "Workspace not found"}, + ) + ], +) + +PROJECT_NAME_TAKEN_RESPONSE = OpenApiResponse( + description="Project name already taken", + examples=[ + OpenApiExample( + name="Project Name Taken", + value={"error": "Project name already taken"}, + ) + ], +) + +# Issue-specific Responses +ISSUE_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Issue not found", + examples=[ + OpenApiExample( + name="Issue Not Found", + value={"error": "Issue not found"}, + ) + ], +) + +WORK_ITEM_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Work item not found", + examples=[ + OpenApiExample( + name="Work Item Not Found", + value={"error": "Work item not found"}, + ) + ], +) + +EXTERNAL_ID_EXISTS_RESPONSE = OpenApiResponse( + description="Resource with same external ID already exists", + examples=[ + OpenApiExample( + name="External ID Exists", + value={ + "error": "Resource with the same external id and external source already exists", # noqa: E501 + "id": "550e8400-e29b-41d4-a716-446655440000", + }, + ) + ], +) + +# Label-specific Responses +LABEL_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Label not found", + examples=[ + OpenApiExample( + name="Label Not Found", + value={"error": "Label not found"}, + ) + ], +) + +LABEL_NAME_EXISTS_RESPONSE = OpenApiResponse( + description="Label with the same name already exists", + examples=[ + OpenApiExample( + name="Label Name Exists", + value={"error": "Label with the same name already exists in the project"}, + ) + ], +) + +# Module-specific Responses +MODULE_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Module not found", + examples=[ + OpenApiExample( + name="Module Not Found", + value={"error": "Module not found"}, + ) + ], +) + +MODULE_ISSUE_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Module issue not found", + examples=[ + OpenApiExample( + name="Module Issue Not Found", + value={"error": "Module issue not found"}, + ) + ], +) + +# Cycle-specific Responses +CYCLE_CANNOT_ARCHIVE_RESPONSE = OpenApiResponse( + description="Cycle cannot be archived", + examples=[ + OpenApiExample( + name="Cycle Cannot Archive", + value={"error": "Only completed cycles can be archived"}, + ) + ], +) + +# State-specific Responses +STATE_NAME_EXISTS_RESPONSE = OpenApiResponse( + description="State with the same name already exists", + examples=[ + OpenApiExample( + name="State Name Exists", + value={"error": "State with the same name already exists"}, + ) + ], +) + +STATE_CANNOT_DELETE_RESPONSE = OpenApiResponse( + description="State cannot be deleted", + examples=[ + OpenApiExample( + name="State Cannot Delete", + value={ + "error": "State cannot be deleted", + "reason": "Default state or has issues", + }, + ) + ], +) + +# Comment-specific Responses +COMMENT_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Comment not found", + examples=[ + OpenApiExample( + name="Comment Not Found", + value={"error": "Comment not found"}, + ) + ], +) + +# Link-specific Responses +LINK_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Link not found", + examples=[ + OpenApiExample( + name="Link Not Found", + value={"error": "Link not found"}, + ) + ], +) + +# Attachment-specific Responses +ATTACHMENT_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Attachment not found", + examples=[ + OpenApiExample( + name="Attachment Not Found", + value={"error": "Attachment not found"}, + ) + ], +) + +# Search-specific Responses +BAD_SEARCH_REQUEST_RESPONSE = OpenApiResponse( + description="Bad request - invalid search parameters", + examples=[ + OpenApiExample( + name="Bad Search Request", + value={"error": "Invalid search parameters"}, + ) + ], +) + + +# Pagination Response Templates +def create_paginated_response( + item_schema, + schema_name, + description="Paginated results", + example_name="Paginated Response", +): + """Create a paginated response with the specified item schema""" + + return OpenApiResponse( + description=description, + response=inline_serializer( + name=schema_name, + fields={ + "grouped_by": serializers.CharField(allow_null=True), + "sub_grouped_by": serializers.CharField(allow_null=True), + "total_count": serializers.IntegerField(), + "next_cursor": serializers.CharField(), + "prev_cursor": serializers.CharField(), + "next_page_results": serializers.BooleanField(), + "prev_page_results": serializers.BooleanField(), + "count": serializers.IntegerField(), + "total_pages": serializers.IntegerField(), + "total_results": serializers.IntegerField(), + "extra_stats": serializers.CharField(allow_null=True), + "results": serializers.ListField(child=item_schema()), + }, + ), + examples=[ + OpenApiExample( + name=example_name, + value={ + "grouped_by": "state", + "sub_grouped_by": "priority", + "total_count": 150, + "next_cursor": "20:1:0", + "prev_cursor": "20:0:0", + "next_page_results": True, + "prev_page_results": False, + "count": 20, + "total_pages": 8, + "total_results": 150, + "extra_stats": None, + "results": [get_sample_for_schema(schema_name)], + }, + summary=example_name, + ) + ], + ) + + +# Asset-specific Responses +PRESIGNED_URL_SUCCESS_RESPONSE = OpenApiResponse(description="Presigned URL generated successfully") + +GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE = OpenApiResponse( + description="Presigned URL generated successfully", + examples=[ + OpenApiExample( + name="Generic Asset Upload Response", + value={ + "upload_data": { + "url": "https://s3.amazonaws.com/bucket-name", + "fields": { + "key": "workspace-id/uuid-filename.pdf", + "AWSAccessKeyId": "AKIA...", + "policy": "eyJ...", + "signature": "abc123...", + }, + }, + "asset_id": "550e8400-e29b-41d4-a716-446655440000", + "asset_url": "https://cdn.example.com/workspace-id/uuid-filename.pdf", + }, + ) + ], +) + +GENERIC_ASSET_VALIDATION_ERROR_RESPONSE = OpenApiResponse( + description="Validation error", + examples=[ + OpenApiExample( + name="Missing required fields", + value={"error": "Name and size are required fields.", "status": False}, + ), + OpenApiExample( + name="Invalid file type", + value={"error": "Invalid file type.", "status": False}, + ), + ], +) + +ASSET_CONFLICT_RESPONSE = OpenApiResponse( + description="Asset with same external ID already exists", + examples=[ + OpenApiExample( + name="Duplicate external asset", + value={ + "message": "Asset with same external id and source already exists", + "asset_id": "550e8400-e29b-41d4-a716-446655440000", + "asset_url": "https://cdn.example.com/existing-file.pdf", + }, + ) + ], +) + +ASSET_DOWNLOAD_SUCCESS_RESPONSE = OpenApiResponse( + description="Presigned download URL generated successfully", + examples=[ + OpenApiExample( + name="Asset Download Response", + value={ + "asset_id": "550e8400-e29b-41d4-a716-446655440000", + "asset_url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url", + "asset_name": "document.pdf", + "asset_type": "application/pdf", + }, + ) + ], +) + +ASSET_DOWNLOAD_ERROR_RESPONSE = OpenApiResponse( + description="Bad request", + examples=[ + OpenApiExample(name="Asset not uploaded", value={"error": "Asset not yet uploaded"}), + ], +) + +ASSET_UPDATED_RESPONSE = OpenApiResponse(description="Asset updated successfully") + +ASSET_DELETED_RESPONSE = OpenApiResponse(description="Asset deleted successfully") + +ASSET_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Asset not found", + examples=[OpenApiExample(name="Asset not found", value={"error": "Asset not found"})], +) diff --git a/apps/api/plane/utils/order_queryset.py b/apps/api/plane/utils/order_queryset.py new file mode 100644 index 00000000..167cd069 --- /dev/null +++ b/apps/api/plane/utils/order_queryset.py @@ -0,0 +1,52 @@ +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"] + + +def order_issue_queryset(issue_queryset, order_by_param="-created_at"): + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[When(priority=p, then=Value(i)) for i, p in enumerate(PRIORITY_ORDER)], + output_field=CharField(), + ) + ).order_by("priority_order", "-created_at") + order_by_param = "priority_order" if order_by_param.startswith("-") else "-priority_order" + # State Ordering + elif order_by_param in ["state__group", "-state__group"]: + state_order = STATE_ORDER if order_by_param in ["state__name", "state__group"] else STATE_ORDER[::-1] + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[When(state__group=state_group, then=Value(i)) for i, state_group in enumerate(state_order)], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order", "-created_at") + order_by_param = "-state_order" if order_by_param.startswith("-") else "state_order" + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "assignees__first_name", + "issue_module__module__name", + "-labels__name", + "-assignees__first_name", + "-issue_module__module__name", + ]: + issue_queryset = issue_queryset.annotate( + min_values=Min(order_by_param[1::] if order_by_param.startswith("-") else order_by_param) + ).order_by( + "-min_values" if order_by_param.startswith("-") else "min_values", + "-created_at", + ) + order_by_param = "-min_values" if order_by_param.startswith("-") else "min_values" + else: + # If the order_by_param is created_at, then don't add the -created_at + if "created_at" in order_by_param: + issue_queryset = issue_queryset.order_by(order_by_param) + else: + issue_queryset = issue_queryset.order_by(order_by_param, "-created_at") + order_by_param = order_by_param + return issue_queryset, order_by_param diff --git a/apps/api/plane/utils/paginator.py b/apps/api/plane/utils/paginator.py new file mode 100644 index 00000000..f3a79475 --- /dev/null +++ b/apps/api/plane/utils/paginator.py @@ -0,0 +1,729 @@ +# Python imports +import math +from collections import defaultdict +from collections.abc import Sequence + +# Django imports +from django.db.models import Count, F, Window +from django.db.models.functions import RowNumber + +# Third party imports +from rest_framework.exceptions import ParseError +from rest_framework.response import Response + +# Module imports + + +class Cursor: + # The cursor value + def __init__(self, value, offset=0, is_prev=False, has_results=None): + self.value = value + self.offset = int(offset) + self.is_prev = bool(is_prev) + self.has_results = has_results + + # Return the cursor value in string format + def __str__(self): + return f"{self.value}:{self.offset}:{int(self.is_prev)}" + + # Return the cursor value + def __eq__(self, other): + return all( + getattr(self, attr) == getattr(other, attr) for attr in ("value", "offset", "is_prev", "has_results") + ) + + # Return the representation of the cursor + def __repr__(self): + return f"{(type(self).__name__,)}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}" # noqa: E501 + + # Return if the cursor is true + def __bool__(self): + return bool(self.has_results) + + @classmethod + def from_string(cls, value): + """Return the cursor value from string format""" + try: + bits = value.split(":") + if len(bits) != 3: + raise ValueError("Cursor must be in the format 'value:offset:is_prev'") + + value = float(bits[0]) if "." in bits[0] else int(bits[0]) + return cls(value, int(bits[1]), bool(int(bits[2]))) + except (TypeError, ValueError) as e: + raise ValueError(f"Invalid cursor format: {e}") + + +class CursorResult(Sequence): + def __init__(self, results, next, prev, hits=None, max_hits=None): + self.results = results + self.next = next + self.prev = prev + self.hits = hits + self.max_hits = max_hits + + def __len__(self): + # Return the length of the results + return len(self.results) + + def __iter__(self): + # Return the iterator of the results + return iter(self.results) + + def __getitem__(self, key): + # Return the results based on the key + return self.results[key] + + def __repr__(self): + # Return the representation of the results + return f"<{type(self).__name__}: results={len(self.results)}>" + + +MAX_LIMIT = 1000 + + +class BadPaginationError(Exception): + pass + + +class OffsetPaginator: + """ + The Offset paginator using the offset and limit + with cursor controls + http://example.com/api/users/?cursor=10.0.0&per_page=10 + cursor=limit,offset=page, + """ + + def __init__( + self, + queryset, + order_by=None, + max_limit=MAX_LIMIT, + max_offset=None, + on_results=None, + total_count_queryset=None, + ): + # Key tuple and remove `-` if descending order by + self.key = ( + order_by + if order_by is None or isinstance(order_by, (list, tuple, set)) + else (order_by[1::] if order_by.startswith("-") else order_by,) + ) + # Set desc to true when `-` exists in the order by + self.desc = True if order_by and order_by.startswith("-") else False + self.queryset = queryset + self.max_limit = max_limit + self.max_offset = max_offset + self.on_results = on_results + self.total_count_queryset = total_count_queryset + + def get_result(self, limit=1000, cursor=None): + # offset is page # + # value is page limit + if cursor is None: + cursor = Cursor(0, 0, 0) + + # Get the min from limit and max limit + limit = min(limit, self.max_limit) + + # queryset + queryset = self.queryset + if self.key: + queryset = queryset.order_by( + (F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)), + "-created_at", + ) + # The current page + page = cursor.offset + # The offset - use limit instead of cursor.value for consistent pagination + offset = cursor.offset * limit + stop = offset + limit + 1 + + if self.max_offset is not None and offset >= self.max_offset: + raise BadPaginationError("Pagination offset too large") + if offset < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + results = queryset[offset:stop] + # Duplicate the queryset so it does not evaluate on any python ops + page_results = queryset[offset:stop].values("id") + + # Only slice from the end if we're going backwards (previous page) + if cursor.value != limit and cursor.is_prev: + results = results[-(limit + 1) :] + + total_count = self.total_count_queryset.count() if self.total_count_queryset else queryset.count() + + # Check if there are more results available after the current page + + # Adjust cursors based on the results for pagination + next_cursor = Cursor(limit, page + 1, False, page_results.count() > limit) + # If the page is greater than 0, then set the previous cursor + prev_cursor = Cursor(limit, page - 1, True, page > 0) + + # Process the results + results = results[:limit] + + # Process the results + if self.on_results: + results = self.on_results(results) + + # Count the queryset + count = total_count + + # Optionally, calculate the total count and max_hits if needed + max_hits = math.ceil(count / limit) + + # Return the cursor results + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=count, + max_hits=max_hits, + ) + + def process_results(self, results): + raise NotImplementedError + + +class GroupedOffsetPaginator(OffsetPaginator): + # Field mappers - list m2m fields here + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "issue_module__module_id": "module_ids", + } + + def __init__( + self, + queryset, + group_by_field_name, + group_by_fields, + count_filter, + total_count_queryset=None, + *args, + **kwargs, + ): + # Initiate the parent class for all the parameters + super().__init__(queryset, *args, **kwargs) + + # Set the group by field name + self.group_by_field_name = group_by_field_name + # Set the group by fields + self.group_by_fields = group_by_fields + # Set the count filter - this are extra filters that need to be passed + # to calculate the counts with the filters + self.count_filter = count_filter + + def get_result(self, limit=50, cursor=None): + # offset is page # + # value is page limit + if cursor is None: + cursor = Cursor(0, 0, 0) + + limit = min(limit, self.max_limit) + + # Adjust the initial offset and stop based on the cursor and limit + queryset = self.queryset + + page = cursor.offset + offset = cursor.offset * cursor.value + stop = offset + (cursor.value or limit) + 1 + + # Check if the offset is greater than the max offset + if self.max_offset is not None and offset >= self.max_offset: + raise BadPaginationError("Pagination offset too large") + + # Check if the offset is less than 0 + if offset < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + # Compute the results + results = {} + # Create window for all the groups + queryset = queryset.annotate( + row_number=Window( + expression=RowNumber(), + partition_by=[F(self.group_by_field_name)], + order_by=( + ( + F(*self.key).desc(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 + ), + F("created_at").desc(), + ), + ) + ) + # Filter the results by row number + results = queryset.filter(row_number__gt=offset, row_number__lt=stop).order_by( + (F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)), + F("created_at").desc(), + ) + + # Adjust cursors based on the grouped results for pagination + next_cursor = Cursor(limit, page + 1, False, queryset.filter(row_number__gte=stop).exists()) + + # Add previous cursors + prev_cursor = Cursor(limit, page - 1, True, page > 0) + + # Count the queryset + count = queryset.count() + + # Optionally, calculate the total count and max_hits if needed + # This might require adjustments based on specific use cases + if results: + max_hits = math.ceil( + queryset.values(self.group_by_field_name) + .annotate(count=Count("id", filter=self.count_filter, distinct=True)) + .order_by("-count")[0]["count"] + / limit + ) + else: + max_hits = 0 + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=count, + max_hits=max_hits, + ) + + def __get_total_queryset(self): + # 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)) + .order_by() + ) + + def __get_total_dict(self): + # Convert the total into dictionary of keys as group name and value as the total + 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 + ) + (1 if group.get("count") == 0 else group.get("count")) + return total_group_dict + + def __get_field_dict(self): + # Create a field dictionary + total_group_dict = self.__get_total_dict() + return { + str(field): { + "results": [], + "total_results": total_group_dict.get(str(field), 0), + } + for field in self.group_by_fields + } + + def __result_already_added(self, result, group): + # Check if the result is already added then add it + for existing_issue in group: + if existing_issue["id"] == result["id"]: + return True + return False + + def __query_multi_grouper(self, results): + # Grouping for m2m values + total_group_dict = self.__get_total_dict() + + # Preparing a dict to keep track of group IDs associated with each entity ID + result_group_mapping = defaultdict(set) + # Preparing a dict to group result by group ID + grouped_by_field_name = defaultdict(list) + + # Iterate over results to fill the above dictionaries + for result in results: + result_id = result["id"] + group_id = result[self.group_by_field_name] + result_group_mapping[str(result_id)].add(str(group_id)) + + # Adding group_ids key to each issue and grouping by group_name + for result in results: + result_id = result["id"] + group_ids = list(result_group_mapping[str(result_id)]) + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = [] if "None" in group_ids else group_ids + # If a result belongs to multiple groups, add it to each group + for group_id in group_ids: + if not self.__result_already_added(result, grouped_by_field_name[group_id]): + grouped_by_field_name[group_id].append(result) + + # Convert grouped_by_field_name back to a list for each group + processed_results = { + str(group_id): { + "results": issues, + "total_results": total_group_dict.get(str(group_id)), + } + for group_id, issues in grouped_by_field_name.items() + } + + return processed_results + + def __query_grouper(self, results): + # Grouping for values that are not m2m + processed_results = self.__get_field_dict() + for result in results: + group_value = str(result.get(self.group_by_field_name)) + if group_value in processed_results: + processed_results[str(group_value)]["results"].append(result) + return processed_results + + def process_results(self, results): + # Process results + if results: + if self.group_by_field_name in self.FIELD_MAPPER: + processed_results = self.__query_multi_grouper(results=results) + else: + processed_results = self.__query_grouper(results=results) + else: + processed_results = {} + return processed_results + + +class SubGroupedOffsetPaginator(OffsetPaginator): + # Field mappers this are the fields that are m2m + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "issue_module__module_id": "module_ids", + } + + def __init__( + self, + queryset, + group_by_field_name, + sub_group_by_field_name, + group_by_fields, + sub_group_by_fields, + count_filter, + total_count_queryset=None, + *args, + **kwargs, + ): + # Initiate the parent class for all the parameters + super().__init__(queryset, *args, **kwargs) + + # Set the group by field name + self.group_by_field_name = group_by_field_name + self.group_by_fields = group_by_fields + + # Set the sub group by field name + self.sub_group_by_field_name = sub_group_by_field_name + self.sub_group_by_fields = sub_group_by_fields + + # Set the count filter - this are extra filters that need + # to be passed to calculate the counts with the filters + self.count_filter = count_filter + + def get_result(self, limit=30, cursor=None): + # offset is page # + # value is page limit + if cursor is None: + cursor = Cursor(0, 0, 0) + + # get the minimum value + limit = min(limit, self.max_limit) + + # Adjust the initial offset and stop based on the cursor and limit + queryset = self.queryset + + # the current page + page = cursor.offset + + # the offset + offset = cursor.offset * cursor.value + + # the stop + stop = offset + (cursor.value or limit) + 1 + + if self.max_offset is not None and offset >= self.max_offset: + raise BadPaginationError("Pagination offset too large") + if offset < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + # Compute the results + results = {} + + # Create windows for group and sub group field name + queryset = queryset.annotate( + row_number=Window( + expression=RowNumber(), + partition_by=[ + F(self.group_by_field_name), + F(self.sub_group_by_field_name), + ], + order_by=( + (F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)), + "-created_at", + ), + ) + ) + + # Filter the results + results = queryset.filter(row_number__gt=offset, row_number__lt=stop).order_by( + (F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)), + F("created_at").desc(), + ) + + # Adjust cursors based on the grouped results for pagination + next_cursor = Cursor(limit, page + 1, False, queryset.filter(row_number__gte=stop).exists()) + + # Add previous cursors + prev_cursor = Cursor(limit, page - 1, True, page > 0) + + # Count the queryset + count = queryset.count() + + # Optionally, calculate the total count and max_hits if needed + # This might require adjustments based on specific use cases + if results: + max_hits = math.ceil( + queryset.values(self.group_by_field_name) + .annotate(count=Count("id", filter=self.count_filter, distinct=True)) + .order_by("-count")[0]["count"] + / limit + ) + else: + max_hits = 0 + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=count, + max_hits=max_hits, + ) + + def __get_group_total_queryset(self): + # Get group totals + 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)) + .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)) + .order_by() + .values(self.group_by_field_name, self.sub_group_by_field_name, "count") + ) + + def __get_total_dict(self): + # Use the above to convert to dictionary of 2D objects + total_group_dict = {} + 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 + ) + (1 if group.get("count") == 0 else group.get("count")) + + # Sub group total values + for item in self.__get_subgroup_total_queryset(): + group = str(item[self.group_by_field_name]) + subgroup = str(item[self.sub_group_by_field_name]) + count = item["count"] + + # Create a dictionary of group and sub group + if group not in total_sub_group_dict: + total_sub_group_dict[str(group)] = {} + + # Create a dictionary of sub group + if subgroup not in total_sub_group_dict[group]: + total_sub_group_dict[str(group)][str(subgroup)] = {} + + # Create a nested dictionary of group and sub group + total_sub_group_dict[group][subgroup] = count + + return total_group_dict, total_sub_group_dict + + def __get_field_dict(self): + # Create a field dictionary + total_group_dict, total_sub_group_dict = self.__get_total_dict() + + # Create a dictionary of group and sub group + return { + str(group): { + "results": { + str(sub_group): { + "results": [], + "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), []) + }, + "total_results": total_group_dict.get(str(group), 0), + } + for group in self.group_by_fields + } + + def __query_multi_grouper(self, results): + # Multi grouper + processed_results = self.__get_field_dict() + # Preparing a dict to keep track of group IDs associated with each label ID + result_group_mapping = defaultdict(set) + result_sub_group_mapping = defaultdict(set) + + # Iterate over results to fill the above dictionaries + if self.group_by_field_name in self.FIELD_MAPPER: + for result in results: + result_id = result["id"] + group_id = result[self.group_by_field_name] + result_group_mapping[str(result_id)].add(str(group_id)) + # Use the same calculation for the sub group + if self.sub_group_by_field_name in self.FIELD_MAPPER: + for result in results: + result_id = result["id"] + sub_group_id = result[self.sub_group_by_field_name] + result_sub_group_mapping[str(result_id)].add(str(sub_group_id)) + + # Iterate over results + for result in results: + # Get the group value + group_value = str(result.get(self.group_by_field_name)) + # Get the sub group value + sub_group_value = str(result.get(self.sub_group_by_field_name)) + # Check if the group value is in the processed results + result_id = result["id"] + + if group_value in processed_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 + group_ids = list(result_group_mapping[str(result_id)]) + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = [] 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)]) + # for multi groups + 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) + + return processed_results + + def __query_grouper(self, results): + # Single grouper + processed_results = self.__get_field_dict() + for result in results: + group_value = str(result.get(self.group_by_field_name)) + sub_group_value = str(result.get(self.sub_group_by_field_name)) + processed_results[group_value]["results"][sub_group_value]["results"].append(result) + + return processed_results + + def process_results(self, results): + if results: + if self.group_by_field_name in self.FIELD_MAPPER or self.sub_group_by_field_name in self.FIELD_MAPPER: + # if the grouping is done through m2m then + processed_results = self.__query_multi_grouper(results=results) + else: + # group it directly + processed_results = self.__query_grouper(results=results) + else: + processed_results = {} + return processed_results + + +class BasePaginator: + """BasePaginator class can be inherited by any View to return a paginated view""" + + # cursor query parameter name + cursor_name = "cursor" + + # get the per page parameter from request + def get_per_page(self, request, default_per_page=1000, max_per_page=1000): + try: + per_page = int(request.GET.get("per_page", default_per_page)) + except ValueError: + raise ParseError(detail="Invalid per_page parameter.") + + max_per_page = max(max_per_page, default_per_page) + if per_page > max_per_page: + raise ParseError(detail=f"Invalid per_page value. Cannot exceed {max_per_page}.") + + return per_page + + def paginate( + self, + request, + on_results=None, + paginator=None, + paginator_cls=OffsetPaginator, + default_per_page=1000, + max_per_page=1000, + cursor_cls=Cursor, + extra_stats=None, + controller=None, + group_by_field_name=None, + group_by_fields=None, + sub_group_by_field_name=None, + sub_group_by_fields=None, + count_filter=None, + total_count_queryset=None, + **paginator_kwargs, + ): + """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")) + except ValueError: + raise ParseError(detail="Invalid cursor parameter.") + + if not paginator: + if group_by_field_name: + paginator_kwargs["group_by_field_name"] = group_by_field_name + paginator_kwargs["group_by_fields"] = group_by_fields + paginator_kwargs["count_filter"] = count_filter + + if sub_group_by_field_name: + paginator_kwargs["sub_group_by_field_name"] = sub_group_by_field_name + paginator_kwargs["sub_group_by_fields"] = sub_group_by_fields + + paginator_kwargs["total_count_queryset"] = total_count_queryset + + paginator = paginator_cls(**paginator_kwargs) + + try: + cursor_result = paginator.get_result(limit=per_page, cursor=input_cursor) + except BadPaginationError: + raise ParseError(detail="Error in parsing") + + if on_results: + results = on_results(cursor_result.results) + else: + results = cursor_result.results + + if group_by_field_name: + results = paginator.process_results(results=results) + + # Add Manipulation functions to the response + if controller is not None: + results = controller(results) + else: + results = results + + # Return the response + response = Response( + { + "grouped_by": group_by_field_name, + "sub_grouped_by": sub_group_by_field_name, + "total_count": (cursor_result.hits), + "next_cursor": str(cursor_result.next), + "prev_cursor": str(cursor_result.prev), + "next_page_results": cursor_result.next.has_results, + "prev_page_results": cursor_result.prev.has_results, + "count": cursor_result.__len__(), + "total_pages": cursor_result.max_hits, + "total_results": cursor_result.hits, + "extra_stats": extra_stats, + "results": results, + } + ) + + return response diff --git a/apps/api/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py new file mode 100644 index 00000000..ede3f116 --- /dev/null +++ b/apps/api/plane/utils/path_validator.py @@ -0,0 +1,141 @@ +# Django imports +from django.utils.http import url_has_allowed_host_and_scheme +from django.conf import settings + +# Python imports +from urllib.parse import urlparse + + +def _contains_suspicious_patterns(path: str) -> bool: + """ + Check for suspicious patterns that might indicate malicious intent. + + Args: + path (str): The path to check + + Returns: + bool: True if suspicious patterns found, False otherwise + """ + suspicious_patterns = [ + r"javascript:", # JavaScript injection + r"data:", # Data URLs + r"vbscript:", # VBScript injection + r"file:", # File protocol + r"ftp:", # FTP protocol + r"%2e%2e", # URL encoded path traversal + r"%2f%2f", # URL encoded double slash + r"%5c%5c", # URL encoded backslashes + r" str: + """Validates that next_path is a safe relative path for redirection.""" + # Browsers interpret backslashes as forward slashes. Remove all backslashes. + if not next_path or not isinstance(next_path, str): + return "" + + # Limit input length to prevent DoS attacks + if len(next_path) > 500: + return "" + + next_path = next_path.replace("\\", "") + parsed_url = urlparse(next_path) + + # Block absolute URLs or anything with scheme/netloc + if parsed_url.scheme or parsed_url.netloc: + next_path = parsed_url.path # Extract only the path component + + # Must start with a forward slash and not be empty + if not next_path or not next_path.startswith("/"): + return "" + + # Prevent path traversal + if ".." in next_path: + return "" + + # Additional security checks + if _contains_suspicious_patterns(next_path): + return "" + + return next_path + + +def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) -> str: + """ + Safely construct a redirect URL with validated next_path. + + Args: + base_url (str): The base URL to redirect to + next_path (str): The next path to append + params (dict): The parameters to append + Returns: + str: The safe redirect URL + """ + from urllib.parse import urlencode + + # Validate the next path + validated_path = validate_next_path(next_path) + + # Add the next path to the parameters + base_url = base_url.rstrip("/") + + # Prepare the query parameters + query_parts = [] + encoded_params = "" + + # Add the next path to the parameters + if validated_path: + query_parts.append(f"next_path={validated_path}") + + # Add additional parameters + if params: + encoded_params = urlencode(params) + query_parts.append(encoded_params) + + # Construct the url query string + if query_parts: + query_string = "&".join(query_parts) + url = f"{base_url}/?{query_string}" + else: + url = base_url + + # Check if the URL is allowed + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return url + + # Return the base URL if the URL is not allowed + return base_url + (f"?{encoded_params}" if encoded_params else "") diff --git a/apps/api/plane/utils/telemetry.py b/apps/api/plane/utils/telemetry.py new file mode 100644 index 00000000..bec3d240 --- /dev/null +++ b/apps/api/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/apps/api/plane/utils/timezone_converter.py b/apps/api/plane/utils/timezone_converter.py new file mode 100644 index 00000000..9a66742e --- /dev/null +++ b/apps/api/plane/utils/timezone_converter.py @@ -0,0 +1,121 @@ +# Python imports +import pytz +from datetime import datetime, time +from datetime import timedelta + +# Django imports +from django.utils import timezone + +# Module imports +from plane.db.models import Project + + +def user_timezone_converter(queryset, datetime_fields, user_timezone): + # Create a timezone object for the user's timezone + user_tz = pytz.timezone(user_timezone) + + # Check if queryset is a dictionary (single item) or a list of dictionaries + if isinstance(queryset, dict): + queryset_values = [queryset] + else: + queryset_values = list(queryset) + + # Iterate over the dictionaries in the list + for item in queryset_values: + # Iterate over the datetime fields + for field in datetime_fields: + # Convert the datetime field to the user's timezone + if field in item and item[field]: + item[field] = item[field].astimezone(user_tz) + + # If queryset was a single item, return a single item + if isinstance(queryset, dict): + return queryset_values[0] + else: + return queryset_values + + +def convert_to_utc(date, project_id, is_start_date=False): + """ + Converts a start date string to the project's local timezone at 12:00 AM + and then converts it to UTC for storage. + + Args: + date (str): The date string in "YYYY-MM-DD" format. + project_id (int): The project's ID to fetch the associated timezone. + + Returns: + datetime: The UTC datetime. + """ + # Retrieve the project's timezone using the project ID + project = Project.objects.get(id=project_id) + project_timezone = project.timezone + if not date or not project_timezone: + raise ValueError("Both date and timezone must be provided.") + + # Parse the string into a date object + start_date = datetime.strptime(date, "%Y-%m-%d").date() + + # Get the project's timezone + local_tz = pytz.timezone(project_timezone) + + # Combine the date with 12:00 AM time + local_datetime = datetime.combine(start_date, time.min) + + # Localize the datetime to the project's timezone + localized_datetime = local_tz.localize(local_datetime) + + # If it's an start date, add one minute + if is_start_date: + localized_datetime += timedelta(minutes=0, seconds=1) + + # Convert the localized datetime to UTC + utc_datetime = localized_datetime.astimezone(pytz.utc) + + current_datetime_in_project_tz = timezone.now().astimezone(local_tz) + current_datetime_in_utc = current_datetime_in_project_tz.astimezone(pytz.utc) + + if localized_datetime.date() == current_datetime_in_project_tz.date(): + return current_datetime_in_utc + + return utc_datetime + else: + # the cycle end date is the last minute of the day + localized_datetime += timedelta(hours=23, minutes=59, seconds=0) + + # Convert the localized datetime to UTC + utc_datetime = localized_datetime.astimezone(pytz.utc) + + # Return the UTC datetime for storage + return utc_datetime + + +def convert_utc_to_project_timezone(utc_datetime, project_id): + """ + Converts a UTC datetime (stored in the database) to the project's local timezone. + + Args: + utc_datetime (datetime): The UTC datetime to be converted. + project_id (int): The project's ID to fetch the associated timezone. + + Returns: + datetime: The datetime in the project's local timezone. + """ + # Retrieve the project's timezone using the project ID + project = Project.objects.get(id=project_id) + project_timezone = project.timezone + if not project_timezone: + raise ValueError("Project timezone must be provided.") + + # Get the timezone object for the project's timezone + local_tz = pytz.timezone(project_timezone) + + # Convert the UTC datetime to the project's local timezone + if utc_datetime.tzinfo is None: + # Localize UTC datetime if it's naive (i.e., without timezone info) + utc_datetime = pytz.utc.localize(utc_datetime) + + # Convert to the project's local timezone + local_datetime = utc_datetime.astimezone(local_tz) + + return local_datetime diff --git a/apps/api/plane/utils/url.py b/apps/api/plane/utils/url.py new file mode 100644 index 00000000..773608bd --- /dev/null +++ b/apps/api/plane/utils/url.py @@ -0,0 +1,129 @@ +# Python imports +import re +from typing import Optional +from urllib.parse import urlparse, urlunparse + +# Compiled regex pattern for better performance and ReDoS protection +# Using atomic groups and length limits to prevent excessive backtracking +URL_PATTERN = re.compile( + r"(?i)" # Case insensitive + r"(?:" # Non-capturing group for alternatives + r"https?://[^\s]+" # http:// or https:// followed by non-whitespace + r"|" + r"www\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*" # noqa: E501 + r"|" + r"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}" # noqa: E501 + r"|" + r"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)" # noqa: E501 + r")" +) + + +def contains_url(value: str) -> bool: + """ + Check if the value contains a URL. + + This function is protected against ReDoS attacks by: + 1. Using a pre-compiled regex pattern + 2. Limiting input length to prevent excessive processing + 3. Using atomic groups and specific quantifiers to avoid backtracking + + Args: + value (str): The input string to check for URLs + + Returns: + bool: True if the string contains a URL, False otherwise + """ + # Prevent ReDoS by limiting input length + if len(value) > 1000: # Reasonable limit for URL detection + return False + + # Additional safety: truncate very long lines that might contain URLs + lines = value.split("\n") + for line in lines: + if len(line) > 500: # Process only reasonable length lines + line = line[:500] + if URL_PATTERN.search(line): + return True + + return False + + +def is_valid_url(url: str) -> bool: + """ + Validates whether the given string is a well-formed URL. + + Args: + url (str): The URL string to validate. + + Returns: + bool: True if the URL is valid, False otherwise. + + Example: + >>> is_valid_url("https://example.com") + True + >>> is_valid_url("not a url") + False + """ + try: + result = urlparse(url) + # A valid URL should have at least scheme and netloc + return all([result.scheme, result.netloc]) + except TypeError: + return False + + +def get_url_components(url: str) -> Optional[dict]: + """ + Parses the URL and returns its components if valid. + + Args: + url (str): The URL string to parse. + + Returns: + Optional[dict]: A dictionary with URL components if valid, None otherwise. + + Example: + >>> get_url_components("https://example.com/path?query=1") + { + 'scheme': 'https', 'netloc': 'example.com', + 'path': '/path', 'params': '', + 'query': 'query=1', 'fragment': ''} + """ + if not is_valid_url(url): + return None + result = urlparse(url) + return { + "scheme": result.scheme, + "netloc": result.netloc, + "path": result.path, + "params": result.params, + "query": result.query, + "fragment": result.fragment, + } + + +def normalize_url_path(url: str) -> str: + """ + Normalize the path component of a URL by + replacing multiple consecutive slashes with a single slash. + + This function preserves the protocol, domain, + query parameters, and fragments of the URL, + only modifying the path portion to ensure there are no duplicate slashes. + + Args: + url (str): The input URL string to normalize. + + Returns: + str: The normalized URL with redundant slashes in the path removed. + + Example: + >>> normalize_url_path('https://example.com//foo///bar//baz?x=1#frag') + 'https://example.com/foo/bar/baz?x=1#frag' + """ + parts = urlparse(url) + # Normalize the path + normalized_path = re.sub(r"/+", "/", parts.path) + # Reconstruct the URL + return urlunparse(parts._replace(path=normalized_path)) diff --git a/apps/api/plane/utils/uuid.py b/apps/api/plane/utils/uuid.py new file mode 100644 index 00000000..03f695fd --- /dev/null +++ b/apps/api/plane/utils/uuid.py @@ -0,0 +1,22 @@ +# Python imports +import uuid +import hashlib + + +def is_valid_uuid(uuid_str): + """Check if a string is a valid UUID version 4""" + try: + uuid_obj = uuid.UUID(uuid_str) + return uuid_obj.version == 4 + except ValueError: + return False + + +def convert_uuid_to_integer(uuid_val: uuid.UUID) -> int: + """Convert a UUID to a 64-bit signed integer""" + # Ensure UUID is a string + uuid_value: str = str(uuid_val) + # Hash to 64-bit signed int + h: bytes = hashlib.sha256(uuid_value.encode()).digest() + bigint: int = int.from_bytes(h[:8], byteorder="big", signed=True) + return bigint diff --git a/apps/api/plane/web/__init__.py b/apps/api/plane/web/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/plane/web/apps.py b/apps/api/plane/web/apps.py new file mode 100644 index 00000000..a5861f9b --- /dev/null +++ b/apps/api/plane/web/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WebConfig(AppConfig): + name = "plane.web" diff --git a/apps/api/plane/web/urls.py b/apps/api/plane/web/urls.py new file mode 100644 index 00000000..28734ad9 --- /dev/null +++ b/apps/api/plane/web/urls.py @@ -0,0 +1,4 @@ +from django.urls import path +from plane.web.views import robots_txt, health_check + +urlpatterns = [path("robots.txt", robots_txt), path("", health_check)] diff --git a/apps/api/plane/web/views.py b/apps/api/plane/web/views.py new file mode 100644 index 00000000..8acb70a7 --- /dev/null +++ b/apps/api/plane/web/views.py @@ -0,0 +1,9 @@ +from django.http import HttpResponse, JsonResponse + + +def health_check(request): + return JsonResponse({"status": "OK"}) + + +def robots_txt(request): + return HttpResponse("User-agent: *\nDisallow: /", content_type="text/plain") diff --git a/apps/api/plane/wsgi.py b/apps/api/plane/wsgi.py new file mode 100644 index 00000000..b3051f9f --- /dev/null +++ b/apps/api/plane/wsgi.py @@ -0,0 +1,14 @@ +""" +WSGI config for plane project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") + +application = get_wsgi_application() diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml new file mode 100644 index 00000000..428aabba --- /dev/null +++ b/apps/api/pyproject.toml @@ -0,0 +1,96 @@ +[project] +name = "Plane" +version = "0.24.0" +description = "Open-source project management that unlocks customer value" + +[tool.ruff] +# Exclude a variety of commonly ignored directories. +exclude = [ + ".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 = 120 +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/apps/api/pytest.ini b/apps/api/pytest.ini new file mode 100644 index 00000000..e2f19445 --- /dev/null +++ b/apps/api/pytest.ini @@ -0,0 +1,17 @@ +[pytest] +DJANGO_SETTINGS_MODULE = plane.settings.test +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +markers = + unit: Unit tests for models, serializers, and utility functions + contract: Contract tests for API endpoints + smoke: Smoke tests for critical functionality + slow: Tests that are slow and might be skipped in some contexts + +addopts = + --strict-markers + --reuse-db + --nomigrations + -vs \ No newline at end of file diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt new file mode 100644 index 00000000..9887773e --- /dev/null +++ b/apps/api/requirements.txt @@ -0,0 +1,3 @@ +# This file is here because many Platforms as a Service look for +# requirements.txt in the root directory of a project. +-r requirements/production.txt \ No newline at end of file diff --git a/apps/api/requirements/base.txt b/apps/api/requirements/base.txt new file mode 100644 index 00000000..cde403b0 --- /dev/null +++ b/apps/api/requirements/base.txt @@ -0,0 +1,73 @@ +# base requirements + +# django +Django==4.2.25 +# rest framework +djangorestframework==3.15.2 +# postgres +psycopg==3.2.9 +psycopg-binary==3.2.9 +psycopg-c==3.2.9 +dj-database-url==2.1.0 +# mongo +pymongo==4.6.3 +# redis +redis==5.0.4 +django-redis==5.4.0 +# cors +django-cors-headers==4.3.1 +# celery +celery==5.4.0 +django_celery_beat==2.6.0 +django-celery-results==2.5.1 +# file serve +whitenoise==6.6.0 +# fake data +faker==25.0.0 +# filters +django-filter==24.2 +# json model +jsonmodels==2.7.0 +# storage +django-storages==1.14.2 +# user management +django-crum==0.7.9 +# web server +uvicorn==0.29.0 +# sockets +channels==4.1.0 +# ai +openai==1.63.2 +# slack +slack-sdk==3.27.1 +# apm +scout-apm==3.1.0 +# xlsx generation +openpyxl==3.1.2 +# logging +python-json-logger==3.3.0 +# html parser +beautifulsoup4==4.12.3 +# analytics +posthog==3.5.0 +# crypto +cryptography==44.0.1 +# html validator +lxml==6.0.0 +# s3 +boto3==1.34.96 +# password validator +zxcvbn==4.4.28 +# timezone +pytz==2024.1 +# jwt +PyJWT==2.8.0 +# OpenTelemetry +opentelemetry-api==1.28.1 +opentelemetry-sdk==1.28.1 +opentelemetry-instrumentation-django==0.49b1 +opentelemetry-exporter-otlp==1.28.1 +# OpenAPI Specification +drf-spectacular==0.28.0 +# html sanitizer +nh3==0.2.18 diff --git a/apps/api/requirements/local.txt b/apps/api/requirements/local.txt new file mode 100644 index 00000000..2146554f --- /dev/null +++ b/apps/api/requirements/local.txt @@ -0,0 +1,5 @@ +-r base.txt +# debug toolbar +django-debug-toolbar==4.3.0 +# formatter +ruff==0.9.7 diff --git a/apps/api/requirements/production.txt b/apps/api/requirements/production.txt new file mode 100644 index 00000000..f09c6080 --- /dev/null +++ b/apps/api/requirements/production.txt @@ -0,0 +1,3 @@ +-r base.txt +# server +gunicorn==23.0.0 diff --git a/apps/api/requirements/test.txt b/apps/api/requirements/test.txt new file mode 100644 index 00000000..66a1ff16 --- /dev/null +++ b/apps/api/requirements/test.txt @@ -0,0 +1,12 @@ +-r base.txt +# test framework +pytest==7.4.0 +pytest-django==4.5.2 +pytest-cov==4.1.0 +pytest-xdist==3.3.1 +pytest-mock==3.11.1 +factory-boy==3.3.0 +freezegun==1.2.2 +coverage==7.2.7 +httpx==0.24.1 +requests==2.32.4 \ No newline at end of file diff --git a/apps/api/run_tests.py b/apps/api/run_tests.py new file mode 100755 index 00000000..b92f9fe5 --- /dev/null +++ b/apps/api/run_tests.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +import argparse +import subprocess +import sys + + +def main(): + parser = argparse.ArgumentParser(description="Run Plane tests") + parser.add_argument("-u", "--unit", action="store_true", help="Run unit tests only") + parser.add_argument("-c", "--contract", action="store_true", help="Run contract tests only") + parser.add_argument("-s", "--smoke", action="store_true", help="Run smoke tests only") + parser.add_argument("-o", "--coverage", action="store_true", help="Generate coverage report") + parser.add_argument("-p", "--parallel", action="store_true", help="Run tests in parallel") + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + args = parser.parse_args() + + # Build command + cmd = ["python", "-m", "pytest"] + markers = [] + + # Add test markers + if args.unit: + markers.append("unit") + if args.contract: + markers.append("contract") + if args.smoke: + markers.append("smoke") + + # Add markers filter + if markers: + cmd.extend(["-m", " or ".join(markers)]) + + # Add coverage + if args.coverage: + cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"]) + + # Add parallel + if args.parallel: + cmd.extend(["-n", "auto"]) + + # Add verbose + if args.verbose: + cmd.append("-v") + + # Add common flags + cmd.extend(["--reuse-db", "--nomigrations"]) + + # Print command + print(f"Running: {' '.join(cmd)}") + + # Execute command + result = subprocess.run(cmd) + + # Check coverage thresholds if coverage is enabled + if args.coverage: + print("Checking coverage thresholds...") + coverage_cmd = ["python", "-m", "coverage", "report", "--fail-under=90"] + coverage_result = subprocess.run(coverage_cmd) + if coverage_result.returncode != 0: + print("Coverage below threshold (90%)") + sys.exit(coverage_result.returncode) + + sys.exit(result.returncode) + + +if __name__ == "__main__": + main() diff --git a/apps/api/run_tests.sh b/apps/api/run_tests.sh new file mode 100755 index 00000000..7e22479b --- /dev/null +++ b/apps/api/run_tests.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# This is a simple wrapper script that calls the main test runner in the tests directory +exec tests/run_tests.sh "$@" \ No newline at end of file diff --git a/apps/api/templates/admin/base_site.html b/apps/api/templates/admin/base_site.html new file mode 100644 index 00000000..fd1d8906 --- /dev/null +++ b/apps/api/templates/admin/base_site.html @@ -0,0 +1,23 @@ +{% extends "admin/base.html" %}{% load i18n %} + +{% block title %}{{ title }} | {% trans 'plane Admin' %} {% endblock %} + +{% block branding %} + + +

    {% trans 'Plane Django Admin' %}

    + + +{% endblock %}{% block nav-global %}{% endblock %} diff --git a/apps/api/templates/base.html b/apps/api/templates/base.html new file mode 100644 index 00000000..a3a2003e --- /dev/null +++ b/apps/api/templates/base.html @@ -0,0 +1,20 @@ +{% load static %} + + + + + + + + + Hello plane! + + + {% block content %}{% endblock content %} + + + + + + + diff --git a/apps/api/templates/csrf_failure.html b/apps/api/templates/csrf_failure.html new file mode 100644 index 00000000..b5a58cb0 --- /dev/null +++ b/apps/api/templates/csrf_failure.html @@ -0,0 +1,66 @@ + + + + + + + CSRF Verification Failed + + + +
    +
    +

    CSRF Verification Failed

    +
    +
    +

    + It looks like your form submission has expired or there was a problem + with your request. +

    +

    Please try the following:

    +
      +
    • Refresh the page and try submitting the form again.
    • +
    • Ensure that cookies are enabled in your browser.
    • +
    + Go to Home Page +
    +
    + + diff --git a/apps/api/templates/emails/auth/forgot_password.html b/apps/api/templates/emails/auth/forgot_password.html new file mode 100644 index 00000000..f673c1e6 --- /dev/null +++ b/apps/api/templates/emails/auth/forgot_password.html @@ -0,0 +1,330 @@ + + + + + + + + Set a new password to your Plane account + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/api/templates/emails/auth/magic_signin.html b/apps/api/templates/emails/auth/magic_signin.html new file mode 100644 index 00000000..c32b399f --- /dev/null +++ b/apps/api/templates/emails/auth/magic_signin.html @@ -0,0 +1,288 @@ + + + + + + + + Your unique Plane login code is code + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/api/templates/emails/exports/analytics.html b/apps/api/templates/emails/exports/analytics.html new file mode 100644 index 00000000..d2caa9d7 --- /dev/null +++ b/apps/api/templates/emails/exports/analytics.html @@ -0,0 +1,2 @@ + + Hey there,
    Your requested data export from Plane Analytics is now ready. The information has been compiled into a CSV format for your convenience.
    Please find the attachment and download the CSV file. This file can easily be imported into any spreadsheet program for further analysis.
    If you require any assistance or have any questions, please do not hesitate to contact us.
    Thank you \ No newline at end of file diff --git a/apps/api/templates/emails/invitations/project_invitation.html b/apps/api/templates/emails/invitations/project_invitation.html new file mode 100644 index 00000000..254408ac --- /dev/null +++ b/apps/api/templates/emails/invitations/project_invitation.html @@ -0,0 +1,349 @@ + + + + + + + + {{ first_name }} invited you to join {{ project_name }} on Plane + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/api/templates/emails/invitations/workspace_invitation.html b/apps/api/templates/emails/invitations/workspace_invitation.html new file mode 100644 index 00000000..619f0399 --- /dev/null +++ b/apps/api/templates/emails/invitations/workspace_invitation.html @@ -0,0 +1,219 @@ + + + + + + + + {{first_name}} has invited you to join them in {{workspace_name}} on Plane. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/api/templates/emails/notifications/issue-updates.html b/apps/api/templates/emails/notifications/issue-updates.html new file mode 100644 index 00000000..c6fe3b27 --- /dev/null +++ b/apps/api/templates/emails/notifications/issue-updates.html @@ -0,0 +1,243 @@ + + + + + + Updates on {{entity_type}} + + + + + +
    + +
    + + + + +
    +
    +
    +
    + +
    +
    + + + + +
    +

    {{ issue.issue_identifier }} updates

    +

    {{workspace}}/{{project}}/{{issue.issue_identifier}}: {{ issue.name }}

    +
    +
    + {% if actors_involved == 1 %} +

    {{summary}} {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %} .

    + {% else %} +

    {{summary}} {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %} and others.

    + {% endif %} {% for update in data %} {% if update.changes.name %} +

    The {{entity_type}} title has been updated to {{ issue.name}}

    + {% endif %} {% if data %} +
    + +
    +

    Updates

    +
    + +
    + + + + + + + +
    + {% if update.actor_detail.avatar_url %} {% else %} + + + + +
    {{ update.actor_detail.first_name.0 }}
    + {% endif %} +
    +

    {{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }}

    +
    +

    {{ update.activity_time }}

    +
    + {% if update.changes.target_date %} + + + + + + +
    +
    +

    Due Date:

    +
    +
    + {% if update.changes.target_date.new_value.0 %} +

    {{ update.changes.target_date.new_value.0 }}

    + {% else %} +

    {{ update.changes.target_date.old_value.0 }}

    + {% endif %} +
    + {% endif %} {% if update.changes.duplicate %} + + + + {% if update.changes.duplicate.new_value.0 %} + + {% endif %} {% if update.changes.duplicate.new_value.2 %} + + {% endif %} {% if update.changes.duplicate.old_value.0 %} + + {% endif %} {% if update.changes.duplicate.old_value.2 %} + + {% endif %} + +
    Duplicate: {% for duplicate in update.changes.duplicate.new_value|slice:":2" %} {{ duplicate }} {% endfor %} +{{ update.changes.duplicate.new_value|length|add:"-2" }} more {% for duplicate in update.changes.duplicate.old_value|slice:":2" %} {{ duplicate }} {% endfor %} +{{ update.changes.duplicate.old_value|length|add:"-2" }} more
    + {% endif %} {% if update.changes.assignees %} + + + + + +
    Assignee: {% if update.changes.assignees.new_value.0 %} {{update.changes.assignees.new_value.0}} {% endif %} {% if update.changes.assignees.new_value.1 %} +{{ update.changes.assignees.new_value|length|add:"-1"}} more {% endif %} {% if update.changes.assignees.old_value.0 %} {{update.changes.assignees.old_value.0}} {% endif %} {% if update.changes.assignees.old_value.1 %} +{{ update.changes.assignees.old_value|length|add:"-1"}} more {% endif %}
    + {% endif %} {% if update.changes.labels %} + + + + + +
    Labels: {% if update.changes.labels.new_value.0 %} {{update.changes.labels.new_value.0}} {% endif %} {% if update.changes.labels.new_value.1 %} +{{ update.changes.labels.new_value|length|add:"-1"}} more {% endif %} {% if update.changes.labels.old_value.0 %} {{update.changes.labels.old_value.0}} {% endif %} {% if update.changes.labels.old_value.1 %} +{{ update.changes.labels.old_value|length|add:"-1"}} more {% endif %}
    + {% endif %} {% if update.changes.state %} + + + + + {% if update.changes.state.old_value.0 == 'Backlog' or update.changes.state.old_value.0 == 'In Progress' or update.changes.state.old_value.0 == 'Done' or update.changes.state.old_value.0 == 'Cancelled' %} + + {% endif %} + + + {% if update.changes.state.new_value|last == 'Backlog' or update.changes.state.new_value|last == 'In Progress' or update.changes.state.new_value|last == 'Done' or update.changes.state.new_value|last == 'Cancelled' %} + + {% endif %} + + +
    +

    State:

    +
    +

    {{ update.changes.state.old_value.0 }}

    +
    +

    {{update.changes.state.new_value|last }}

    +
    + {% endif %} {% if update.changes.link %} + + + + + + +
    +

    Links:

    +
    + {% for link in update.changes.link.new_value %} {{ link }} {% endfor %} {% if update.changes.link.old_value|length > 0 %} {% if update.changes.link.old_value.0 != "None" %} +

    2 Links were removed

    + {% endif %} {% endif %} +
    + {% endif %} {% if update.changes.priority %} + + + + + + + + +
    +

    Priority:

    +
    +

    {{ update.changes.priority.old_value.0 }}

    +
    +

    {{ update.changes.priority.new_value|last }}

    +
    + {% endif %} {% if update.changes.blocking.new_value %} + + + + {% if update.changes.blocking.new_value.0 %} + + {% endif %} {% if update.changes.blocking.new_value.2 %} + + {% endif %} {% if update.changes.blocking.old_value.0 %} + + {% endif %} {% if update.changes.blocking.old_value.2 %} + + {% endif %} + +
    Blocking: {% for blocking in update.changes.blocking.new_value|slice:":2" %} {{ blocking }} {% endfor %} +{{ update.changes.blocking.new_value|length|add:"-2" }} more {% for blocking in update.changes.blocking.old_value|slice:":2" %} {{ blocking }} {% endfor %} +{{ update.changes.blocking.old_value|length|add:"-2" }} more
    + {% endif %} +
    +
    + {% endif %} {% endfor %} {% if comments.0 %} +
    + +

    Comments

    + {% for comment in comments %} + + + + + +
    + {% if comment.actor_detail.avatar_url %} {% else %} + + + + +
    {{ comment.actor_detail.first_name.0 }}
    + {% endif %} +
    + + + + + {% for actor_comment in comment.actor_comments.new_value %} + + + + {% endfor %} +
    +

    {{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }}

    +
    +
    +

    {{ actor_comment|safe }}

    +
    +
    +
    + {% endfor %} +
    + {% endif %} +
    + +
    View {{entity_type}}
    +
    +
    + + + + + +
    +
    + This email was sent to {{ receiver.email }}. If you'd rather not receive this kind of email, you can unsubscribe to the {{entity_type}} or manage your email preferences. + +
    +
    +
    + + \ No newline at end of file diff --git a/apps/api/templates/emails/notifications/project_addition.html b/apps/api/templates/emails/notifications/project_addition.html new file mode 100644 index 00000000..59c7e0e4 --- /dev/null +++ b/apps/api/templates/emails/notifications/project_addition.html @@ -0,0 +1,1591 @@ + + + + + + + + You are have been invited to a Plane project + + + + + + + + + + + + + + + diff --git a/apps/api/templates/emails/notifications/webhook-deactivate.html b/apps/api/templates/emails/notifications/webhook-deactivate.html new file mode 100644 index 00000000..272271f9 --- /dev/null +++ b/apps/api/templates/emails/notifications/webhook-deactivate.html @@ -0,0 +1,298 @@ + + + + + + + + {{ message }} + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/api/templates/emails/test_email.html b/apps/api/templates/emails/test_email.html new file mode 100644 index 00000000..4e4d3d94 --- /dev/null +++ b/apps/api/templates/emails/test_email.html @@ -0,0 +1,6 @@ + + +

    This is a test email sent to verify if email configuration is working as expected in your Plane instance.

    + +

    Regards,
    Team Plane

    + \ No newline at end of file diff --git a/apps/api/templates/emails/user/user_activation.html b/apps/api/templates/emails/user/user_activation.html new file mode 100644 index 00000000..a454d0a3 --- /dev/null +++ b/apps/api/templates/emails/user/user_activation.html @@ -0,0 +1,1570 @@ + + + + + + + + Your Plane account is now active + + + + + + + + + + + + + + + diff --git a/apps/api/templates/emails/user/user_deactivation.html b/apps/api/templates/emails/user/user_deactivation.html new file mode 100644 index 00000000..8a0c097a --- /dev/null +++ b/apps/api/templates/emails/user/user_deactivation.html @@ -0,0 +1,1571 @@ + + + + + + + + Your Plane account has been deactivated + + + + + + + + + + + + + + + diff --git a/apps/live/.env.example b/apps/live/.env.example new file mode 100644 index 00000000..5fc90d75 --- /dev/null +++ b/apps/live/.env.example @@ -0,0 +1,14 @@ +PORT=3100 +API_BASE_URL="http://localhost:8000" + +WEB_BASE_URL="http://localhost:3000" + +LIVE_BASE_URL="http://localhost:3100" +LIVE_BASE_PATH="/live" + +LIVE_SERVER_SECRET_KEY="secret-key" + +# If you prefer not to provide a Redis URL, you can set the REDIS_HOST and REDIS_PORT environment variables instead. +REDIS_PORT=6379 +REDIS_HOST=localhost +REDIS_URL="redis://localhost:6379/" diff --git a/apps/live/.eslintignore b/apps/live/.eslintignore new file mode 100644 index 00000000..08542169 --- /dev/null +++ b/apps/live/.eslintignore @@ -0,0 +1,4 @@ +.turbo/* +out/* +dist/* +public/* \ No newline at end of file diff --git a/apps/live/.eslintrc.cjs b/apps/live/.eslintrc.cjs new file mode 100644 index 00000000..0a4c3d9e --- /dev/null +++ b/apps/live/.eslintrc.cjs @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ["@plane/eslint-config/server.js"], +}; diff --git a/apps/live/.prettierignore b/apps/live/.prettierignore new file mode 100644 index 00000000..8f6f9062 --- /dev/null +++ b/apps/live/.prettierignore @@ -0,0 +1,6 @@ +.next +.turbo +out/ +dist/ +build/ +node_modules/ \ No newline at end of file diff --git a/apps/live/.prettierrc b/apps/live/.prettierrc new file mode 100644 index 00000000..87d988f1 --- /dev/null +++ b/apps/live/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/apps/live/Dockerfile.dev b/apps/live/Dockerfile.dev new file mode 100644 index 00000000..5e0f5372 --- /dev/null +++ b/apps/live/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM node:22-alpine + +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY . . +RUN corepack enable pnpm && pnpm add -g turbo +RUN pnpm install +EXPOSE 3003 + +ENV TURBO_TELEMETRY_DISABLED=1 + +VOLUME [ "/app/node_modules", "/app/live/node_modules"] + +CMD ["pnpm","dev", "--filter=live"] diff --git a/apps/live/Dockerfile.live b/apps/live/Dockerfile.live new file mode 100644 index 00000000..92fbee6a --- /dev/null +++ b/apps/live/Dockerfile.live @@ -0,0 +1,64 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-alpine AS base + +# Setup pnpm package manager with corepack and configure global bin directory for caching +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +# ***************************************************************************** +# STAGE 1: Prune the project +# ***************************************************************************** +FROM base AS builder +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk update +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app +ARG TURBO_VERSION=2.5.6 +RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION} +COPY . . +RUN turbo prune --scope=live --docker + +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** +# Add lockfile and package.json's of isolated subworkspace +FROM base AS installer +RUN apk update +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# First install dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN corepack enable pnpm +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store + +# Build the project and its dependencies +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store + +ENV TURBO_TELEMETRY_DISABLED=1 + +RUN pnpm turbo run build --filter=live + +# ***************************************************************************** +# STAGE 3: Run the project +# ***************************************************************************** + +FROM base AS runner +WORKDIR /app + +COPY --from=installer /app/packages ./packages +COPY --from=installer /app/apps/live/dist ./apps/live/dist +COPY --from=installer /app/apps/live/node_modules ./apps/live/node_modules +COPY --from=installer /app/node_modules ./node_modules + +ENV TURBO_TELEMETRY_DISABLED=1 + +EXPOSE 3000 + +CMD ["node", "apps/live/dist/start.js"] diff --git a/apps/live/package.json b/apps/live/package.json new file mode 100644 index 00000000..6f866e8a --- /dev/null +++ b/apps/live/package.json @@ -0,0 +1,63 @@ +{ + "name": "live", + "version": "1.1.0", + "license": "AGPL-3.0", + "description": "A realtime collaborative server powers Plane's rich text editor", + "main": "./dist/start.js", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --noEmit && tsdown", + "dev": "tsdown --watch --onSuccess \"node --env-file=.env dist/start.js\"", + "start": "node --env-file=.env dist/start.js", + "check:lint": "eslint . --max-warnings 10", + "check:types": "tsc --noEmit", + "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", + "fix:lint": "eslint . --fix", + "fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"", + "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist" + }, + "author": "Plane Software Inc.", + "dependencies": { + "@dotenvx/dotenvx": "^1.49.0", + "@hocuspocus/extension-database": "2.15.2", + "@hocuspocus/extension-logger": "2.15.2", + "@hocuspocus/extension-redis": "2.15.2", + "@hocuspocus/server": "2.15.2", + "@hocuspocus/transformer": "2.15.2", + "@plane/decorators": "workspace:*", + "@plane/editor": "workspace:*", + "@plane/logger": "workspace:*", + "@plane/types": "workspace:*", + "@sentry/node": "catalog:", + "@sentry/profiling-node": "catalog:", + "@tiptap/core": "catalog:", + "@tiptap/html": "catalog:", + "axios": "catalog:", + "compression": "1.8.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "express-ws": "^5.0.2", + "helmet": "^7.1.0", + "ioredis": "5.7.0", + "uuid": "catalog:", + "ws": "^8.18.3", + "y-prosemirror": "^1.3.7", + "y-protocols": "^1.0.6", + "yjs": "^13.6.20", + "zod": "^3.25.76" + }, + "devDependencies": { + "@plane/eslint-config": "workspace:*", + "@plane/typescript-config": "workspace:*", + "@types/compression": "1.8.1", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.23", + "@types/express-ws": "^3.0.5", + "@types/node": "catalog:", + "@types/ws": "^8.18.1", + "tsdown": "catalog:", + "typescript": "catalog:" + } +} diff --git a/apps/live/src/controllers/collaboration.controller.ts b/apps/live/src/controllers/collaboration.controller.ts new file mode 100644 index 00000000..59bfe7b0 --- /dev/null +++ b/apps/live/src/controllers/collaboration.controller.ts @@ -0,0 +1,33 @@ +import type { Hocuspocus } from "@hocuspocus/server"; +import type { Request } from "express"; +import type WebSocket from "ws"; +// plane imports +import { Controller, WebSocket as WSDecorator } from "@plane/decorators"; +import { logger } from "@plane/logger"; + +@Controller("/collaboration") +export class CollaborationController { + [key: string]: unknown; + private readonly hocusPocusServer: Hocuspocus; + + constructor(hocusPocusServer: Hocuspocus) { + this.hocusPocusServer = hocusPocusServer; + } + + @WSDecorator("/") + handleConnection(ws: WebSocket, req: Request) { + try { + // Initialize the connection with Hocuspocus + this.hocusPocusServer.handleConnection(ws, req); + + // Set up error handling for the connection + ws.on("error", (error: Error) => { + logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error); + ws.close(1011, "Internal server error"); + }); + } catch (error) { + logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error); + ws.close(1011, "Internal server error"); + } + } +} diff --git a/apps/live/src/controllers/document.controller.ts b/apps/live/src/controllers/document.controller.ts new file mode 100644 index 00000000..3b45c4e9 --- /dev/null +++ b/apps/live/src/controllers/document.controller.ts @@ -0,0 +1,63 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +// helpers +import { Controller, Post } from "@plane/decorators"; +import { convertHTMLDocumentToAllFormats } from "@plane/editor"; +// logger +import { logger } from "@plane/logger"; +import { type TConvertDocumentRequestBody } from "@/types"; + +// Define the schema with more robust validation +const convertDocumentSchema = z.object({ + description_html: z + .string() + .min(1, "HTML content cannot be empty") + .refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace") + .refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"), + variant: z.enum(["rich", "document"]), +}); + +@Controller("/convert-document") +export class DocumentController { + @Post("/") + async convertDocument(req: Request, res: Response) { + try { + // Validate request body + const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody); + const { description_html, variant } = validatedData; + + // Process document conversion + const { description, description_binary } = convertHTMLDocumentToAllFormats({ + document_html: description_html, + variant, + }); + + // Return successful response + res.status(200).json({ + description, + description_binary, + }); + } catch (error) { + if (error instanceof z.ZodError) { + const validationErrors = error.errors.map((err) => ({ + path: err.path.join("."), + message: err.message, + })); + logger.error("DOCUMENT_CONTROLLER: Validation error", { + validationErrors, + }); + return res.status(400).json({ + message: `Validation error`, + context: { + validationErrors, + }, + }); + } else { + logger.error("DOCUMENT_CONTROLLER: Internal server error", error); + return res.status(500).json({ + message: `Internal server error.`, + }); + } + } + } +} diff --git a/apps/live/src/controllers/health.controller.ts b/apps/live/src/controllers/health.controller.ts new file mode 100644 index 00000000..34026c04 --- /dev/null +++ b/apps/live/src/controllers/health.controller.ts @@ -0,0 +1,15 @@ +import type { Request, Response } from "express"; +import { Controller, Get } from "@plane/decorators"; +import { env } from "@/env"; + +@Controller("/health") +export class HealthController { + @Get("/") + async healthCheck(_req: Request, res: Response) { + res.status(200).json({ + status: "OK", + timestamp: new Date().toISOString(), + version: env.APP_VERSION, + }); + } +} diff --git a/apps/live/src/controllers/index.ts b/apps/live/src/controllers/index.ts new file mode 100644 index 00000000..3b45cb1e --- /dev/null +++ b/apps/live/src/controllers/index.ts @@ -0,0 +1,5 @@ +import { CollaborationController } from "./collaboration.controller"; +import { DocumentController } from "./document.controller"; +import { HealthController } from "./health.controller"; + +export const CONTROLLERS = [CollaborationController, DocumentController, HealthController]; diff --git a/apps/live/src/env.ts b/apps/live/src/env.ts new file mode 100644 index 00000000..3c1a91ec --- /dev/null +++ b/apps/live/src/env.ts @@ -0,0 +1,36 @@ +import * as dotenv from "@dotenvx/dotenvx"; +import { z } from "zod"; + +dotenv.config(); + +// Environment variable validation +const envSchema = z.object({ + APP_VERSION: z.string().default("1.0.0"), + HOSTNAME: z.string().optional(), + PORT: z.string().default("3000"), + API_BASE_URL: z.string().url("API_BASE_URL must be a valid URL"), + // CORS configuration + CORS_ALLOWED_ORIGINS: z.string().default(""), + // Live running location + LIVE_BASE_PATH: z.string().default("/live"), + // Compression options + COMPRESSION_LEVEL: z.string().default("6").transform(Number), + COMPRESSION_THRESHOLD: z.string().default("5000").transform(Number), + // secret + LIVE_SERVER_SECRET_KEY: z.string(), + // Redis configuration + REDIS_HOST: z.string().optional(), + REDIS_PORT: z.string().default("6379").transform(Number), + REDIS_URL: z.string().optional(), +}); + +const validateEnv = () => { + const result = envSchema.safeParse(process.env); + if (!result.success) { + console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4)); + process.exit(1); + } + return result.data; +}; + +export const env = validateEnv(); diff --git a/apps/live/src/extensions/database.ts b/apps/live/src/extensions/database.ts new file mode 100644 index 00000000..be7a3139 --- /dev/null +++ b/apps/live/src/extensions/database.ts @@ -0,0 +1,112 @@ +import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database"; +// utils +import { + getAllDocumentFormatsFromDocumentEditorBinaryData, + getBinaryDataFromDocumentEditorHTMLString, +} from "@plane/editor"; +// logger +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +// services +import { getPageService } from "@/services/page/handler"; +// type +import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types"; +import { ForceCloseReason, CloseCode } from "@/types/admin-commands"; +import { broadcastError } from "@/utils/broadcast-error"; +// force close utility +import { forceCloseDocumentAcrossServers } from "./force-close-handler"; + +const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => { + try { + const service = getPageService(context.documentType, context); + // fetch details + const response = await service.fetchDescriptionBinary(pageId); + const binaryData = new Uint8Array(response); + // if binary data is empty, convert HTML to binary data + if (binaryData.byteLength === 0) { + const pageDetails = await service.fetchDetails(pageId); + const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "

    "); + if (convertedBinaryData) { + return convertedBinaryData; + } + } + // return binary data + return binaryData; + } catch (error) { + const appError = new AppError(error, { context: { pageId } }); + logger.error("Error in fetching document", appError); + + // Broadcast error to frontend for user document types + await broadcastError(instance, pageId, "Unable to load the page. Please try refreshing.", "fetch", context); + + throw appError; + } +}; + +const storeDocument = async ({ + context, + state: pageBinaryData, + documentName: pageId, + instance, +}: StorePayloadWithContext) => { + try { + const service = getPageService(context.documentType, context); + // convert binary data to all formats + const { contentBinaryEncoded, contentHTML, contentJSON } = + getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData); + // create payload + const payload = { + description_binary: contentBinaryEncoded, + description_html: contentHTML, + description: contentJSON, + }; + await service.updateDescriptionBinary(pageId, payload); + } catch (error) { + const appError = new AppError(error, { context: { pageId } }); + logger.error("Error in updating document:", appError); + + // Check error types + const isContentTooLarge = appError.statusCode === 413; + + // Determine if we should disconnect and unload + const shouldDisconnect = isContentTooLarge; + + // Determine error message and code + let errorMessage: string; + let errorCode: "content_too_large" | "page_locked" | "page_archived" | undefined; + + if (isContentTooLarge) { + errorMessage = "Document is too large to save. Please reduce the content size."; + errorCode = "content_too_large"; + } else { + errorMessage = "Unable to save the page. Please try again."; + } + + // Broadcast error to frontend for user document types + await broadcastError(instance, pageId, errorMessage, "store", context, errorCode, shouldDisconnect); + + // If we should disconnect, close connections and unload document + if (shouldDisconnect) { + // Map error code to ForceCloseReason with proper types + const reason = + errorCode === "content_too_large" ? ForceCloseReason.DOCUMENT_TOO_LARGE : ForceCloseReason.CRITICAL_ERROR; + + const closeCode = errorCode === "content_too_large" ? CloseCode.DOCUMENT_TOO_LARGE : CloseCode.FORCE_CLOSE; + + // force close connections and unload document + await forceCloseDocumentAcrossServers(instance, pageId, reason, closeCode); + + // Don't throw after force close - document is already unloaded + // Throwing would cause hocuspocus's finally block to access the null document + return; + } + + throw appError; + } +}; + +export class Database extends HocuspocusDatabase { + constructor() { + super({ fetch: fetchDocument, store: storeDocument }); + } +} diff --git a/apps/live/src/extensions/force-close-handler.ts b/apps/live/src/extensions/force-close-handler.ts new file mode 100644 index 00000000..522d0909 --- /dev/null +++ b/apps/live/src/extensions/force-close-handler.ts @@ -0,0 +1,203 @@ +import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server"; +import { logger } from "@plane/logger"; +import { Redis } from "@/extensions/redis"; +import { + AdminCommand, + CloseCode, + ForceCloseReason, + getForceCloseMessage, + isForceCloseCommand, + type ClientForceCloseMessage, + type ForceCloseCommandData, +} from "@/types/admin-commands"; + +/** + * Extension to handle force close commands from other servers via Redis admin channel + */ +export class ForceCloseHandler implements Extension { + name = "ForceCloseHandler"; + priority = 999; + + async onConfigure({ instance }: onConfigurePayload) { + const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined; + + if (!redisExt) { + logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found"); + return; + } + + // Register handler for force_close admin command + redisExt.onAdminCommand(AdminCommand.FORCE_CLOSE, async (data) => { + // Type guard for safety + if (!isForceCloseCommand(data)) { + logger.error("[FORCE_CLOSE_HANDLER] Received invalid force close command"); + return; + } + + const { docId, reason, code } = data; + + const document = instance.documents.get(docId); + if (!document) { + // Not our document, ignore + return; + } + + const connectionCount = document.getConnectionsCount(); + logger.info(`[FORCE_CLOSE_HANDLER] Sending force close message to ${connectionCount} clients...`); + + // Step 1: Send force close message to ALL clients first + const forceCloseMessage: ClientForceCloseMessage = { + type: "force_close", + reason, + code, + message: getForceCloseMessage(reason), + timestamp: new Date().toISOString(), + }; + + let messageSent = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.sendStateless(JSON.stringify(forceCloseMessage)); + messageSent++; + } catch (error) { + logger.error("[FORCE_CLOSE_HANDLER] Failed to send message:", error); + } + }); + + logger.info(`[FORCE_CLOSE_HANDLER] Sent force close message to ${messageSent}/${connectionCount} clients`); + + // Wait a moment for messages to be delivered + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Step 2: Close connections + logger.info(`[FORCE_CLOSE_HANDLER] Closing ${connectionCount} connections...`); + + let closed = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.close({ code, reason }); + closed++; + } catch (error) { + logger.error("[FORCE_CLOSE_HANDLER] Failed to close connection:", error); + } + }); + + logger.info(`[FORCE_CLOSE_HANDLER] Closed ${closed}/${connectionCount} connections for ${docId}`); + }); + + logger.info("[FORCE_CLOSE_HANDLER] Registered with Redis extension"); + } +} + +/** + * Force close all connections to a document across all servers and unload it from memory. + * Used for critical errors or admin operations. + * + * @param instance - The Hocuspocus server instance + * @param pageId - The document ID to force close + * @param reason - The reason for force closing + * @param code - Optional WebSocket close code (defaults to FORCE_CLOSE) + * @returns Promise that resolves when document is closed and unloaded + * @throws Error if document not found in memory + */ +export const forceCloseDocumentAcrossServers = async ( + instance: Hocuspocus, + pageId: string, + reason: ForceCloseReason, + code: CloseCode = CloseCode.FORCE_CLOSE +): Promise => { + // STEP 1: VERIFY DOCUMENT EXISTS + const document = instance.documents.get(pageId); + + if (!document) { + logger.info(`[FORCE_CLOSE] Document ${pageId} already unloaded - no action needed`); + return; // Document already cleaned up, nothing to do + } + + const connectionsBefore = document.getConnectionsCount(); + logger.info(`[FORCE_CLOSE] Sending force close message to ${connectionsBefore} local clients...`); + + const forceCloseMessage: ClientForceCloseMessage = { + type: "force_close", + reason, + code, + message: getForceCloseMessage(reason), + timestamp: new Date().toISOString(), + }; + + let messageSentCount = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.sendStateless(JSON.stringify(forceCloseMessage)); + messageSentCount++; + } catch (error) { + logger.error("[FORCE_CLOSE] Failed to send message to client:", error); + } + }); + + logger.info(`[FORCE_CLOSE] Sent force close message to ${messageSentCount}/${connectionsBefore} clients`); + + // Wait a moment for messages to be delivered + await new Promise((resolve) => setTimeout(resolve, 50)); + + // STEP 3: CLOSE LOCAL CONNECTIONS + logger.info(`[FORCE_CLOSE] Closing ${connectionsBefore} local connections...`); + + let closedCount = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.close({ code, reason }); + closedCount++; + } catch (error) { + logger.error("[FORCE_CLOSE] Failed to close local connection:", error); + } + }); + + logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`); + + // STEP 4: BROADCAST TO OTHER SERVERS + const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined; + + if (redisExt) { + const commandData: ForceCloseCommandData = { + command: AdminCommand.FORCE_CLOSE, + docId: pageId, + reason, + code, + originServer: instance.configuration.name || "unknown", + timestamp: new Date().toISOString(), + }; + + const receivers = await redisExt.publishAdminCommand(commandData); + logger.info(`[FORCE_CLOSE] Notified ${receivers} other server(s)`); + } else { + logger.warn("[FORCE_CLOSE] Redis extension not found, cannot notify other servers"); + } + + // STEP 5: WAIT FOR OTHER SERVERS + const waitTime = 800; + logger.info(`[FORCE_CLOSE] Waiting ${waitTime}ms for other servers to close connections...`); + await new Promise((resolve) => setTimeout(resolve, waitTime)); + + // STEP 6: UNLOAD DOCUMENT after closing all the connections + logger.info(`[FORCE_CLOSE] Unloading document from memory...`); + + try { + await instance.unloadDocument(document); + logger.info(`[FORCE_CLOSE] Document unloaded successfully ✅`); + } catch (unloadError: unknown) { + logger.error("[FORCE_CLOSE] UNLOAD FAILED:", unloadError); + logger.error(` Error: ${unloadError instanceof Error ? unloadError.message : "unknown"}`); + } + + // STEP 7: VERIFY UNLOAD + const documentAfterUnload = instance.documents.get(pageId); + + if (documentAfterUnload) { + logger.error( + `❌ [FORCE_CLOSE] Document still in memory!, Document ID: ${pageId}, Connections: ${documentAfterUnload.getConnectionsCount()}` + ); + } else { + logger.info(`✅ [FORCE_CLOSE] COMPLETE, Document: ${pageId}, Status: Successfully closed and unloaded`); + } +}; diff --git a/apps/live/src/extensions/index.ts b/apps/live/src/extensions/index.ts new file mode 100644 index 00000000..e82b1fb6 --- /dev/null +++ b/apps/live/src/extensions/index.ts @@ -0,0 +1,5 @@ +import { Database } from "./database"; +import { Logger } from "./logger"; +import { Redis } from "./redis"; + +export const getExtensions = () => [new Logger(), new Database(), new Redis()]; diff --git a/apps/live/src/extensions/logger.ts b/apps/live/src/extensions/logger.ts new file mode 100644 index 00000000..34a4f6a4 --- /dev/null +++ b/apps/live/src/extensions/logger.ts @@ -0,0 +1,13 @@ +import { Logger as HocuspocusLogger } from "@hocuspocus/extension-logger"; +import { logger } from "@plane/logger"; + +export class Logger extends HocuspocusLogger { + constructor() { + super({ + onChange: false, + log: (message) => { + logger.info(message); + }, + }); + } +} diff --git a/apps/live/src/extensions/redis.ts b/apps/live/src/extensions/redis.ts new file mode 100644 index 00000000..66c728f2 --- /dev/null +++ b/apps/live/src/extensions/redis.ts @@ -0,0 +1,134 @@ +import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis"; +import { OutgoingMessage, type onConfigurePayload } from "@hocuspocus/server"; +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +import { redisManager } from "@/redis"; +import { AdminCommand } from "@/types/admin-commands"; +import type { AdminCommandData, AdminCommandHandler } from "@/types/admin-commands"; + +const getRedisClient = () => { + const redisClient = redisManager.getClient(); + if (!redisClient) { + throw new AppError("Redis client not initialized"); + } + return redisClient; +}; + +export class Redis extends HocuspocusRedis { + private adminHandlers = new Map(); + private readonly ADMIN_CHANNEL = "hocuspocus:admin"; + + constructor() { + super({ redis: getRedisClient() }); + } + + async onConfigure(payload: onConfigurePayload) { + await super.onConfigure(payload); + + // Subscribe to admin channel + await new Promise((resolve, reject) => { + this.sub.subscribe(this.ADMIN_CHANNEL, (error: Error) => { + if (error) { + logger.error(`[Redis] Failed to subscribe to admin channel:`, error); + reject(error); + } else { + logger.info(`[Redis] Subscribed to admin channel: ${this.ADMIN_CHANNEL}`); + resolve(); + } + }); + }); + + // Listen for admin messages + this.sub.on("message", this.handleAdminMessage); + logger.info(`[Redis] Attached admin message listener`); + } + + private handleAdminMessage = async (channel: string, message: string) => { + if (channel !== this.ADMIN_CHANNEL) return; + + try { + const data = JSON.parse(message) as AdminCommandData; + + // Validate command + if (!data.command || !Object.values(AdminCommand).includes(data.command as AdminCommand)) { + logger.warn(`[Redis] Invalid admin command received: ${data.command}`); + return; + } + + const handler = this.adminHandlers.get(data.command); + + if (handler) { + await handler(data); + } else { + logger.warn(`[Redis] No handler registered for admin command: ${data.command}`); + } + } catch (error) { + logger.error("[Redis] Error handling admin message:", error); + } + }; + + /** + * Register handler for an admin command + */ + public onAdminCommand( + command: AdminCommand, + handler: AdminCommandHandler + ) { + this.adminHandlers.set(command, handler as AdminCommandHandler); + logger.info(`[Redis] Registered admin command: ${command}`); + } + + /** + * Publish admin command to global channel + */ + public async publishAdminCommand(data: T): Promise { + // Validate command data + if (!data.command || !Object.values(AdminCommand).includes(data.command)) { + throw new AppError(`Invalid admin command: ${data.command}`); + } + + const message = JSON.stringify(data); + const receivers = await this.pub.publish(this.ADMIN_CHANNEL, message); + + logger.info(`[Redis] Published "${data.command}" command, received by ${receivers} server(s)`); + return receivers; + } + + async onDestroy() { + // Unsubscribe from admin channel + await new Promise((resolve) => { + this.sub.unsubscribe(this.ADMIN_CHANNEL, (error: Error) => { + if (error) { + logger.error(`[Redis] Error unsubscribing from admin channel:`, error); + } + resolve(); + }); + }); + + // Remove the message listener to prevent memory leaks + this.sub.removeListener("message", this.handleAdminMessage); + logger.info(`[Redis] Removed admin message listener`); + + await super.onDestroy(); + } + + /** + * Broadcast a message to a document across all servers via Redis. + * Uses empty identifier so ALL servers process the message. + */ + public async broadcastToDocument(documentName: string, payload: unknown): Promise { + const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload); + + const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload); + + const emptyPrefix = Buffer.concat([Buffer.from([0])]); + const channel = this["pubKey"](documentName); + const encodedMessage = Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())]); + + const result = await this.pub.publishBuffer(channel, encodedMessage); + + logger.info(`REDIS_EXTENSION: Published to ${documentName}, ${result} subscribers`); + + return result; + } +} diff --git a/apps/live/src/hocuspocus.ts b/apps/live/src/hocuspocus.ts new file mode 100644 index 00000000..1b3b07a7 --- /dev/null +++ b/apps/live/src/hocuspocus.ts @@ -0,0 +1,63 @@ +import { Hocuspocus } from "@hocuspocus/server"; +import { v4 as uuidv4 } from "uuid"; +// env +import { env } from "@/env"; +// extensions +import { getExtensions } from "@/extensions"; +// lib +import { onAuthenticate } from "@/lib/auth"; +import { onStateless } from "@/lib/stateless"; + +export class HocusPocusServerManager { + private static instance: HocusPocusServerManager | null = null; + private server: Hocuspocus | null = null; + // server options + private serverName = env.HOSTNAME || uuidv4(); + + private constructor() { + // Private constructor to prevent direct instantiation + } + + /** + * Get the singleton instance of HocusPocusServerManager + */ + public static getInstance(): HocusPocusServerManager { + if (!HocusPocusServerManager.instance) { + HocusPocusServerManager.instance = new HocusPocusServerManager(); + } + return HocusPocusServerManager.instance; + } + + /** + * Initialize and configure the HocusPocus server + */ + public async initialize(): Promise { + if (this.server) { + return this.server; + } + + this.server = new Hocuspocus({ + name: this.serverName, + onAuthenticate, + onStateless, + extensions: getExtensions(), + debounce: 10000, + }); + + return this.server; + } + + /** + * Get the configured server instance + */ + public getServer(): Hocuspocus | null { + return this.server; + } + + /** + * Reset the singleton instance (useful for testing) + */ + public static resetInstance(): void { + HocusPocusServerManager.instance = null; + } +} diff --git a/apps/live/src/instrument.ts b/apps/live/src/instrument.ts new file mode 100644 index 00000000..a49016eb --- /dev/null +++ b/apps/live/src/instrument.ts @@ -0,0 +1,15 @@ +import * as Sentry from "@sentry/node"; +import { nodeProfilingIntegration } from "@sentry/profiling-node"; + +export const setupSentry = () => { + if (process.env.SENTRY_DSN) { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + integrations: [Sentry.httpIntegration(), Sentry.expressIntegration(), nodeProfilingIntegration()], + tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE ? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE) : 0.5, + environment: process.env.SENTRY_ENVIRONMENT || "development", + release: process.env.APP_VERSION || "v1.0.0", + sendDefaultPii: true, + }); + } +}; diff --git a/apps/live/src/lib/auth-middleware.ts b/apps/live/src/lib/auth-middleware.ts new file mode 100644 index 00000000..8cdfc1b3 --- /dev/null +++ b/apps/live/src/lib/auth-middleware.ts @@ -0,0 +1,50 @@ +import type { Request, Response, NextFunction } from "express"; +import { logger } from "@plane/logger"; +import { env } from "@/env"; + +/** + * Express middleware to verify secret key authentication for protected endpoints + * + * Checks for secret key in headers: + * - x-admin-secret-key (preferred for admin endpoints) + * - live-server-secret-key (for backward compatibility) + * + * @param req - Express request object + * @param res - Express response object + * @param next - Express next function + * + * @example + * ```typescript + * import { Middleware } from "@plane/decorators"; + * import { requireSecretKey } from "@/lib/auth-middleware"; + * + * @Get("/protected") + * @Middleware(requireSecretKey) + * async protectedEndpoint(req: Request, res: Response) { + * // This will only execute if secret key is valid + * } + * ``` + */ +// TODO - Move to hmac +export const requireSecretKey = (req: Request, res: Response, next: NextFunction): void => { + const secretKey = req.headers["live-server-secret-key"]; + + if (!secretKey || secretKey !== env.LIVE_SERVER_SECRET_KEY) { + logger.warn(` + ⚠️ [AUTH] Unauthorized access attempt + Endpoint: ${req.path} + Method: ${req.method} + IP: ${req.ip} + User-Agent: ${req.headers["user-agent"]} + `); + + res.status(401).json({ + error: "Unauthorized", + status: 401, + }); + return; + } + + // Secret key is valid, proceed to the route handler + next(); +}; diff --git a/apps/live/src/lib/auth.ts b/apps/live/src/lib/auth.ts new file mode 100644 index 00000000..a1e82314 --- /dev/null +++ b/apps/live/src/lib/auth.ts @@ -0,0 +1,91 @@ +// plane imports +import type { IncomingHttpHeaders } from "http"; +import type { TUserDetails } from "@plane/editor"; +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +// services +import { UserService } from "@/services/user.service"; +// types +import type { HocusPocusServerContext, TDocumentTypes } from "@/types"; + +/** + * Authenticate the user + * @param requestHeaders - The request headers + * @param context - The context + * @param token - The token + * @returns The authenticated user + */ +export const onAuthenticate = async ({ + requestHeaders, + requestParameters, + context, + token, +}: { + requestHeaders: IncomingHttpHeaders; + context: HocusPocusServerContext; + requestParameters: URLSearchParams; + token: string; +}) => { + let cookie: string | undefined = undefined; + let userId: string | undefined = undefined; + + // 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) { + const appError = new AppError(error, { + context: { operation: "onAuthenticate" }, + }); + logger.error("Token parsing failed, using request headers", appError); + } finally { + // If cookie is still not found, fallback to request headers + if (!cookie) { + cookie = requestHeaders.cookie?.toString(); + } + } + + if (!cookie || !userId) { + const appError = new AppError("Credentials not provided", { code: "AUTH_MISSING_CREDENTIALS" }); + logger.error("Credentials not provided", appError); + throw appError; + } + + // set cookie in context, so it can be used throughout the ws connection + context.cookie = cookie ?? requestParameters.get("cookie") ?? ""; + context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes; + context.projectId = requestParameters.get("projectId"); + context.userId = userId; + context.workspaceSlug = requestParameters.get("workspaceSlug"); + + return await handleAuthentication({ + cookie: context.cookie, + userId: context.userId, + }); +}; + +export const handleAuthentication = async ({ cookie, userId }: { cookie: string; userId: string }) => { + // fetch current user info + try { + const userService = new UserService(); + const user = await userService.currentUser(cookie); + if (user.id !== userId) { + throw new AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" }); + } + + return { + user: { + id: user.id, + name: user.display_name, + }, + }; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "handleAuthentication" }, + }); + logger.error("Authentication failed", appError); + throw new AppError("Authentication unsuccessful", { code: appError.code }); + } +}; diff --git a/apps/live/src/lib/errors.ts b/apps/live/src/lib/errors.ts new file mode 100644 index 00000000..480d4317 --- /dev/null +++ b/apps/live/src/lib/errors.ts @@ -0,0 +1,73 @@ +import { AxiosError } from "axios"; + +/** + * Application error class that sanitizes and standardizes errors across the app. + * Extracts only essential information from AxiosError to prevent massive log bloat + * and sensitive data leaks (cookies, tokens, etc). + * + * Usage: + * new AppError("Simple error message") + * new AppError("Custom error", { code: "MY_CODE", statusCode: 400 }) + * new AppError(axiosError) // Auto-extracts essential info + * new AppError(anyError) // Works with any error type + */ +export class AppError extends Error { + statusCode?: number; + method?: string; + url?: string; + code?: string; + context?: Record; + + constructor(messageOrError: string | unknown, data?: Partial>) { + // Handle error objects - extract essential info + const error = messageOrError; + + // Already AppError - return immediately for performance (no need to re-process) + if (error instanceof AppError) { + return error; + } + + // Handle string message (simple case like regular Error) + if (typeof messageOrError === "string") { + super(messageOrError); + this.name = "AppError"; + if (data) { + Object.assign(this, data); + } + return; + } + + // AxiosError - extract ONLY essential info (no config, no headers, no cookies) + if (error && typeof error === "object" && "isAxiosError" in error) { + const axiosError = error as AxiosError; + const responseData = axiosError.response?.data as any; + super(responseData?.message || axiosError.message); + this.name = "AppError"; + this.statusCode = axiosError.response?.status; + this.method = axiosError.config?.method?.toUpperCase(); + this.url = axiosError.config?.url; + this.code = axiosError.code; + return; + } + + // DOMException (AbortError from cancelled requests) + if (error instanceof DOMException && error.name === "AbortError") { + super(error.message); + this.name = "AppError"; + this.code = "ABORT_ERROR"; + return; + } + + // Standard Error objects + if (error instanceof Error) { + super(error.message); + this.name = "AppError"; + this.code = error.name; + return; + } + + // Unknown error types - safe fallback + super("Unknown error occurred"); + this.name = "AppError"; + } +} diff --git a/apps/live/src/lib/stateless.ts b/apps/live/src/lib/stateless.ts new file mode 100644 index 00000000..2f74f0e9 --- /dev/null +++ b/apps/live/src/lib/stateless.ts @@ -0,0 +1,13 @@ +import type { onStatelessPayload } from "@hocuspocus/server"; +import { DocumentCollaborativeEvents, type TDocumentEventsServer } from "@plane/editor/lib"; + +/** + * Broadcast the client event to all the clients so that they can update their state + * @param param0 + */ +export const onStateless = async ({ payload, document }: onStatelessPayload) => { + const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client; + if (response) { + document.broadcastStateless(response); + } +}; diff --git a/apps/live/src/redis.ts b/apps/live/src/redis.ts new file mode 100644 index 00000000..aac0eb71 --- /dev/null +++ b/apps/live/src/redis.ts @@ -0,0 +1,214 @@ +import Redis from "ioredis"; +import { logger } from "@plane/logger"; +import { env } from "./env"; + +export class RedisManager { + private static instance: RedisManager; + private redisClient: Redis | null = null; + private isConnected: boolean = false; + private connectionPromise: Promise | null = null; + + private constructor() {} + + public static getInstance(): RedisManager { + if (!RedisManager.instance) { + RedisManager.instance = new RedisManager(); + } + return RedisManager.instance; + } + + public async initialize(): Promise { + if (this.redisClient && this.isConnected) { + logger.info("REDIS_MANAGER: client already initialized and connected"); + return; + } + + if (this.connectionPromise) { + logger.info("REDIS_MANAGER: Redis connection already in progress, waiting..."); + await this.connectionPromise; + return; + } + + this.connectionPromise = this.connect(); + await this.connectionPromise; + } + + private getRedisUrl(): string { + const redisUrl = env.REDIS_URL; + const redisHost = env.REDIS_HOST; + const redisPort = env.REDIS_PORT; + + if (redisUrl) { + return redisUrl; + } + + if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) { + return `redis://${redisHost}:${redisPort}`; + } + + return ""; + } + + private async connect(): Promise { + try { + const redisUrl = this.getRedisUrl(); + + if (!redisUrl) { + logger.warn("REDIS_MANAGER: No Redis URL provided, Redis functionality will be disabled"); + this.isConnected = false; + return; + } + + // Configuration optimized for BOTH regular operations AND pub/sub + // HocuspocusRedis uses .duplicate() which inherits these settings + this.redisClient = new Redis(redisUrl, { + lazyConnect: false, // Connect immediately for reliability (duplicates inherit this) + keepAlive: 30000, + connectTimeout: 10000, + maxRetriesPerRequest: 3, + enableOfflineQueue: true, // Keep commands queued during reconnection + retryStrategy: (times: number) => { + // Exponential backoff with max 2 seconds + const delay = Math.min(times * 50, 2000); + logger.info(`REDIS_MANAGER: Reconnection attempt ${times}, delay: ${delay}ms`); + return delay; + }, + }); + + // Set up event listeners + this.redisClient.on("connect", () => { + logger.info("REDIS_MANAGER: Redis client connected"); + this.isConnected = true; + }); + + this.redisClient.on("ready", () => { + logger.info("REDIS_MANAGER: Redis client ready"); + this.isConnected = true; + }); + + this.redisClient.on("error", (error) => { + logger.error("REDIS_MANAGER: Redis client error:", error); + this.isConnected = false; + }); + + this.redisClient.on("close", () => { + logger.warn("REDIS_MANAGER: Redis client connection closed"); + this.isConnected = false; + }); + + this.redisClient.on("reconnecting", () => { + logger.info("REDIS_MANAGER: Redis client reconnecting..."); + this.isConnected = false; + }); + + await this.redisClient.ping(); + logger.info("REDIS_MANAGER: Redis connection test successful"); + } catch (error) { + logger.error("REDIS_MANAGER: Failed to initialize Redis client:", error); + this.isConnected = false; + throw error; + } finally { + this.connectionPromise = null; + } + } + + public getClient(): Redis | null { + if (!this.redisClient || !this.isConnected) { + logger.warn("REDIS_MANAGER: Redis client not available or not connected"); + return null; + } + return this.redisClient; + } + + public isClientConnected(): boolean { + return this.isConnected && this.redisClient !== null; + } + + public async disconnect(): Promise { + if (this.redisClient) { + try { + await this.redisClient.quit(); + logger.info("REDIS_MANAGER: Redis client disconnected gracefully"); + } catch (error) { + logger.error("REDIS_MANAGER: Error disconnecting Redis client:", error); + // Force disconnect if quit fails + this.redisClient.disconnect(); + } finally { + this.redisClient = null; + this.isConnected = false; + } + } + } + + // Convenience methods for common Redis operations + public async set(key: string, value: string, ttl?: number): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + if (ttl) { + await client.setex(key, ttl, value); + } else { + await client.set(key, value); + } + return true; + } catch (error) { + logger.error(`REDIS_MANAGER: Error setting Redis key ${key}:`, error); + return false; + } + } + + public async get(key: string): Promise { + const client = this.getClient(); + if (!client) return null; + + try { + return await client.get(key); + } catch (error) { + logger.error(`REDIS_MANAGER: Error getting Redis key ${key}:`, error); + return null; + } + } + + public async del(key: string): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + await client.del(key); + return true; + } catch (error) { + logger.error(`REDIS_MANAGER: Error deleting Redis key ${key}:`, error); + return false; + } + } + + public async exists(key: string): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + const result = await client.exists(key); + return result === 1; + } catch (error) { + logger.error(`REDIS_MANAGER: Error checking Redis key ${key}:`, error); + return false; + } + } + + public async expire(key: string, ttl: number): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + const result = await client.expire(key, ttl); + return result === 1; + } catch (error) { + logger.error(`REDIS_MANAGER: Error setting expiry for Redis key ${key}:`, error); + return false; + } + } +} + +// Export a default instance for convenience +export const redisManager = RedisManager.getInstance(); diff --git a/apps/live/src/server.ts b/apps/live/src/server.ts new file mode 100644 index 00000000..1535844f --- /dev/null +++ b/apps/live/src/server.ts @@ -0,0 +1,121 @@ +import { Server as HttpServer } from "http"; +import { type Hocuspocus } from "@hocuspocus/server"; +import compression from "compression"; +import cors from "cors"; +import express, { Express, Request, Response, Router } from "express"; +import expressWs from "express-ws"; +import helmet from "helmet"; +// plane imports +import { registerController } from "@plane/decorators"; +import { logger, loggerMiddleware } from "@plane/logger"; +// controllers +import { CONTROLLERS } from "@/controllers"; +// env +import { env } from "@/env"; +// hocuspocus server +import { HocusPocusServerManager } from "@/hocuspocus"; +// redis +import { redisManager } from "@/redis"; + +export class Server { + private app: Express; + private router: Router; + private hocuspocusServer: Hocuspocus | undefined; + private httpServer: HttpServer | undefined; + + constructor() { + this.app = express(); + expressWs(this.app); + this.setupMiddleware(); + this.router = express.Router(); + this.app.set("port", env.PORT || 3000); + this.app.use(env.LIVE_BASE_PATH, this.router); + } + + public async initialize(): Promise { + try { + await redisManager.initialize(); + logger.info("SERVER: Redis setup completed"); + const manager = HocusPocusServerManager.getInstance(); + this.hocuspocusServer = await manager.initialize(); + logger.info("SERVER: HocusPocus setup completed"); + this.setupRoutes(this.hocuspocusServer); + this.setupNotFoundHandler(); + } catch (error) { + logger.error("SERVER: Failed to initialize live server dependencies:", error); + throw error; + } + } + + private setupMiddleware() { + // Security middleware + this.app.use(helmet()); + // Middleware for response compression + this.app.use(compression({ level: env.COMPRESSION_LEVEL, threshold: env.COMPRESSION_THRESHOLD })); + // Logging middleware + this.app.use(loggerMiddleware); + // Body parsing middleware + this.app.use(express.json()); + this.app.use(express.urlencoded({ extended: true })); + // cors middleware + this.setupCors(); + } + + private setupCors() { + const allowedOrigins = env.CORS_ALLOWED_ORIGINS.split(",").map((s) => s.trim()); + this.app.use( + cors({ + origin: allowedOrigins.length > 0 ? allowedOrigins : false, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "x-api-key"], + }) + ); + } + + private setupNotFoundHandler() { + this.app.use((_req: Request, res: Response) => { + res.status(404).json({ + message: "Not Found", + }); + }); + } + + private setupRoutes(hocuspocusServer: Hocuspocus) { + CONTROLLERS.forEach((controller) => registerController(this.router, controller, [hocuspocusServer])); + } + + public listen() { + this.httpServer = this.app + .listen(this.app.get("port"), () => { + logger.info(`SERVER: Express server has started at port ${this.app.get("port")}`); + }) + .on("error", (err) => { + logger.error("SERVER: Failed to start server:", err); + throw err; + }); + } + + public async destroy() { + if (this.hocuspocusServer) { + this.hocuspocusServer.closeConnections(); + logger.info("SERVER: HocusPocus connections closed gracefully."); + } + + await redisManager.disconnect(); + logger.info("SERVER: Redis connection closed gracefully."); + + if (this.httpServer) { + await new Promise((resolve, reject) => { + this.httpServer!.close((err) => { + if (err) { + reject(err); + } else { + logger.info("SERVER: Express server closed gracefully."); + resolve(); + } + }); + }); + } + } +} diff --git a/apps/live/src/services/api.service.ts b/apps/live/src/services/api.service.ts new file mode 100644 index 00000000..8c2cb2e3 --- /dev/null +++ b/apps/live/src/services/api.service.ts @@ -0,0 +1,63 @@ +import axios, { AxiosInstance } from "axios"; +import { env } from "@/env"; +import { AppError } from "@/lib/errors"; + +export abstract class APIService { + protected baseURL: string; + private axiosInstance: AxiosInstance; + private header: Record = {}; + + constructor(baseURL?: string) { + this.baseURL = baseURL || env.API_BASE_URL; + this.axiosInstance = axios.create({ + baseURL: this.baseURL, + withCredentials: true, + timeout: 20000, + }); + this.setupInterceptors(); + } + + private setupInterceptors() { + this.axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + return Promise.reject(new AppError(error)); + } + ); + } + + setHeader(key: string, value: string) { + this.header[key] = value; + } + + getHeader() { + return this.header; + } + + get(url: string, params = {}, config = {}) { + return this.axiosInstance.get(url, { + ...params, + ...config, + }); + } + + post(url: string, data = {}, config = {}) { + return this.axiosInstance.post(url, data, config); + } + + put(url: string, data = {}, config = {}) { + return this.axiosInstance.put(url, data, config); + } + + patch(url: string, data = {}, config = {}) { + return this.axiosInstance.patch(url, data, config); + } + + delete(url: string, data?: Record | null | string, config = {}) { + return this.axiosInstance.delete(url, { data, ...config }); + } + + request(config = {}) { + return this.axiosInstance(config); + } +} diff --git a/apps/live/src/services/page/core.service.ts b/apps/live/src/services/page/core.service.ts new file mode 100644 index 00000000..ca4d5d28 --- /dev/null +++ b/apps/live/src/services/page/core.service.ts @@ -0,0 +1,119 @@ +import { logger } from "@plane/logger"; +import { TPage } from "@plane/types"; +// services +import { AppError } from "@/lib/errors"; +import { APIService } from "../api.service"; + +export type TPageDescriptionPayload = { + description_binary: string; + description_html: string; + description: object; +}; + +export abstract class PageCoreService extends APIService { + protected abstract basePath: string; + + constructor() { + super(); + } + + async fetchDetails(pageId: string): Promise { + return this.get(`${this.basePath}/pages/${pageId}/`, { + headers: this.getHeader(), + }) + .then((response) => response?.data) + .catch((error) => { + const appError = new AppError(error, { + context: { operation: "fetchDetails", pageId }, + }); + logger.error("Failed to fetch page details", appError); + throw appError; + }); + } + + async fetchDescriptionBinary(pageId: string): Promise { + return this.get(`${this.basePath}/pages/${pageId}/description/`, { + headers: { + ...this.getHeader(), + "Content-Type": "application/octet-stream", + }, + responseType: "arraybuffer", + }) + .then((response) => response?.data) + .catch((error) => { + const appError = new AppError(error, { + context: { operation: "fetchDescriptionBinary", pageId }, + }); + logger.error("Failed to fetch page description binary", appError); + throw appError; + }); + } + + /** + * Updates the title of a page + */ + async updatePageProperties( + pageId: string, + params: { data: Partial; abortSignal?: AbortSignal } + ): Promise { + const { data, abortSignal } = params; + + // Early abort check + if (abortSignal?.aborted) { + throw new AppError(new DOMException("Aborted", "AbortError")); + } + + // Create an abort listener that will reject the pending promise + let abortListener: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + if (abortSignal) { + abortListener = () => { + reject(new AppError(new DOMException("Aborted", "AbortError"))); + }; + abortSignal.addEventListener("abort", abortListener); + } + }); + + try { + return await Promise.race([ + this.patch(`${this.basePath}/pages/${pageId}/`, data, { + headers: this.getHeader(), + signal: abortSignal, + }) + .then((response) => response?.data) + .catch((error) => { + const appError = new AppError(error, { + context: { operation: "updatePageProperties", pageId }, + }); + + if (appError.code === "ABORT_ERROR") { + throw appError; + } + + logger.error("Failed to update page properties", appError); + throw appError; + }), + abortPromise, + ]); + } finally { + // Clean up abort listener + if (abortSignal && abortListener) { + abortSignal.removeEventListener("abort", abortListener); + } + } + } + + async updateDescriptionBinary(pageId: string, data: TPageDescriptionPayload): Promise { + return this.patch(`${this.basePath}/pages/${pageId}/description/`, data, { + headers: this.getHeader(), + }) + .then((response) => response?.data) + .catch((error) => { + const appError = new AppError(error, { + context: { operation: "updateDescriptionBinary", pageId }, + }); + logger.error("Failed to update page description binary", appError); + throw appError; + }); + } +} diff --git a/apps/live/src/services/page/extended.service.ts b/apps/live/src/services/page/extended.service.ts new file mode 100644 index 00000000..29ef316d --- /dev/null +++ b/apps/live/src/services/page/extended.service.ts @@ -0,0 +1,12 @@ +import { PageCoreService } from "./core.service"; + +/** + * This is the extended service for the page service. + * It extends the core service and adds additional functionality. + * Implementation for this is found in the enterprise repository. + */ +export abstract class PageService extends PageCoreService { + constructor() { + super(); + } +} diff --git a/apps/live/src/services/page/handler.ts b/apps/live/src/services/page/handler.ts new file mode 100644 index 00000000..9b2f5ada --- /dev/null +++ b/apps/live/src/services/page/handler.ts @@ -0,0 +1,16 @@ +import { AppError } from "@/lib/errors"; +import type { HocusPocusServerContext, TDocumentTypes } from "@/types"; +// services +import { ProjectPageService } from "./project-page.service"; + +export const getPageService = (documentType: TDocumentTypes, context: HocusPocusServerContext) => { + if (documentType === "project_page") { + return new ProjectPageService({ + workspaceSlug: context.workspaceSlug, + projectId: context.projectId, + cookie: context.cookie, + }); + } + + throw new AppError(`Invalid document type ${documentType} provided.`); +}; diff --git a/apps/live/src/services/page/project-page.service.ts b/apps/live/src/services/page/project-page.service.ts new file mode 100644 index 00000000..89a11562 --- /dev/null +++ b/apps/live/src/services/page/project-page.service.ts @@ -0,0 +1,25 @@ +import { AppError } from "@/lib/errors"; +import { PageService } from "./extended.service"; + +interface ProjectPageServiceParams { + workspaceSlug: string | null; + projectId: string | null; + cookie: string | null; + [key: string]: unknown; +} + +export class ProjectPageService extends PageService { + protected basePath: string; + + constructor(params: ProjectPageServiceParams) { + super(); + const { workspaceSlug, projectId } = params; + if (!workspaceSlug || !projectId) throw new AppError("Missing required fields."); + // validate cookie + if (!params.cookie) throw new AppError("Cookie is required."); + // set cookie + this.setHeader("Cookie", params.cookie); + // set base path + this.basePath = `/api/workspaces/${workspaceSlug}/projects/${projectId}`; + } +} diff --git a/apps/live/src/services/user.service.ts b/apps/live/src/services/user.service.ts new file mode 100644 index 00000000..272d7543 --- /dev/null +++ b/apps/live/src/services/user.service.ts @@ -0,0 +1,34 @@ +// types +import { logger } from "@plane/logger"; +import type { IUser } from "@plane/types"; +// services +import { AppError } from "@/lib/errors"; +import { APIService } from "@/services/api.service"; + +export class UserService extends APIService { + constructor() { + super(); + } + + currentUserConfig() { + return { + url: `${this.baseURL}/api/users/me/`, + }; + } + + async currentUser(cookie: string): Promise { + return this.get("/api/users/me/", { + headers: { + Cookie: cookie, + }, + }) + .then((response) => response?.data) + .catch((error) => { + const appError = new AppError(error, { + context: { operation: "currentUser" }, + }); + logger.error("Failed to fetch current user", appError); + throw appError; + }); + } +} diff --git a/apps/live/src/start.ts b/apps/live/src/start.ts new file mode 100644 index 00000000..7929b9b9 --- /dev/null +++ b/apps/live/src/start.ts @@ -0,0 +1,61 @@ +// eslint-disable-next-line import/order +import { setupSentry } from "./instrument"; +setupSentry(); + +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +import { Server } from "./server"; + +let server: Server; + +async function startServer() { + server = new Server(); + try { + await server.initialize(); + server.listen(); + } catch (error) { + logger.error("Failed to start server:", error); + process.exit(1); + } +} + +startServer(); + +// Handle process signals +process.on("SIGTERM", async () => { + logger.info("Received SIGTERM signal. Initiating graceful shutdown..."); + try { + if (server) { + await server.destroy(); + } + logger.info("Server shut down gracefully"); + } catch (error) { + logger.error("Error during graceful shutdown:", error); + process.exit(1); + } + process.exit(0); +}); + +process.on("SIGINT", async () => { + logger.info("Received SIGINT signal. Killing node process..."); + try { + if (server) { + await server.destroy(); + } + logger.info("Server shut down gracefully"); + } catch (error) { + logger.error("Error during graceful shutdown:", error); + process.exit(1); + } + process.exit(1); +}); + +process.on("unhandledRejection", (err: Error) => { + const error = new AppError(err); + logger.error(`[UNHANDLED_REJECTION]`, error); +}); + +process.on("uncaughtException", (err: Error) => { + const error = new AppError(err); + logger.error(`[UNCAUGHT_EXCEPTION]`, error); +}); diff --git a/apps/live/src/types/admin-commands.ts b/apps/live/src/types/admin-commands.ts new file mode 100644 index 00000000..bd8e5cd5 --- /dev/null +++ b/apps/live/src/types/admin-commands.ts @@ -0,0 +1,143 @@ +/** + * Type-safe admin commands for server-to-server communication + */ + +/** + * Force close error codes - reasons why a document is being force closed + */ +export enum ForceCloseReason { + CRITICAL_ERROR = "critical_error", + MEMORY_LEAK = "memory_leak", + DOCUMENT_TOO_LARGE = "document_too_large", + ADMIN_REQUEST = "admin_request", + SERVER_SHUTDOWN = "server_shutdown", + SECURITY_VIOLATION = "security_violation", + CORRUPTION_DETECTED = "corruption_detected", +} + +/** + * WebSocket close codes + * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code + */ +export enum CloseCode { + /** Normal closure; the connection successfully completed */ + NORMAL = 1000, + /** The endpoint is going away (server shutdown or browser navigating away) */ + GOING_AWAY = 1001, + /** Protocol error */ + PROTOCOL_ERROR = 1002, + /** Unsupported data */ + UNSUPPORTED_DATA = 1003, + /** Reserved (no status code was present) */ + NO_STATUS = 1005, + /** Abnormal closure */ + ABNORMAL = 1006, + /** Invalid frame payload data */ + INVALID_DATA = 1007, + /** Policy violation */ + POLICY_VIOLATION = 1008, + /** Message too big */ + MESSAGE_TOO_BIG = 1009, + /** Client expected extension not negotiated */ + MANDATORY_EXTENSION = 1010, + /** Server encountered unexpected condition */ + INTERNAL_ERROR = 1011, + /** Custom: Force close requested */ + FORCE_CLOSE = 4000, + /** Custom: Document too large */ + DOCUMENT_TOO_LARGE = 4001, + /** Custom: Memory pressure */ + MEMORY_PRESSURE = 4002, + /** Custom: Security violation */ + SECURITY_VIOLATION = 4003, +} + +/** + * Admin command types + */ +export enum AdminCommand { + FORCE_CLOSE = "force_close", + HEALTH_CHECK = "health_check", + RESTART_DOCUMENT = "restart_document", +} + +/** + * Force close command data structure + */ +export interface ForceCloseCommandData { + command: AdminCommand.FORCE_CLOSE; + docId: string; + reason: ForceCloseReason; + code: CloseCode; + originServer: string; + timestamp?: string; +} + +/** + * Health check command data structure + */ +export interface HealthCheckCommandData { + command: AdminCommand.HEALTH_CHECK; + originServer: string; + timestamp: string; +} + +/** + * Union type for all admin commands + */ +export type AdminCommandData = ForceCloseCommandData | HealthCheckCommandData; + +/** + * Client force close message structure (sent to clients via sendStateless) + */ +export interface ClientForceCloseMessage { + type: "force_close"; + reason: ForceCloseReason; + code: CloseCode; + message?: string; + timestamp?: string; +} + +/** + * Admin command handler function type + */ +export type AdminCommandHandler = (data: T) => Promise | void; + +/** + * Type guard to check if data is a ForceCloseCommandData + */ +export function isForceCloseCommand(data: AdminCommandData): data is ForceCloseCommandData { + return data.command === AdminCommand.FORCE_CLOSE; +} + +/** + * Type guard to check if data is a HealthCheckCommandData + */ +export function isHealthCheckCommand(data: AdminCommandData): data is HealthCheckCommandData { + return data.command === AdminCommand.HEALTH_CHECK; +} + +/** + * Validate force close reason + */ +export function isValidForceCloseReason(reason: string): reason is ForceCloseReason { + return Object.values(ForceCloseReason).includes(reason as ForceCloseReason); +} + +/** + * Get human-readable message for force close reason + */ +export function getForceCloseMessage(reason: ForceCloseReason): string { + const messages: Record = { + [ForceCloseReason.CRITICAL_ERROR]: "A critical error occurred. Please refresh the page.", + [ForceCloseReason.MEMORY_LEAK]: "Memory limit exceeded. Please refresh the page.", + [ForceCloseReason.DOCUMENT_TOO_LARGE]: + "Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.", + [ForceCloseReason.ADMIN_REQUEST]: "Connection closed by administrator. Please try again later.", + [ForceCloseReason.SERVER_SHUTDOWN]: "Server is shutting down. Please reconnect in a moment.", + [ForceCloseReason.SECURITY_VIOLATION]: "Security violation detected. Connection terminated.", + [ForceCloseReason.CORRUPTION_DETECTED]: "Data corruption detected. Please refresh the page.", + }; + + return messages[reason] || "Connection closed. Please refresh the page."; +} diff --git a/apps/live/src/types/index.ts b/apps/live/src/types/index.ts new file mode 100644 index 00000000..6c05fb83 --- /dev/null +++ b/apps/live/src/types/index.ts @@ -0,0 +1,29 @@ +import type { fetchPayload, onLoadDocumentPayload, storePayload } from "@hocuspocus/server"; + +export type TConvertDocumentRequestBody = { + description_html: string; + variant: "rich" | "document"; +}; + +export interface OnLoadDocumentPayloadWithContext extends onLoadDocumentPayload { + context: HocusPocusServerContext; +} + +export interface FetchPayloadWithContext extends fetchPayload { + context: HocusPocusServerContext; +} + +export interface StorePayloadWithContext extends storePayload { + context: HocusPocusServerContext; +} + +export type TDocumentTypes = "project_page"; + +// Additional Hocuspocus types that are not exported from the main package +export type HocusPocusServerContext = { + projectId: string | null; + cookie: string; + documentType: TDocumentTypes; + workspaceSlug: string | null; + userId: string; +}; diff --git a/apps/live/src/utils/broadcast-error.ts b/apps/live/src/utils/broadcast-error.ts new file mode 100644 index 00000000..3dfa9da4 --- /dev/null +++ b/apps/live/src/utils/broadcast-error.ts @@ -0,0 +1,38 @@ +import { type Hocuspocus } from "@hocuspocus/server"; +import { createRealtimeEvent } from "@plane/editor"; +import { logger } from "@plane/logger"; +import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types"; +import { broadcastMessageToPage } from "./broadcast-message"; + +// Helper to broadcast error to frontend +export const broadcastError = async ( + hocuspocusServerInstance: Hocuspocus, + pageId: string, + errorMessage: string, + errorType: "fetch" | "store", + context: FetchPayloadWithContext["context"] | StorePayloadWithContext["context"], + errorCode?: "content_too_large" | "page_locked" | "page_archived", + shouldDisconnect?: boolean +) => { + try { + const errorEvent = createRealtimeEvent({ + action: "error", + page_id: pageId, + parent_id: undefined, + descendants_ids: [], + data: { + error_message: errorMessage, + error_type: errorType, + error_code: errorCode, + should_disconnect: shouldDisconnect, + user_id: context.userId || "", + }, + workspace_slug: context.workspaceSlug || "", + user_id: context.userId || "", + }); + + await broadcastMessageToPage(hocuspocusServerInstance, pageId, errorEvent); + } catch (broadcastError) { + logger.error("Error broadcasting error message to frontend:", broadcastError); + } +}; diff --git a/apps/live/src/utils/broadcast-message.ts b/apps/live/src/utils/broadcast-message.ts new file mode 100644 index 00000000..7c9a3ced --- /dev/null +++ b/apps/live/src/utils/broadcast-message.ts @@ -0,0 +1,34 @@ +import { Hocuspocus } from "@hocuspocus/server"; +import { BroadcastedEvent } from "@plane/editor"; +import { logger } from "@plane/logger"; +import { Redis } from "@/extensions/redis"; +import { AppError } from "@/lib/errors"; + +export const broadcastMessageToPage = async ( + hocuspocusServerInstance: Hocuspocus, + documentName: string, + eventData: BroadcastedEvent +): Promise => { + if (!hocuspocusServerInstance || !hocuspocusServerInstance.documents) { + const appError = new AppError("HocusPocus server not available or initialized", { + context: { operation: "broadcastMessageToPage", documentName }, + }); + logger.error("Error while broadcasting message:", appError); + return false; + } + + const redisExtension = hocuspocusServerInstance.configuration.extensions.find((ext) => ext instanceof Redis); + + if (!redisExtension) { + logger.error("BROADCAST_MESSAGE_TO_PAGE: Redis extension not found"); + return false; + } + + try { + await redisExtension.broadcastToDocument(documentName, eventData); + return true; + } catch (error) { + logger.error(`BROADCAST_MESSAGE_TO_PAGE: Error broadcasting to ${documentName}:`, error); + return false; + } +}; diff --git a/apps/live/src/utils/document.ts b/apps/live/src/utils/document.ts new file mode 100644 index 00000000..318a506e --- /dev/null +++ b/apps/live/src/utils/document.ts @@ -0,0 +1,21 @@ +export const generateTitleProsemirrorJson = (text: string) => { + return { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + ...(text + ? { + content: [ + { + type: "text", + text, + }, + ], + } + : {}), + }, + ], + }; +}; diff --git a/apps/live/src/utils/index.ts b/apps/live/src/utils/index.ts new file mode 100644 index 00000000..fe6d89c0 --- /dev/null +++ b/apps/live/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./document"; diff --git a/apps/live/tsconfig.json b/apps/live/tsconfig.json new file mode 100644 index 00000000..cdfe5996 --- /dev/null +++ b/apps/live/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "@plane/typescript-config/base.json", + "compilerOptions": { + "module": "ES2015", + "moduleResolution": "Bundler", + "lib": ["ES2015"], + "target": "ES2015", + "outDir": "./dist", + "rootDir": ".", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@/plane-live/*": ["./src/ce/*"] + }, + "removeComments": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "sourceRoot": "/", + "types": ["node"] + }, + "include": ["src/**/*.ts", "tsdown.config.ts"], + "exclude": ["./dist", "./build", "./node_modules", "**/*.d.ts"] +} diff --git a/apps/live/tsdown.config.ts b/apps/live/tsdown.config.ts new file mode 100644 index 00000000..d8c78826 --- /dev/null +++ b/apps/live/tsdown.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/start.ts"], + outDir: "dist", + format: ["esm"], + dts: false, + clean: true, + sourcemap: false, +}); diff --git a/apps/proxy/Caddyfile.ce b/apps/proxy/Caddyfile.ce new file mode 100644 index 00000000..7f8fc79f --- /dev/null +++ b/apps/proxy/Caddyfile.ce @@ -0,0 +1,34 @@ +(plane_proxy) { + request_body { + max_size {$FILE_SIZE_LIMIT} + } + + reverse_proxy /spaces/* space:3000 + + reverse_proxy /god-mode/* admin:3000 + + reverse_proxy /live/* live:3000 + + reverse_proxy /api/* api:8000 + + reverse_proxy /auth/* api:8000 + + reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000 + + reverse_proxy /* web:3000 +} + +{ + {$CERT_EMAIL} + acme_ca {$CERT_ACME_CA:https://acme-v02.api.letsencrypt.org/directory} + {$CERT_ACME_DNS} + servers { + max_header_size 25MB + client_ip_headers X-Forwarded-For X-Real-IP + trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0} + } +} + +{$SITE_ADDRESS} { + import plane_proxy +} \ No newline at end of file diff --git a/apps/proxy/Dockerfile.ce b/apps/proxy/Dockerfile.ce new file mode 100644 index 00000000..2c0f3ead --- /dev/null +++ b/apps/proxy/Dockerfile.ce @@ -0,0 +1,14 @@ +FROM caddy:2.10.0-builder-alpine AS caddy-builder + +RUN xcaddy build \ + --with github.com/caddy-dns/cloudflare@v0.2.1 \ + --with github.com/caddy-dns/digitalocean@04bde2867106aa1b44c2f9da41a285fa02e629c5 \ + --with github.com/mholt/caddy-l4@4d3c80e89c5f80438a3e048a410d5543ff5fb9f4 + +FROM caddy:2.10.0-alpine + +RUN apk add --no-cache nss-tools bash curl + +COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy + +COPY Caddyfile.ce /etc/caddy/Caddyfile \ No newline at end of file diff --git a/apps/space/.env.example b/apps/space/.env.example new file mode 100644 index 00000000..15d7a36a --- /dev/null +++ b/apps/space/.env.example @@ -0,0 +1,12 @@ +NEXT_PUBLIC_API_BASE_URL="http://localhost:8000" + +NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000" + +NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001" +NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" + +NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002" +NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" + +NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100" +NEXT_PUBLIC_LIVE_BASE_PATH="/live" diff --git a/apps/space/.eslintignore b/apps/space/.eslintignore new file mode 100644 index 00000000..27e50ad7 --- /dev/null +++ b/apps/space/.eslintignore @@ -0,0 +1,12 @@ +.next/* +out/* +public/* +dist/* +node_modules/* +.turbo/* +.env* +.env +.env.local +.env.development +.env.production +.env.test \ No newline at end of file diff --git a/apps/space/.eslintrc.js b/apps/space/.eslintrc.js new file mode 100644 index 00000000..a0bc76d5 --- /dev/null +++ b/apps/space/.eslintrc.js @@ -0,0 +1,18 @@ +module.exports = { + root: true, + extends: ["@plane/eslint-config/next.js"], + rules: { + "no-duplicate-imports": "off", + "import/no-duplicates": ["error", { "prefer-inline": false }], + "import/consistent-type-specifier-style": ["error", "prefer-top-level"], + "@typescript-eslint/no-import-type-side-effects": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "separate-type-imports", + disallowTypeAnnotations: false, + }, + ], + }, +}; diff --git a/apps/space/.gitignore b/apps/space/.gitignore new file mode 100644 index 00000000..bc7846c3 --- /dev/null +++ b/apps/space/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# env +.env + +# Sentry Config File +.env.sentry-build-plugin diff --git a/apps/space/.prettierignore b/apps/space/.prettierignore new file mode 100644 index 00000000..07bf87ab --- /dev/null +++ b/apps/space/.prettierignore @@ -0,0 +1,7 @@ +.next +.vercel +.tubro +out/ +dist/ +build/ +node_modules/ diff --git a/apps/space/.prettierrc.json b/apps/space/.prettierrc.json new file mode 100644 index 00000000..87d988f1 --- /dev/null +++ b/apps/space/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/apps/space/Dockerfile.dev b/apps/space/Dockerfile.dev new file mode 100644 index 00000000..b915aad0 --- /dev/null +++ b/apps/space/Dockerfile.dev @@ -0,0 +1,19 @@ +FROM node:22-alpine + +RUN apk add --no-cache libc6-compat + +# Set working directory +WORKDIR /app + +COPY . . + +RUN corepack enable pnpm && pnpm add -g turbo +RUN pnpm install + +EXPOSE 3002 + +ENV NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" + +VOLUME [ "/app/node_modules", "/app/apps/space/node_modules"] + +CMD ["pnpm", "dev", "--filter=space"] diff --git a/apps/space/Dockerfile.space b/apps/space/Dockerfile.space new file mode 100644 index 00000000..570511b9 --- /dev/null +++ b/apps/space/Dockerfile.space @@ -0,0 +1,103 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-alpine AS base + +# Setup pnpm package manager with corepack and configure global bin directory for caching +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +# ***************************************************************************** +# STAGE 1: Build the project +# ***************************************************************************** +FROM base AS builder +RUN apk add --no-cache libc6-compat +WORKDIR /app + +ARG TURBO_VERSION=2.5.6 +RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION} +COPY . . + +RUN turbo prune --scope=space --docker + +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** +FROM base AS installer + +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN corepack enable pnpm +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store + +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store + +ARG NEXT_PUBLIC_API_BASE_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_URL="" +ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 + +RUN pnpm turbo run build --filter=space + +# ***************************************************************************** +# STAGE 3: Copy the project and start it +# ***************************************************************************** +FROM base AS runner +WORKDIR /app + +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +USER nextjs + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer /app/apps/space/.next/standalone ./ +COPY --from=installer /app/apps/space/.next/static ./apps/space/.next/static +COPY --from=installer /app/apps/space/public ./apps/space/public + +ARG NEXT_PUBLIC_API_BASE_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_URL="" +ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 + +EXPOSE 3000 + +CMD ["node", "apps/space/server.js"] diff --git a/apps/space/README.md b/apps/space/README.md new file mode 100644 index 00000000..fc229810 --- /dev/null +++ b/apps/space/README.md @@ -0,0 +1,10 @@ +

    + +

    + + Plane Logo + +

    + +

    Plane Space

    +

    Open-source, self-hosted project planning tool

    diff --git a/apps/space/additional.d.ts b/apps/space/additional.d.ts new file mode 100644 index 00000000..f400344c --- /dev/null +++ b/apps/space/additional.d.ts @@ -0,0 +1,2 @@ +// additional.d.ts +/// diff --git a/apps/space/app/[workspaceSlug]/[projectId]/page.ts b/apps/space/app/[workspaceSlug]/[projectId]/page.ts new file mode 100644 index 00000000..94c4152e --- /dev/null +++ b/apps/space/app/[workspaceSlug]/[projectId]/page.ts @@ -0,0 +1,41 @@ +import { notFound, redirect } from "next/navigation"; +// plane imports +import { SitesProjectPublishService } from "@plane/services"; +import type { TProjectPublishSettings } from "@plane/types"; + +const publishService = new SitesProjectPublishService(); + +type Props = { + params: { + workspaceSlug: string; + projectId: string; + }; + searchParams: Record<"board" | "peekId", string | string[] | undefined>; +}; + +export default async function IssuesPage(props: Props) { + const { params, searchParams } = props; + // query params + const { workspaceSlug, projectId } = params; + const { board, peekId } = searchParams; + + let response: TProjectPublishSettings | undefined = undefined; + try { + response = await publishService.retrieveSettingsByProjectId(workspaceSlug, projectId); + } catch (error) { + console.error("Error fetching project publish settings:", error); + notFound(); + } + + let url = ""; + if (response?.entity_name === "project") { + url = `/issues/${response?.anchor}`; + const params = new URLSearchParams(); + if (board) params.append("board", String(board)); + if (peekId) params.append("peekId", String(peekId)); + if (params.toString()) url += `?${params.toString()}`; + redirect(url); + } else { + notFound(); + } +} diff --git a/apps/space/app/error.tsx b/apps/space/app/error.tsx new file mode 100644 index 00000000..98fe1cd0 --- /dev/null +++ b/apps/space/app/error.tsx @@ -0,0 +1,47 @@ +"use client"; + +// ui +import { Button } from "@plane/propel/button"; + +const ErrorPage = () => { + const handleRetry = () => { + window.location.reload(); + }; + + return ( +
    +
    +
    +

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

    +

    + That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more + details, please write to{" "} + + support@plane.so + {" "} + or on our{" "} + + Discord + + . +

    +
    +
    + + {/* */} +
    +
    +
    + ); +}; + +export default ErrorPage; diff --git a/apps/space/app/issues/[anchor]/client-layout.tsx b/apps/space/app/issues/[anchor]/client-layout.tsx new file mode 100644 index 00000000..398591c4 --- /dev/null +++ b/apps/space/app/issues/[anchor]/client-layout.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { observer } from "mobx-react"; +import useSWR from "swr"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PoweredBy } from "@/components/common/powered-by"; +import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error"; +import { IssuesNavbarRoot } from "@/components/issues/navbar"; +// hooks +import { usePublish, usePublishList } from "@/hooks/store/publish"; +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; + +type Props = { + children: React.ReactNode; + anchor: string; +}; + +export const IssuesClientLayout = observer((props: Props) => { + const { children, anchor } = props; + // store hooks + const { fetchPublishSettings } = usePublishList(); + const publishSettings = usePublish(anchor); + const { updateLayoutOptions } = useIssueFilter(); + // fetch publish settings + const { error } = useSWR( + anchor ? `PUBLISH_SETTINGS_${anchor}` : null, + anchor + ? async () => { + const response = await fetchPublishSettings(anchor); + if (response.view_props) { + updateLayoutOptions({ + list: !!response.view_props.list, + kanban: !!response.view_props.kanban, + calendar: !!response.view_props.calendar, + gantt: !!response.view_props.gantt, + spreadsheet: !!response.view_props.spreadsheet, + }); + } + } + : null + ); + + if (!publishSettings && !error) { + return ( +
    + +
    + ); + } + + if (error) return ; + + return ( + <> +
    +
    + +
    +
    {children}
    +
    + + + ); +}); diff --git a/apps/space/app/issues/[anchor]/layout.tsx b/apps/space/app/issues/[anchor]/layout.tsx new file mode 100644 index 00000000..46f187dd --- /dev/null +++ b/apps/space/app/issues/[anchor]/layout.tsx @@ -0,0 +1,57 @@ +"use server"; + +import { IssuesClientLayout } from "./client-layout"; + +type Props = { + children: React.ReactNode; + params: { + anchor: string; + }; +}; + +export async function generateMetadata({ params }: Props) { + const { anchor } = params; + const DEFAULT_TITLE = "Plane"; + const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities."; + // Validate anchor before using in request (only allow alphanumeric, -, _) + const ANCHOR_REGEX = /^[a-zA-Z0-9_-]+$/; + if (!ANCHOR_REGEX.test(anchor)) { + return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION }; + } + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/anchor/${anchor}/meta/`); + const data = await response.json(); + return { + title: data?.name || DEFAULT_TITLE, + description: data?.description || DEFAULT_DESCRIPTION, + openGraph: { + title: data?.name || DEFAULT_TITLE, + description: data?.description || DEFAULT_DESCRIPTION, + type: "website", + images: [ + { + url: data?.cover_image, + width: 800, + height: 600, + alt: data?.name || DEFAULT_TITLE, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: data?.name || DEFAULT_TITLE, + description: data?.description || DEFAULT_DESCRIPTION, + images: [data?.cover_image], + }, + }; + } catch { + return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION }; + } +} + +export default async function IssuesLayout(props: Props) { + const { children, params } = props; + const { anchor } = params; + + return {children}; +} diff --git a/apps/space/app/issues/[anchor]/page.tsx b/apps/space/app/issues/[anchor]/page.tsx new file mode 100644 index 00000000..baff2132 --- /dev/null +++ b/apps/space/app/issues/[anchor]/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import useSWR from "swr"; +// components +import { IssuesLayoutsRoot } from "@/components/issues/issue-layouts"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useLabel } from "@/hooks/store/use-label"; +import { useStates } from "@/hooks/store/use-state"; + +type Props = { + params: { + anchor: string; + }; +}; + +const IssuesPage = observer((props: Props) => { + const { params } = props; + const { anchor } = params; + // params + const searchParams = useSearchParams(); + const peekId = searchParams.get("peekId") || undefined; + // store + const { fetchStates } = useStates(); + const { fetchLabels } = useLabel(); + + useSWR(anchor ? `PUBLIC_STATES_${anchor}` : null, anchor ? () => fetchStates(anchor) : null); + useSWR(anchor ? `PUBLIC_LABELS_${anchor}` : null, anchor ? () => fetchLabels(anchor) : null); + + const publishSettings = usePublish(anchor); + + if (!publishSettings) return null; + + return ; +}); + +export default IssuesPage; diff --git a/apps/space/app/layout.tsx b/apps/space/app/layout.tsx new file mode 100644 index 00000000..05d54bd0 --- /dev/null +++ b/apps/space/app/layout.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +// helpers +import { SPACE_BASE_PATH } from "@plane/constants"; +// styles +import "@/styles/globals.css"; +// components +import { AppProvider } from "./provider"; + +export const metadata: Metadata = { + title: "Plane Publish | Make your Plane boards public with one-click", + description: "Plane Publish is a customer feedback management tool built on top of plane.so", + openGraph: { + title: "Plane Publish | Make your Plane boards public with one-click", + description: "Plane Publish is a customer feedback management tool built on top of plane.so", + url: "https://sites.plane.so/", + }, + keywords: + "software development, customer feedback, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + + +
    + + <>{children} + + + + ); +} diff --git a/apps/space/app/not-found.tsx b/apps/space/app/not-found.tsx new file mode 100644 index 00000000..9b6050ed --- /dev/null +++ b/apps/space/app/not-found.tsx @@ -0,0 +1,23 @@ +"use client"; + +import Image from "next/image"; +// assets +import SomethingWentWrongImage from "public/something-went-wrong.svg"; + +const NotFound = () => ( +
    +
    +
    +
    + User already logged in +
    +
    +

    That didn{"'"}t work

    +

    + Check the URL you are entering in the browser{"'"}s address bar and try again. +

    +
    +
    +); + +export default NotFound; diff --git a/apps/space/app/page.tsx b/apps/space/app/page.tsx new file mode 100644 index 00000000..f544bcb1 --- /dev/null +++ b/apps/space/app/page.tsx @@ -0,0 +1,47 @@ +"use client"; +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams, useRouter } from "next/navigation"; +// plane imports +import { isValidNextPath } from "@plane/utils"; +// components +import { UserLoggedIn } from "@/components/account/user-logged-in"; +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { AuthView } from "@/components/views"; +// hooks +import { useUser } from "@/hooks/store/use-user"; + +const HomePage = observer(() => { + const { data: currentUser, isAuthenticated, isInitializing } = useUser(); + const searchParams = useSearchParams(); + const router = useRouter(); + const nextPath = searchParams.get("next_path"); + + useEffect(() => { + if (currentUser && isAuthenticated && nextPath && isValidNextPath(nextPath)) { + router.replace(nextPath); + } + }, [currentUser, isAuthenticated, nextPath, router]); + + if (isInitializing) + return ( +
    + +
    + ); + + if (currentUser && isAuthenticated) { + if (nextPath && isValidNextPath(nextPath)) { + return ( +
    + +
    + ); + } + return ; + } + + return ; +}); + +export default HomePage; diff --git a/apps/space/app/provider.tsx b/apps/space/app/provider.tsx new file mode 100644 index 00000000..4a0a483a --- /dev/null +++ b/apps/space/app/provider.tsx @@ -0,0 +1,29 @@ +"use client"; + +import type { ReactNode, FC } from "react"; +import { ThemeProvider } from "next-themes"; +// components +import { TranslationProvider } from "@plane/i18n"; +import { InstanceProvider } from "@/lib/instance-provider"; +import { StoreProvider } from "@/lib/store-provider"; +import { ToastProvider } from "@/lib/toast-provider"; + +interface IAppProvider { + children: ReactNode; +} + +export const AppProvider: FC = (props) => { + const { children } = props; + + return ( + + + + + {children} + + + + + ); +}; diff --git a/apps/space/app/views/[anchor]/layout.tsx b/apps/space/app/views/[anchor]/layout.tsx new file mode 100644 index 00000000..e2a38071 --- /dev/null +++ b/apps/space/app/views/[anchor]/layout.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { observer } from "mobx-react"; +import useSWR from "swr"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PoweredBy } from "@/components/common/powered-by"; +import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error"; +// hooks +import { usePublish, usePublishList } from "@/hooks/store/publish"; +// Plane web +import { ViewNavbarRoot } from "@/plane-web/components/navbar"; +import { useView } from "@/plane-web/hooks/store"; + +type Props = { + children: React.ReactNode; + params: { + anchor: string; + }; +}; + +const ViewsLayout = observer((props: Props) => { + const { children, params } = props; + // params + const { anchor } = params; + // store hooks + const { fetchPublishSettings } = usePublishList(); + const { viewData, fetchViewDetails } = useView(); + const publishSettings = usePublish(anchor); + + // fetch publish settings && view details + const { error } = useSWR( + anchor ? `PUBLISHED_VIEW_SETTINGS_${anchor}` : null, + anchor + ? async () => { + const promises = []; + promises.push(fetchPublishSettings(anchor)); + promises.push(fetchViewDetails(anchor)); + await Promise.all(promises); + } + : null + ); + + if (error) return ; + + if (!publishSettings || !viewData) { + return ( +
    + +
    + ); + } + + return ( +
    +
    + +
    +
    {children}
    + +
    + ); +}); + +export default ViewsLayout; diff --git a/apps/space/app/views/[anchor]/page.tsx b/apps/space/app/views/[anchor]/page.tsx new file mode 100644 index 00000000..5c877c89 --- /dev/null +++ b/apps/space/app/views/[anchor]/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// components +import { PoweredBy } from "@/components/common/powered-by"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +// plane-web +import { ViewLayoutsRoot } from "@/plane-web/components/issue-layouts/root"; + +type Props = { + params: { + anchor: string; + }; +}; + +const ViewsPage = observer((props: Props) => { + const { params } = props; + const { anchor } = params; + // params + const searchParams = useSearchParams(); + const peekId = searchParams.get("peekId") || undefined; + + const publishSettings = usePublish(anchor); + + if (!publishSettings) return null; + + return ( + <> + + + + ); +}); + +export default ViewsPage; diff --git a/apps/space/ce/components/editor/embeds/index.ts b/apps/space/ce/components/editor/embeds/index.ts new file mode 100644 index 00000000..8146e94d --- /dev/null +++ b/apps/space/ce/components/editor/embeds/index.ts @@ -0,0 +1 @@ +export * from "./mentions"; diff --git a/apps/space/ce/components/editor/embeds/mentions/index.ts b/apps/space/ce/components/editor/embeds/mentions/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/space/ce/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/space/ce/components/editor/embeds/mentions/root.tsx b/apps/space/ce/components/editor/embeds/mentions/root.tsx new file mode 100644 index 00000000..23f15fe2 --- /dev/null +++ b/apps/space/ce/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,4 @@ +// plane editor +import type { TMentionComponentProps } from "@plane/editor"; + +export const EditorAdditionalMentionsRoot: React.FC = () => null; diff --git a/apps/space/ce/components/editor/index.ts b/apps/space/ce/components/editor/index.ts new file mode 100644 index 00000000..cf8352ae --- /dev/null +++ b/apps/space/ce/components/editor/index.ts @@ -0,0 +1 @@ +export * from "./embeds"; diff --git a/apps/space/ce/components/issue-layouts/root.tsx b/apps/space/ce/components/issue-layouts/root.tsx new file mode 100644 index 00000000..028bf4e9 --- /dev/null +++ b/apps/space/ce/components/issue-layouts/root.tsx @@ -0,0 +1,9 @@ +import { PageNotFound } from "@/components/ui/not-found"; +import type { PublishStore } from "@/store/publish/publish.store"; + +type Props = { + peekId: string | undefined; + publishSettings: PublishStore; +}; + +export const ViewLayoutsRoot = (_props: Props) => ; diff --git a/apps/space/ce/components/navbar/index.tsx b/apps/space/ce/components/navbar/index.tsx new file mode 100644 index 00000000..0d00777c --- /dev/null +++ b/apps/space/ce/components/navbar/index.tsx @@ -0,0 +1,8 @@ +import type { PublishStore } from "@/store/publish/publish.store"; + +type Props = { + publishSettings: PublishStore; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const ViewNavbarRoot = (props: Props) => <>; diff --git a/apps/space/ce/hooks/store/index.ts b/apps/space/ce/hooks/store/index.ts new file mode 100644 index 00000000..a5fc99ee --- /dev/null +++ b/apps/space/ce/hooks/store/index.ts @@ -0,0 +1 @@ +export * from "./use-published-view"; diff --git a/apps/space/ce/hooks/store/use-published-view.ts b/apps/space/ce/hooks/store/use-published-view.ts new file mode 100644 index 00000000..170d934d --- /dev/null +++ b/apps/space/ce/hooks/store/use-published-view.ts @@ -0,0 +1,5 @@ +export const useView = () => ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + fetchViewDetails: (anchor: string) => {}, + viewData: {}, +}); diff --git a/apps/space/ce/hooks/use-editor-flagging.ts b/apps/space/ce/hooks/use-editor-flagging.ts new file mode 100644 index 00000000..9e80c35a --- /dev/null +++ b/apps/space/ce/hooks/use-editor-flagging.ts @@ -0,0 +1,35 @@ +// editor +import type { TExtensions } from "@plane/editor"; + +export type TEditorFlaggingHookReturnType = { + document: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + liteText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + richText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; +}; + +/** + * @description extensions disabled in various editors + */ +export const useEditorFlagging = (anchor: string): TEditorFlaggingHookReturnType => ({ + document: { + disabled: [], + flagged: [], + }, + liteText: { + disabled: [], + flagged: [], + }, + richText: { + disabled: [], + flagged: [], + }, +}); diff --git a/apps/space/ce/store/root.store.ts b/apps/space/ce/store/root.store.ts new file mode 100644 index 00000000..710462e1 --- /dev/null +++ b/apps/space/ce/store/root.store.ts @@ -0,0 +1,8 @@ +// store +import { CoreRootStore } from "@/store/root.store"; + +export class RootStore extends CoreRootStore { + constructor() { + super(); + } +} diff --git a/apps/space/core/components/account/auth-forms/auth-banner.tsx b/apps/space/core/components/account/auth-forms/auth-banner.tsx new file mode 100644 index 00000000..30cd6e09 --- /dev/null +++ b/apps/space/core/components/account/auth-forms/auth-banner.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { FC } from "react"; +import { Info, X } from "lucide-react"; +// helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; + +type TAuthBanner = { + bannerData: TAuthErrorInfo | undefined; + handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void; +}; + +export const AuthBanner: FC = (props) => { + const { bannerData, handleBannerData } = props; + + if (!bannerData) return <>; + return ( +
    +
    + +
    +
    {bannerData?.message}
    +
    handleBannerData && handleBannerData(undefined)} + > + +
    +
    + ); +}; diff --git a/apps/space/core/components/account/auth-forms/auth-header.tsx b/apps/space/core/components/account/auth-forms/auth-header.tsx new file mode 100644 index 00000000..7996feed --- /dev/null +++ b/apps/space/core/components/account/auth-forms/auth-header.tsx @@ -0,0 +1,55 @@ +"use client"; + +import type { FC } from "react"; +// helpers +import { EAuthModes } from "@/types/auth"; + +type TAuthHeader = { + authMode: EAuthModes; +}; + +type TAuthHeaderContent = { + header: string; + subHeader: string; +}; + +type TAuthHeaderDetails = { + [mode in EAuthModes]: TAuthHeaderContent; +}; + +const Titles: TAuthHeaderDetails = { + [EAuthModes.SIGN_IN]: { + header: "Sign in to upvote or comment", + subHeader: "Contribute in nudging the features you want to get built.", + }, + [EAuthModes.SIGN_UP]: { + header: "View, comment, and do more", + subHeader: "Sign up or log in to work with Plane work items and Pages.", + }, +}; + +export const AuthHeader: FC = (props) => { + const { authMode } = props; + + const getHeaderSubHeader = (mode: EAuthModes | null): TAuthHeaderContent => { + if (mode) { + return Titles[mode]; + } + + return { + header: "Comment or react to work items", + subHeader: "Use plane to add your valuable inputs to features.", + }; + }; + + const { header, subHeader } = getHeaderSubHeader(authMode); + + return ( + <> +
    + {header} + {subHeader} +
    + + ); +}; diff --git a/apps/space/core/components/account/auth-forms/auth-root.tsx b/apps/space/core/components/account/auth-forms/auth-root.tsx new file mode 100644 index 00000000..86452a3c --- /dev/null +++ b/apps/space/core/components/account/auth-forms/auth-root.tsx @@ -0,0 +1,237 @@ +"use client"; + +import type { FC } from "react"; +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { SitesAuthService } from "@plane/services"; +import type { IEmailCheckData } from "@plane/types"; +import { OAuthOptions } from "@plane/ui"; +// components +// helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { EErrorAlertType, authErrorHandler, EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; +// hooks +import { useInstance } from "@/hooks/store/use-instance"; +// types +import { EAuthModes, EAuthSteps } from "@/types/auth"; +// assets +import GithubLightLogo from "/public/logos/github-black.png"; +import GithubDarkLogo from "/public/logos/github-dark.svg"; +import GitlabLogo from "/public/logos/gitlab-logo.svg"; +import GoogleLogo from "/public/logos/google-logo.svg"; +// local imports +import { TermsAndConditions } from "../terms-and-conditions"; +import { AuthBanner } from "./auth-banner"; +import { AuthHeader } from "./auth-header"; +import { AuthEmailForm } from "./email"; +import { AuthPasswordForm } from "./password"; +import { AuthUniqueCodeForm } from "./unique-code"; + +const authService = new SitesAuthService(); + +export const AuthRoot: FC = observer(() => { + // router params + const searchParams = useSearchParams(); + const emailParam = searchParams.get("email") || undefined; + const error_code = searchParams.get("error_code") || undefined; + const nextPath = searchParams.get("next_path") || undefined; + const next_path = searchParams.get("next_path"); + // states + const [authMode, setAuthMode] = useState(EAuthModes.SIGN_UP); + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); + const [errorInfo, setErrorInfo] = useState(undefined); + const [isPasswordAutoset, setIsPasswordAutoset] = useState(true); + // hooks + const { resolvedTheme } = useTheme(); + const { config } = useInstance(); + + useEffect(() => { + if (error_code) { + const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); + if (errorhandler) { + if (errorhandler.code === EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN) { + setAuthMode(EAuthModes.SIGN_IN); + setAuthStep(EAuthSteps.PASSWORD); + } + if (errorhandler.code === EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.PASSWORD); + } + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN, + ].includes(errorhandler.code) + ) { + setAuthMode(EAuthModes.SIGN_IN); + setAuthStep(EAuthSteps.UNIQUE_CODE); + } + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP, + ].includes(errorhandler.code) + ) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.UNIQUE_CODE); + } + setErrorInfo(errorhandler); + } + } + }, [error_code]); + + const isSMTPConfigured = config?.is_smtp_configured || false; + const isMagicLoginEnabled = config?.is_magic_login_enabled || false; + const isEmailPasswordEnabled = config?.is_email_password_enabled || false; + const isOAuthEnabled = + (config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false; + + // submit handler- email verification + const handleEmailVerification = async (data: IEmailCheckData) => { + setEmail(data.email); + + await authService + .emailCheck(data) + .then(async (response) => { + let currentAuthMode: EAuthModes = response.existing ? EAuthModes.SIGN_IN : EAuthModes.SIGN_UP; + if (response.existing) { + currentAuthMode = EAuthModes.SIGN_IN; + setAuthMode(() => EAuthModes.SIGN_IN); + } else { + currentAuthMode = EAuthModes.SIGN_UP; + setAuthMode(() => EAuthModes.SIGN_UP); + } + + if (currentAuthMode === EAuthModes.SIGN_IN) { + if (response.is_password_autoset && isSMTPConfigured && isMagicLoginEnabled) { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (isEmailPasswordEnabled) { + setIsPasswordAutoset(false); + setAuthStep(EAuthSteps.PASSWORD); + } else { + const errorhandler = authErrorHandler("5005" as EAuthenticationErrorCodes); + setErrorInfo(errorhandler); + } + } else { + if (isSMTPConfigured && isMagicLoginEnabled) { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (isEmailPasswordEnabled) { + setAuthStep(EAuthSteps.PASSWORD); + } else { + const errorhandler = authErrorHandler("5006" as EAuthenticationErrorCodes); + setErrorInfo(errorhandler); + } + } + }) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined); + if (errorhandler?.type) setErrorInfo(errorhandler); + }); + }; + + // generating the unique code + const generateEmailUniqueCode = async (email: string): Promise<{ code: string } | undefined> => { + const payload = { email: email }; + return await authService + .generateUniqueCode(payload) + .then(() => ({ code: "" })) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code.toString()); + if (errorhandler?.type) setErrorInfo(errorhandler); + throw error; + }); + }; + + const content = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in"; + + const OAuthConfig = [ + { + id: "google", + text: `${content} with Google`, + icon: Google Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/google/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_google_enabled, + }, + { + id: "github", + text: `${content} with GitHub`, + icon: ( + GitHub Logo + ), + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_github_enabled, + }, + { + id: "gitlab", + text: `${content} with GitLab`, + icon: GitLab Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_gitlab_enabled, + }, + ]; + + return ( +
    +
    + {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} + + {isOAuthEnabled && } + + {authStep === EAuthSteps.EMAIL && } + {authStep === EAuthSteps.UNIQUE_CODE && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + generateEmailUniqueCode={generateEmailUniqueCode} + /> + )} + {authStep === EAuthSteps.PASSWORD && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + handleAuthStep={(step: EAuthSteps) => { + if (step === EAuthSteps.UNIQUE_CODE) generateEmailUniqueCode(email); + setAuthStep(step); + }} + /> + )} + +
    +
    + ); +}); diff --git a/apps/space/core/components/account/auth-forms/email.tsx b/apps/space/core/components/account/auth-forms/email.tsx new file mode 100644 index 00000000..7abaef6f --- /dev/null +++ b/apps/space/core/components/account/auth-forms/email.tsx @@ -0,0 +1,104 @@ +"use client"; + +import type { FC, FormEvent } from "react"; +import { useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +// icons +import { CircleAlert, XCircle } from "lucide-react"; +// types +import { Button } from "@plane/propel/button"; +import type { IEmailCheckData } from "@plane/types"; +// ui +import { Input, Spinner } from "@plane/ui"; +// helpers +import { cn } from "@plane/utils"; +import { checkEmailValidity } from "@/helpers/string.helper"; + +type TAuthEmailForm = { + defaultEmail: string; + onSubmit: (data: IEmailCheckData) => Promise; +}; + +export const AuthEmailForm: FC = observer((props) => { + const { onSubmit, defaultEmail } = props; + // states + const [isSubmitting, setIsSubmitting] = useState(false); + const [email, setEmail] = useState(defaultEmail); + + const emailError = useMemo( + () => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined), + [email] + ); + + const handleFormSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + const payload: IEmailCheckData = { + email: email, + }; + await onSubmit(payload); + setIsSubmitting(false); + }; + + const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting; + + const [isFocused, setIsFocused] = useState(true); + const inputRef = useRef(null); + + return ( +
    +
    + +
    { + setIsFocused(true); + }} + onBlur={() => { + setIsFocused(false); + }} + > + setEmail(e.target.value)} + placeholder="name@company.com" + className={`disable-autofill-style h-10 w-full placeholder:text-custom-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`} + autoComplete="on" + autoFocus + ref={inputRef} + /> + {email.length > 0 && ( + + )} +
    + {emailError?.email && !isFocused && ( +

    + + {emailError.email} +

    + )} +
    + +
    + ); +}); diff --git a/apps/space/core/components/account/auth-forms/index.ts b/apps/space/core/components/account/auth-forms/index.ts new file mode 100644 index 00000000..aa4ee6fd --- /dev/null +++ b/apps/space/core/components/account/auth-forms/index.ts @@ -0,0 +1 @@ +export * from "./auth-root"; diff --git a/apps/space/core/components/account/auth-forms/password.tsx b/apps/space/core/components/account/auth-forms/password.tsx new file mode 100644 index 00000000..acd081bf --- /dev/null +++ b/apps/space/core/components/account/auth-forms/password.tsx @@ -0,0 +1,244 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { Eye, EyeOff, XCircle } from "lucide-react"; +// plane imports +import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { AuthService } from "@plane/services"; +import { Input, Spinner, PasswordStrengthIndicator } from "@plane/ui"; +import { getPasswordStrength } from "@plane/utils"; +// types +import { EAuthModes, EAuthSteps } from "@/types/auth"; + +type Props = { + email: string; + isPasswordAutoset: boolean; + isSMTPConfigured: boolean; + mode: EAuthModes; + nextPath: string | undefined; + handleEmailClear: () => void; + handleAuthStep: (step: EAuthSteps) => void; +}; + +type TPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TPasswordFormValues = { + email: "", + password: "", +}; + +const authService = new AuthService(); + +export const AuthPasswordForm: React.FC = observer((props: Props) => { + const { email, nextPath, isSMTPConfigured, handleAuthStep, handleEmailClear, mode } = props; + // ref + const formRef = useRef(null); + // states + const [csrfPromise, setCsrfPromise] = useState | undefined>(undefined); + const [passwordFormData, setPasswordFormData] = useState({ ...defaultValues, email }); + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TPasswordFormValues, value: string) => + setPasswordFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfPromise === undefined) { + const promise = authService.requestCSRFToken(); + setCsrfPromise(promise); + } + }, [csrfPromise]); + + const redirectToUniqueCodeSignIn = async () => { + handleAuthStep(EAuthSteps.UNIQUE_CODE); + }; + + const passwordSupport = passwordFormData.password.length > 0 && + mode === EAuthModes.SIGN_UP && + getPasswordStrength(passwordFormData.password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( + + ); + + const isButtonDisabled = useMemo( + () => + !isSubmitting && + !!passwordFormData.password && + (mode === EAuthModes.SIGN_UP + ? getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID && + passwordFormData.password === passwordFormData.confirm_password + : true) + ? false + : true, + [isSubmitting, mode, passwordFormData.confirm_password, passwordFormData.password] + ); + + const password = passwordFormData.password ?? ""; + const confirmPassword = passwordFormData.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + const handleCSRFToken = async () => { + if (!formRef || !formRef.current) return; + const token = await csrfPromise; + if (!token?.csrf_token) return; + const csrfElement = formRef.current.querySelector("input[name=csrfmiddlewaretoken]"); + csrfElement?.setAttribute("value", token?.csrf_token); + }; + + return ( +
    { + event.preventDefault(); + await handleCSRFToken(); + if (formRef.current) { + formRef.current.submit(); + } + setIsSubmitting(true); + }} + onError={() => setIsSubmitting(false)} + > + + + +
    + +
    + handleFormChange("email", e.target.value)} + placeholder="name@company.com" + className={`disable-autofill-style h-10 w-full placeholder:text-custom-text-400 border-0`} + disabled + /> + {passwordFormData.email.length > 0 && ( + + )} +
    +
    + +
    + +
    + handleFormChange("password", e.target.value)} + placeholder="Enter password" + className="disable-autofill-style h-10 w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400" + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="on" + autoFocus + /> + {showPassword?.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
    + {passwordSupport} +
    + + {mode === EAuthModes.SIGN_UP && ( +
    + +
    + handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="disable-autofill-style h-10 w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400" + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + {showPassword?.retypePassword ? ( + handleShowPassword("retypePassword")} + /> + ) : ( + handleShowPassword("retypePassword")} + /> + )} +
    + {!!passwordFormData.confirm_password && + passwordFormData.password !== passwordFormData.confirm_password && + renderPasswordMatchError && Passwords don{"'"}t match} +
    + )} + +
    + {mode === EAuthModes.SIGN_IN ? ( + <> + + {isSMTPConfigured && ( + + )} + + ) : ( + + )} +
    +
    + ); +}); diff --git a/apps/space/core/components/account/auth-forms/unique-code.tsx b/apps/space/core/components/account/auth-forms/unique-code.tsx new file mode 100644 index 00000000..8e691cf8 --- /dev/null +++ b/apps/space/core/components/account/auth-forms/unique-code.tsx @@ -0,0 +1,153 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { CircleCheck, XCircle } from "lucide-react"; +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { AuthService } from "@plane/services"; +import { Input, Spinner } from "@plane/ui"; +// hooks +import useTimer from "@/hooks/use-timer"; +// types +import { EAuthModes } from "@/types/auth"; + +// services +const authService = new AuthService(); + +type TAuthUniqueCodeForm = { + mode: EAuthModes; + email: string; + nextPath: string | undefined; + handleEmailClear: () => void; + generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>; +}; + +type TUniqueCodeFormValues = { + email: string; + code: string; +}; + +const defaultValues: TUniqueCodeFormValues = { + email: "", + code: "", +}; + +export const AuthUniqueCodeForm: React.FC = (props) => { + const { mode, email, nextPath, handleEmailClear, generateEmailUniqueCode } = props; + // derived values + const defaultResetTimerValue = 5; + // states + const [uniqueCodeFormData, setUniqueCodeFormData] = useState({ ...defaultValues, email }); + const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); + + const handleFormChange = (key: keyof TUniqueCodeFormValues, value: string) => + setUniqueCodeFormData((prev) => ({ ...prev, [key]: value })); + + const generateNewCode = async (email: string) => { + try { + setIsRequestingNewCode(true); + const uniqueCode = await generateEmailUniqueCode(email); + setResendCodeTimer(defaultResetTimerValue); + handleFormChange("code", uniqueCode?.code || ""); + setIsRequestingNewCode(false); + } catch { + setResendCodeTimer(0); + console.error("Error while requesting new code"); + setIsRequestingNewCode(false); + } + }; + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; + const isButtonDisabled = isRequestingNewCode || !uniqueCodeFormData.code || isSubmitting; + + return ( +
    setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + + + +
    + +
    + handleFormChange("email", e.target.value)} + placeholder="name@company.com" + className={`disable-autofill-style h-10 w-full placeholder:text-custom-text-400 border-0`} + disabled + /> + {uniqueCodeFormData.email.length > 0 && ( + + )} +
    +
    + +
    + + handleFormChange("code", e.target.value)} + placeholder="gets-sets-flys" + className="disable-autofill-style h-10 w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400" + autoFocus + /> +
    +

    + + Paste the code sent to your email +

    + +
    +
    + +
    + +
    +
    + ); +}; diff --git a/apps/space/core/components/account/terms-and-conditions.tsx b/apps/space/core/components/account/terms-and-conditions.tsx new file mode 100644 index 00000000..09611d92 --- /dev/null +++ b/apps/space/core/components/account/terms-and-conditions.tsx @@ -0,0 +1,28 @@ +"use client"; + +import type { FC } from "react"; +import React from "react"; +import Link from "next/link"; + +type Props = { + isSignUp?: boolean; +}; + +export const TermsAndConditions: FC = (props) => { + const { isSignUp = false } = props; + return ( + +

    + {isSignUp ? "By creating an account" : "By signing in"}, you agree to our{" \n"} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + {"."} +

    +
    + ); +}; diff --git a/apps/space/core/components/account/user-logged-in.tsx b/apps/space/core/components/account/user-logged-in.tsx new file mode 100644 index 00000000..51175c16 --- /dev/null +++ b/apps/space/core/components/account/user-logged-in.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { observer } from "mobx-react"; +import Image from "next/image"; +import { PlaneLockup } from "@plane/propel/icons"; +// components +import { PoweredBy } from "@/components/common/powered-by"; +import { UserAvatar } from "@/components/issues/navbar/user-avatar"; +// hooks +import { useUser } from "@/hooks/store/use-user"; +// assets +import UserLoggedInImage from "@/public/user-logged-in.svg"; + +export const UserLoggedIn = observer(() => { + // store hooks + const { data: user } = useUser(); + + if (!user) return null; + + return ( +
    +
    + + +
    + +
    +
    +
    +
    + User already logged in +
    +
    +

    Nice! Just one more step.

    +

    + Enter the public-share URL or link of the view or Page you are trying to see in the browser{"'"}s address + bar. +

    +
    +
    + +
    + ); +}); diff --git a/apps/space/core/components/common/logo-spinner.tsx b/apps/space/core/components/common/logo-spinner.tsx new file mode 100644 index 00000000..7b6a8e8f --- /dev/null +++ b/apps/space/core/components/common/logo-spinner.tsx @@ -0,0 +1,18 @@ +"use client"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif"; +import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif"; + +export const LogoSpinner = () => { + const { resolvedTheme } = useTheme(); + + const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark; + + return ( +
    + logo +
    + ); +}; diff --git a/apps/space/core/components/common/powered-by.tsx b/apps/space/core/components/common/powered-by.tsx new file mode 100644 index 00000000..653c150f --- /dev/null +++ b/apps/space/core/components/common/powered-by.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { FC } from "react"; +import { WEBSITE_URL } from "@plane/constants"; +// assets +import { PlaneLogo } from "@plane/propel/icons"; + +type TPoweredBy = { + disabled?: boolean; +}; + +export const PoweredBy: FC = (props) => { + // props + const { disabled = false } = props; + + if (disabled || !WEBSITE_URL) return null; + + return ( + + +
    + Powered by Plane Publish +
    +
    + ); +}; diff --git a/apps/space/core/components/common/project-logo.tsx b/apps/space/core/components/common/project-logo.tsx new file mode 100644 index 00000000..bea8e213 --- /dev/null +++ b/apps/space/core/components/common/project-logo.tsx @@ -0,0 +1,34 @@ +// types +import type { TLogoProps } from "@plane/types"; +// helpers +import { cn } from "@plane/utils"; + +type Props = { + className?: string; + logo: TLogoProps; +}; + +export const ProjectLogo: React.FC = (props) => { + const { className, logo } = props; + + if (logo.in_use === "icon" && logo.icon) + return ( + + {logo.icon.name} + + ); + + if (logo.in_use === "emoji" && logo.emoji) + return ( + + {logo.emoji.value?.split("-").map((emoji) => String.fromCodePoint(parseInt(emoji, 10)))} + + ); + + return ; +}; diff --git a/apps/space/core/components/editor/embeds/mentions/index.ts b/apps/space/core/components/editor/embeds/mentions/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/space/core/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/space/core/components/editor/embeds/mentions/root.tsx b/apps/space/core/components/editor/embeds/mentions/root.tsx new file mode 100644 index 00000000..95149b92 --- /dev/null +++ b/apps/space/core/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,17 @@ +// plane editor +import type { TMentionComponentProps } from "@plane/editor"; +// plane web components +import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor"; +// local components +import { EditorUserMention } from "./user"; + +export const EditorMentionsRoot: React.FC = (props) => { + const { entity_identifier, entity_name } = props; + + switch (entity_name) { + case "user_mention": + return ; + default: + return ; + } +}; diff --git a/apps/space/core/components/editor/embeds/mentions/user.tsx b/apps/space/core/components/editor/embeds/mentions/user.tsx new file mode 100644 index 00000000..f75bcba3 --- /dev/null +++ b/apps/space/core/components/editor/embeds/mentions/user.tsx @@ -0,0 +1,40 @@ +import { observer } from "mobx-react"; +// helpers +import { cn } from "@plane/utils"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useUser } from "@/hooks/store/use-user"; + +type Props = { + id: string; +}; + +export const EditorUserMention: React.FC = observer((props) => { + const { id } = props; + // store hooks + const { data: currentUser } = useUser(); + const { getMemberById } = useMember(); + // derived values + const userDetails = getMemberById(id); + + if (!userDetails) { + return ( +
    + @deactivated user +
    + ); + } + + return ( +
    + @{userDetails?.member__display_name} +
    + ); +}); diff --git a/apps/space/core/components/editor/lite-text-editor.tsx b/apps/space/core/components/editor/lite-text-editor.tsx new file mode 100644 index 00000000..5b33b581 --- /dev/null +++ b/apps/space/core/components/editor/lite-text-editor.tsx @@ -0,0 +1,90 @@ +import React from "react"; +// plane imports +import { LiteTextEditorWithRef } from "@plane/editor"; +import type { EditorRefApi, ILiteTextEditorProps, TFileHandler } from "@plane/editor"; +import type { MakeOptional } from "@plane/types"; +import { cn, isCommentEmpty } from "@plane/utils"; +// helpers +import { getEditorFileHandlers } from "@/helpers/editor.helper"; +import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; +// local imports +import { EditorMentionsRoot } from "./embeds/mentions"; +import { IssueCommentToolbar } from "./toolbar"; + +type LiteTextEditorWrapperProps = MakeOptional< + Omit, + "disabledExtensions" | "flaggedExtensions" +> & { + anchor: string; + isSubmitting?: boolean; + showSubmitButton?: boolean; + workspaceId: string; +} & ( + | { + editable: false; + } + | { + editable: true; + uploadFile: TFileHandler["upload"]; + } + ); + +export const LiteTextEditor = React.forwardRef((props, ref) => { + const { + anchor, + containerClassName, + disabledExtensions: additionalDisabledExtensions = [], + editable, + isSubmitting = false, + showSubmitButton = true, + workspaceId, + ...rest + } = props; + 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; + const { liteText: liteTextEditorExtensions } = useEditorFlagging(anchor); + + return ( +
    + "", + workspaceId, + })} + mentionHandler={{ + renderComponent: (props) => , + }} + extendedEditorProps={{}} + {...rest} + // overriding the containerClassName to add relative class passed + containerClassName={cn(containerClassName, "relative")} + /> + { + // 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={editorRef} + /> +
    + ); +}); + +LiteTextEditor.displayName = "LiteTextEditor"; diff --git a/apps/space/core/components/editor/rich-text-editor.tsx b/apps/space/core/components/editor/rich-text-editor.tsx new file mode 100644 index 00000000..1d48d7da --- /dev/null +++ b/apps/space/core/components/editor/rich-text-editor.tsx @@ -0,0 +1,69 @@ +import React, { forwardRef } from "react"; +// plane imports +import { RichTextEditorWithRef } from "@plane/editor"; +import type { EditorRefApi, IRichTextEditorProps, TFileHandler } from "@plane/editor"; +import type { MakeOptional } from "@plane/types"; +// helpers +import { getEditorFileHandlers } from "@/helpers/editor.helper"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +// plane web imports +import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; +// local imports +import { EditorMentionsRoot } from "./embeds/mentions"; + +type RichTextEditorWrapperProps = MakeOptional< + Omit, + "disabledExtensions" | "flaggedExtensions" +> & { + anchor: string; + workspaceId: string; +} & ( + | { + editable: false; + } + | { + editable: true; + uploadFile: TFileHandler["upload"]; + } + ); + +export const RichTextEditor = forwardRef((props, ref) => { + const { + anchor, + containerClassName, + editable, + workspaceId, + disabledExtensions: additionalDisabledExtensions = [], + ...rest + } = props; + const { getMemberById } = useMember(); + const { richText: richTextEditorExtensions } = useEditorFlagging(anchor); + + return ( + , + getMentionedEntityDetails: (id: string) => ({ + display_name: getMemberById(id)?.member__display_name ?? "", + }), + }} + ref={ref} + disabledExtensions={[...richTextEditorExtensions.disabled, ...additionalDisabledExtensions]} + editable={editable} + fileHandler={getEditorFileHandlers({ + anchor, + uploadFile: editable ? props.uploadFile : async () => "", + workspaceId, + })} + flaggedExtensions={richTextEditorExtensions.flagged} + extendedEditorProps={{}} + {...rest} + containerClassName={containerClassName} + editorClassName="min-h-[100px] py-2 overflow-hidden" + displayConfig={{ fontSize: "large-font" }} + /> + ); +}); + +RichTextEditor.displayName = "RichTextEditor"; diff --git a/apps/space/core/components/editor/toolbar.tsx b/apps/space/core/components/editor/toolbar.tsx new file mode 100644 index 00000000..bb6347f4 --- /dev/null +++ b/apps/space/core/components/editor/toolbar.tsx @@ -0,0 +1,116 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +// plane imports +import { TOOLBAR_ITEMS } from "@plane/editor"; +import type { ToolbarMenuItem, EditorRefApi } from "@plane/editor"; +import { Button } from "@plane/propel/button"; +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; + +type Props = { + executeCommand: (item: ToolbarMenuItem) => void; + handleSubmit: (event: React.MouseEvent) => void; + isCommentEmpty: boolean; + isSubmitting: boolean; + showSubmitButton: boolean; + editorRef: EditorRefApi | null; +}; + +const toolbarItems = TOOLBAR_ITEMS.lite; + +export const IssueCommentToolbar: React.FC = (props) => { + const { executeCommand, handleSubmit, isCommentEmpty, editorRef, isSubmitting, showSubmitButton } = props; + // states + const [activeStates, setActiveStates] = useState>({}); + + // Function to update active states + const updateActiveStates = useCallback(() => { + 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); + }, [editorRef]); + + // useEffect to call updateActiveStates when isActive prop changes + useEffect(() => { + if (!editorRef) return; + const unsubscribe = editorRef.onStateChange(updateActiveStates); + updateActiveStates(); + return () => unsubscribe(); + }, [editorRef, updateActiveStates]); + + return ( +
    +
    +
    + {Object.keys(toolbarItems).map((key, index) => ( +
    + {toolbarItems[key].map((item) => { + const isItemActive = activeStates[item.renderKey]; + + return ( + + {item.name} + {item.shortcut && {item.shortcut.join(" + ")}} +

    + } + > + +
    + ); + })} +
    + ))} +
    + {showSubmitButton && ( +
    + +
    + )} +
    +
    + ); +}; diff --git a/apps/space/core/components/instance/instance-failure-view.tsx b/apps/space/core/components/instance/instance-failure-view.tsx new file mode 100644 index 00000000..b1190285 --- /dev/null +++ b/apps/space/core/components/instance/instance-failure-view.tsx @@ -0,0 +1,39 @@ +"use client"; + +import type { FC } from "react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import { Button } from "@plane/propel/button"; +// assets +import InstanceFailureDarkImage from "public/instance/instance-failure-dark.svg"; +import InstanceFailureImage from "public/instance/instance-failure.svg"; + +export const InstanceFailureView: FC = () => { + const { resolvedTheme } = useTheme(); + + const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; + + const handleRetry = () => { + window.location.reload(); + }; + + return ( +
    +
    +
    + Plane instance failure image +

    Unable to fetch instance details.

    +

    + We were unable to fetch the details of the instance.
    + Fret not, it might just be a connectivity work items. +

    +
    +
    + +
    +
    +
    + ); +}; diff --git a/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx b/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx new file mode 100644 index 00000000..cb542fac --- /dev/null +++ b/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +// types +import { useTranslation } from "@plane/i18n"; +import type { TFilters } from "@/types/issue"; +// components +import { AppliedPriorityFilters } from "./priority"; +import { AppliedStateFilters } from "./state"; + +type Props = { + appliedFilters: TFilters; + handleRemoveAllFilters: () => void; + handleRemoveFilter: (key: keyof TFilters, value: string | null) => void; +}; + +export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); + +export const AppliedFiltersList: React.FC = observer((props) => { + const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter } = props; + const { t } = useTranslation(); + + return ( +
    + {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof TFilters; + const filterValue = value as TFilters[keyof TFilters]; + + if (!filterValue) return; + + return ( +
    + {replaceUnderscoreIfSnakeCase(filterKey)} +
    + {filterKey === "priority" && ( + handleRemoveFilter("priority", val)} + values={(filterValue ?? []) as TFilters["priority"]} + /> + )} + + {filterKey === "state" && ( + handleRemoveFilter("state", val)} + values={filterValue ?? []} + /> + )} + + +
    +
    + ); + })} + +
    + ); +}); diff --git a/apps/space/core/components/issues/filters/applied-filters/label.tsx b/apps/space/core/components/issues/filters/applied-filters/label.tsx new file mode 100644 index 00000000..5abbd54b --- /dev/null +++ b/apps/space/core/components/issues/filters/applied-filters/label.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { X } from "lucide-react"; +// types +import type { IIssueLabel } from "@/types/issue"; + +type Props = { + handleRemove: (val: string) => void; + labels: IIssueLabel[] | undefined; + values: string[]; +}; + +export const AppliedLabelsFilters: React.FC = (props) => { + const { handleRemove, labels, values } = props; + + return ( + <> + {values.map((labelId) => { + const labelDetails = labels?.find((l) => l.id === labelId); + + if (!labelDetails) return null; + + return ( +
    + + {labelDetails.name} + +
    + ); + })} + + ); +}; diff --git a/apps/space/core/components/issues/filters/applied-filters/priority.tsx b/apps/space/core/components/issues/filters/applied-filters/priority.tsx new file mode 100644 index 00000000..a687cb67 --- /dev/null +++ b/apps/space/core/components/issues/filters/applied-filters/priority.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { X } from "lucide-react"; +import { PriorityIcon } from "@plane/propel/icons"; +import type { TIssuePriorities } from "@plane/propel/icons"; + +type Props = { + handleRemove: (val: string) => void; + values: TIssuePriorities[]; +}; + +export const AppliedPriorityFilters: React.FC = (props) => { + const { handleRemove, values } = props; + + return ( + <> + {values && + values.length > 0 && + values.map((priority) => ( +
    + + {priority} + +
    + ))} + + ); +}; diff --git a/apps/space/core/components/issues/filters/applied-filters/root.tsx b/apps/space/core/components/issues/filters/applied-filters/root.tsx new file mode 100644 index 00000000..f67749f9 --- /dev/null +++ b/apps/space/core/components/issues/filters/applied-filters/root.tsx @@ -0,0 +1,103 @@ +"use client"; + +import type { FC } from "react"; +import { useCallback } from "react"; +import { cloneDeep } from "lodash-es"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +// hooks +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; +// store +import type { TIssueLayout, TIssueQueryFilters } from "@/types/issue"; +// components +import { AppliedFiltersList } from "./filters-list"; + +type TIssueAppliedFilters = { + anchor: string; +}; + +export const IssueAppliedFilters: FC = observer((props) => { + const { anchor } = props; + // router + const router = useRouter(); + // store hooks + const { getIssueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter(); + // derived values + const issueFilters = getIssueFilters(anchor); + const activeLayout = issueFilters?.display_filters?.layout || undefined; + const userFilters = issueFilters?.filters || {}; + + const appliedFilters: any = {}; + Object.entries(userFilters).forEach(([key, value]) => { + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + appliedFilters[key] = value; + }); + + const updateRouteParams = useCallback( + (key: keyof TIssueQueryFilters, value: string[]) => { + const state = key === "state" ? value : (issueFilters?.filters?.state ?? []); + const priority = key === "priority" ? value : (issueFilters?.filters?.priority ?? []); + const labels = key === "labels" ? value : (issueFilters?.filters?.labels ?? []); + + const params: { + board: TIssueLayout | string; + priority?: string; + states?: string; + labels?: string; + } = { + board: activeLayout || "list", + }; + + if (priority.length > 0) params.priority = priority.join(","); + if (state.length > 0) params.states = state.join(","); + if (labels.length > 0) params.labels = labels.join(","); + + const qs = new URLSearchParams(params).toString(); + router.push(`/issues/${anchor}?${qs}`); + }, + [activeLayout, anchor, issueFilters, router] + ); + + const handleFilters = useCallback( + (key: keyof TIssueQueryFilters, value: string | null) => { + let newValues = cloneDeep(issueFilters?.filters?.[key]) ?? []; + + if (value === null) newValues = []; + else if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1); + + updateIssueFilters(anchor, "filters", key, newValues); + updateRouteParams(key, newValues); + }, + [anchor, issueFilters, updateIssueFilters, updateRouteParams] + ); + + const handleRemoveAllFilters = () => { + initIssueFilters( + anchor, + { + display_filters: { layout: activeLayout || "list" }, + filters: { + state: [], + priority: [], + labels: [], + }, + }, + true + ); + + router.push(`/issues/${anchor}?${`board=${activeLayout || "list"}`}`); + }; + + if (Object.keys(appliedFilters).length === 0) return null; + + return ( +
    + +
    + ); +}); diff --git a/apps/space/core/components/issues/filters/applied-filters/state.tsx b/apps/space/core/components/issues/filters/applied-filters/state.tsx new file mode 100644 index 00000000..c80c8688 --- /dev/null +++ b/apps/space/core/components/issues/filters/applied-filters/state.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +// plane imports +import { EIconSize } from "@plane/constants"; +import { StateGroupIcon } from "@plane/propel/icons"; +// hooks +import { useStates } from "@/hooks/store/use-state"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; +}; + +export const AppliedStateFilters: React.FC = observer((props) => { + const { handleRemove, values } = props; + + const { sortedStates: states } = useStates(); + + return ( + <> + {values.map((stateId) => { + const stateDetails = states?.find((s) => s.id === stateId); + + if (!stateDetails) return null; + + return ( +
    + + {stateDetails.name} + +
    + ); + })} + + ); +}); diff --git a/apps/space/core/components/issues/filters/helpers/dropdown.tsx b/apps/space/core/components/issues/filters/helpers/dropdown.tsx new file mode 100644 index 00000000..e9f025b2 --- /dev/null +++ b/apps/space/core/components/issues/filters/helpers/dropdown.tsx @@ -0,0 +1,65 @@ +"use client"; + +import React, { Fragment, useState } from "react"; +import type { Placement } from "@popperjs/core"; +import { usePopper } from "react-popper"; +import { Popover, Transition } from "@headlessui/react"; +// ui +import { Button } from "@plane/propel/button"; + +type Props = { + children: React.ReactNode; + title?: string; + placement?: Placement; +}; + +export const FiltersDropdown: React.FC = (props) => { + const { children, title = "Dropdown", placement } = props; + + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "auto", + }); + + return ( + + {({ open }) => { + if (open) { + } + return ( + <> + + + + + +
    +
    {children}
    +
    +
    +
    + + ); + }} +
    + ); +}; diff --git a/apps/space/core/components/issues/filters/helpers/filter-header.tsx b/apps/space/core/components/issues/filters/helpers/filter-header.tsx new file mode 100644 index 00000000..52d76651 --- /dev/null +++ b/apps/space/core/components/issues/filters/helpers/filter-header.tsx @@ -0,0 +1,24 @@ +"use client"; + +import React from "react"; +// lucide icons +import { ChevronDown, ChevronUp } from "lucide-react"; + +interface IFilterHeader { + title: string; + isPreviewEnabled: boolean; + handleIsPreviewEnabled: () => void; +} + +export const FilterHeader = ({ title, isPreviewEnabled, handleIsPreviewEnabled }: IFilterHeader) => ( +
    +
    {title}
    + +
    +); diff --git a/apps/space/core/components/issues/filters/helpers/filter-option.tsx b/apps/space/core/components/issues/filters/helpers/filter-option.tsx new file mode 100644 index 00000000..ba782b2c --- /dev/null +++ b/apps/space/core/components/issues/filters/helpers/filter-option.tsx @@ -0,0 +1,37 @@ +"use client"; + +import React from "react"; +// lucide icons +import { Check } from "lucide-react"; + +type Props = { + icon?: React.ReactNode; + isChecked: boolean; + title: React.ReactNode; + onClick?: () => void; + multiple?: boolean; +}; + +export const FilterOption: React.FC = (props) => { + const { icon, isChecked, multiple = true, onClick, title } = props; + + return ( + + ); +}; diff --git a/apps/space/core/components/issues/filters/index.ts b/apps/space/core/components/issues/filters/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/space/core/components/issues/filters/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/space/core/components/issues/filters/labels.tsx b/apps/space/core/components/issues/filters/labels.tsx new file mode 100644 index 00000000..a3effcf2 --- /dev/null +++ b/apps/space/core/components/issues/filters/labels.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React, { useState } from "react"; +// plane imports +import { Loader } from "@plane/ui"; +// types +import type { IIssueLabel } from "@/types/issue"; +// local imports +import { FilterHeader } from "./helpers/filter-header"; +import { FilterOption } from "./helpers/filter-option"; + +const LabelIcons = ({ color }: { color: string }) => ( + +); + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + labels: IIssueLabel[] | undefined; + searchQuery: string; +}; + +export const FilterLabels: React.FC = (props) => { + const { appliedFilters, handleUpdate, labels, searchQuery } = props; + + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
    + {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((label) => ( + handleUpdate(label?.id)} + icon={} + title={label.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

    No matches found

    + ) + ) : ( + + + + + + )} +
    + )} + + ); +}; diff --git a/apps/space/core/components/issues/filters/priority.tsx b/apps/space/core/components/issues/filters/priority.tsx new file mode 100644 index 00000000..674b052f --- /dev/null +++ b/apps/space/core/components/issues/filters/priority.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { ISSUE_PRIORITY_FILTERS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { PriorityIcon } from "@plane/propel/icons"; +// local imports +import { FilterHeader } from "./helpers/filter-header"; +import { FilterOption } from "./helpers/filter-option"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterPriority: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + // hooks + const { t } = useTranslation(); + + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = ISSUE_PRIORITY_FILTERS.filter((p) => p.key.includes(searchQuery.toLowerCase())); + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
    + {filteredOptions.length > 0 ? ( + filteredOptions.map((priority) => ( + handleUpdate(priority.key)} + icon={} + title={t(priority.titleTranslationKey)} + /> + )) + ) : ( +

    {t("common.search.no_matches_found")}

    + )} +
    + )} + + ); +}); diff --git a/apps/space/core/components/issues/filters/root.tsx b/apps/space/core/components/issues/filters/root.tsx new file mode 100644 index 00000000..8899a337 --- /dev/null +++ b/apps/space/core/components/issues/filters/root.tsx @@ -0,0 +1,72 @@ +"use client"; + +import type { FC } from "react"; +import { useCallback } from "react"; +import { cloneDeep } from "lodash-es"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +// constants +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@plane/constants"; +// components +import { FiltersDropdown } from "@/components/issues/filters/helpers/dropdown"; +import { FilterSelection } from "@/components/issues/filters/selection"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; +// types +import type { TIssueQueryFilters } from "@/types/issue"; + +type IssueFiltersDropdownProps = { + anchor: string; +}; + +export const IssueFiltersDropdown: FC = observer((props) => { + const { anchor } = props; + // router + const router = useRouter(); + // hooks + const { getIssueFilters, updateIssueFilters } = useIssueFilter(); + // derived values + const issueFilters = getIssueFilters(anchor); + const activeLayout = issueFilters?.display_filters?.layout || undefined; + + const updateRouteParams = useCallback( + (key: keyof TIssueQueryFilters, value: string[]) => { + const state = key === "state" ? value : (issueFilters?.filters?.state ?? []); + const priority = key === "priority" ? value : (issueFilters?.filters?.priority ?? []); + const labels = key === "labels" ? value : (issueFilters?.filters?.labels ?? []); + + const { queryParam } = queryParamGenerator({ board: activeLayout, priority, state, labels }); + router.push(`/issues/${anchor}?${queryParam}`); + }, + [anchor, activeLayout, issueFilters, router] + ); + + const handleFilters = useCallback( + (key: keyof TIssueQueryFilters, value: string) => { + if (!value) return; + + const newValues = cloneDeep(issueFilters?.filters?.[key]) ?? []; + + if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + + updateIssueFilters(anchor, "filters", key, newValues); + updateRouteParams(key, newValues); + }, + [anchor, issueFilters, updateIssueFilters, updateRouteParams] + ); + + return ( +
    + + + +
    + ); +}); diff --git a/apps/space/core/components/issues/filters/selection.tsx b/apps/space/core/components/issues/filters/selection.tsx new file mode 100644 index 00000000..3042a419 --- /dev/null +++ b/apps/space/core/components/issues/filters/selection.tsx @@ -0,0 +1,82 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import { Search, X } from "lucide-react"; +// types +import type { IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue"; +// local imports +import { FilterPriority } from "./priority"; +import { FilterState } from "./state"; + +type Props = { + filters: IIssueFilterOptions; + handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void; + layoutDisplayFiltersOptions: TIssueFilterKeys[]; +}; + +export const FilterSelection: React.FC = observer((props) => { + const { filters, handleFilters, layoutDisplayFiltersOptions } = props; + + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions.includes(filter); + + return ( +
    +
    +
    + + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
    +
    +
    + {/* priority */} + {isFilterEnabled("priority") && ( +
    + handleFilters("priority", val)} + searchQuery={filtersSearchQuery} + /> +
    + )} + + {/* state */} + {isFilterEnabled("state") && ( +
    + handleFilters("state", val)} + searchQuery={filtersSearchQuery} + /> +
    + )} + + {/* labels */} + {/* {isFilterEnabled("labels") && ( +
    + handleFilters("labels", val)} + labels={labels} + searchQuery={filtersSearchQuery} + /> +
    + )} */} +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/filters/state.tsx b/apps/space/core/components/issues/filters/state.tsx new file mode 100644 index 00000000..a794d532 --- /dev/null +++ b/apps/space/core/components/issues/filters/state.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +// ui +import { EIconSize } from "@plane/constants"; +import { StateGroupIcon } from "@plane/propel/icons"; +import { Loader } from "@plane/ui"; +// hooks +import { useStates } from "@/hooks/store/use-state"; +// local imports +import { FilterHeader } from "./helpers/filter-header"; +import { FilterOption } from "./helpers/filter-option"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterState: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const { sortedStates: states } = useStates(); + + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = states?.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
    + {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((state) => ( + handleUpdate(state.id)} + icon={} + title={state.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

    No matches found

    + ) + ) : ( + + + + + + )} +
    + )} + + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/error.tsx b/apps/space/core/components/issues/issue-layouts/error.tsx new file mode 100644 index 00000000..34789c21 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/error.tsx @@ -0,0 +1,17 @@ +import Image from "next/image"; +// assets +import SomethingWentWrongImage from "public/something-went-wrong.svg"; + +export const SomethingWentWrongError = () => ( +
    +
    +
    +
    + Oops! Something went wrong +
    +
    +

    Oops! Something went wrong.

    +

    The public board does not exist. Please check the URL.

    +
    +
    +); diff --git a/apps/space/core/components/issues/issue-layouts/index.ts b/apps/space/core/components/issues/issue-layouts/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx b/apps/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx new file mode 100644 index 00000000..47384334 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx @@ -0,0 +1,35 @@ +import { observer } from "mobx-react"; +// plane imports +import type { TLoader } from "@plane/types"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; + +interface Props { + children: string | React.ReactNode | React.ReactNode[]; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; +} + +export const IssueLayoutHOC = observer((props: Props) => { + const { getIssueLoader, getGroupIssueCount } = props; + + const issueCount = getGroupIssueCount(undefined, undefined, false); + + if (getIssueLoader() === "init-loader" || issueCount === undefined) { + return ( +
    + +
    + ); + } + + if (getGroupIssueCount(undefined, undefined, false) === 0) { + return
    No work items Found
    ; + } + + return <>{props.children}; +}); diff --git a/apps/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/apps/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx new file mode 100644 index 00000000..b65b9392 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { debounce } from "lodash-es"; +import { observer } from "mobx-react"; +// types +import type { IIssueDisplayProperties } from "@plane/types"; +// components +import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC"; +// hooks +import { useIssue } from "@/hooks/store/use-issue"; + +import { KanBan } from "./default"; + +type Props = { + anchor: string; +}; +export const IssueKanbanLayoutRoot: React.FC = observer((props: Props) => { + const { anchor } = props; + // store hooks + const { groupedIssueIds, getIssueLoader, fetchNextPublicIssues, getGroupIssueCount, getPaginationData } = useIssue(); + + const displayProperties: IIssueDisplayProperties = useMemo( + () => ({ + key: true, + state: true, + labels: true, + priority: true, + due_date: true, + }), + [] + ); + + const fetchMoreIssues = useCallback( + (groupId?: string, subgroupId?: string) => { + if (getIssueLoader(groupId, subgroupId) !== "pagination") { + fetchNextPublicIssues(anchor, groupId, subgroupId); + } + }, + [anchor, getIssueLoader, fetchNextPublicIssues] + ); + + const debouncedFetchMoreIssues = debounce( + (groupId?: string, subgroupId?: string) => fetchMoreIssues(groupId, subgroupId), + 300, + { leading: true, trailing: false } + ); + + const scrollableContainerRef = useRef(null); + + return ( + +
    +
    +
    + +
    +
    +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx b/apps/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx new file mode 100644 index 00000000..89769322 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx @@ -0,0 +1,46 @@ +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane utils +import { cn } from "@plane/utils"; +// components +import { IssueEmojiReactions } from "@/components/issues/reactions/issue-emoji-reactions"; +import { IssueVotes } from "@/components/issues/reactions/issue-vote-reactions"; +// hooks +import { usePublish } from "@/hooks/store/publish"; + +type Props = { + issueId: string; +}; +export const BlockReactions = observer((props: Props) => { + const { issueId } = props; + const { anchor } = useParams(); + const { canVote, canReact } = usePublish(anchor.toString()); + + // if the user cannot vote or react then return empty + if (!canVote && !canReact) return <>; + + return ( +
    +
    + {canVote && ( +
    + +
    + )} + {canReact && ( +
    + +
    + )} +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/kanban/block.tsx b/apps/space/core/components/issues/issue-layouts/kanban/block.tsx new file mode 100644 index 00000000..e98502b3 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/kanban/block.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { MutableRefObject } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams, useSearchParams } from "next/navigation"; +// plane types +import { Tooltip } from "@plane/propel/tooltip"; +import type { IIssueDisplayProperties } from "@plane/types"; +// plane ui +// plane utils +import { cn } from "@plane/utils"; +// components +import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +// +import type { IIssue } from "@/types/issue"; +import { IssueProperties } from "../properties/all-properties"; +import { getIssueBlockId } from "../utils"; +import { BlockReactions } from "./block-reactions"; + +interface IssueBlockProps { + issueId: string; + groupId: string; + subGroupId: string; + displayProperties: IIssueDisplayProperties | undefined; + scrollableContainerRef?: MutableRefObject; +} + +interface IssueDetailsBlockProps { + issue: IIssue; + displayProperties: IIssueDisplayProperties | undefined; +} + +const KanbanIssueDetailsBlock: React.FC = observer((props) => { + const { issue, displayProperties } = props; + const { anchor } = useParams(); + // hooks + const { project_details } = usePublish(anchor.toString()); + + return ( +
    + +
    +
    + {project_details?.identifier}-{issue.sequence_id} +
    +
    +
    + +
    + + {issue.name} + +
    + + +
    + ); +}); + +export const KanbanIssueBlock: React.FC = observer((props) => { + const { issueId, groupId, subGroupId, displayProperties } = props; + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board"); + // hooks + const { setPeekId, getIsIssuePeeked, getIssueById } = useIssueDetails(); + + const handleIssuePeekOverview = () => { + setPeekId(issueId); + }; + + const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId }); + + const issue = getIssueById(issueId); + + if (!issue) return null; + + return ( +
    +
    + + + + +
    +
    + ); +}); + +KanbanIssueBlock.displayName = "KanbanIssueBlock"; diff --git a/apps/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx b/apps/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx new file mode 100644 index 00000000..c5cea19c --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -0,0 +1,45 @@ +import type { MutableRefObject } from "react"; +import { observer } from "mobx-react"; +//types +import type { IIssueDisplayProperties } from "@plane/types"; +// components +import { KanbanIssueBlock } from "./block"; + +interface IssueBlocksListProps { + subGroupId: string; + groupId: string; + issueIds: string[]; + displayProperties: IIssueDisplayProperties | undefined; + scrollableContainerRef?: MutableRefObject; +} + +export const KanbanIssueBlocksList: React.FC = observer((props) => { + const { subGroupId, groupId, issueIds, displayProperties, scrollableContainerRef } = props; + + return ( + <> + {issueIds && issueIds.length > 0 ? ( + <> + {issueIds.map((issueId) => { + if (!issueId) return null; + + let draggableId = issueId; + if (groupId) draggableId = `${draggableId}__${groupId}`; + if (subGroupId) draggableId = `${draggableId}__${subGroupId}`; + + return ( + + ); + })} + + ) : null} + + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/kanban/default.tsx b/apps/space/core/components/issues/issue-layouts/kanban/default.tsx new file mode 100644 index 00000000..e5e622e8 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/kanban/default.tsx @@ -0,0 +1,127 @@ +import type { MutableRefObject } from "react"; +import { isNil } from "lodash-es"; +import { observer } from "mobx-react"; +// types +import type { + GroupByColumnTypes, + IGroupByColumn, + TGroupedIssues, + IIssueDisplayProperties, + TSubGroupedIssues, + TIssueGroupByOptions, + TPaginationData, + TLoader, +} from "@plane/types"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useLabel } from "@/hooks/store/use-label"; +import { useMember } from "@/hooks/store/use-member"; +import { useModule } from "@/hooks/store/use-module"; +import { useStates } from "@/hooks/store/use-state"; +// +import { getGroupByColumns } from "../utils"; +// components +import { HeaderGroupByCard } from "./headers/group-by-card"; +import { KanbanGroup } from "./kanban-group"; + +export interface IKanBan { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + groupBy: TIssueGroupByOptions | undefined; + subGroupId?: string; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; + scrollableContainerRef?: MutableRefObject; + showEmptyGroup?: boolean; +} + +export const KanBan: React.FC = observer((props) => { + const { + groupedIssueIds, + displayProperties, + subGroupBy, + groupBy, + subGroupId = "null", + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + scrollableContainerRef, + showEmptyGroup = true, + } = props; + + const member = useMember(); + const label = useLabel(); + const cycle = useCycle(); + const modules = useModule(); + const state = useStates(); + + const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member); + + if (!groupList) return null; + + const visibilityGroupBy = (_list: IGroupByColumn): { showGroup: boolean; showIssues: boolean } => { + const groupVisibility = { + showGroup: true, + showIssues: true, + }; + + if (!showEmptyGroup) { + groupVisibility.showGroup = (getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0; + } + return groupVisibility; + }; + + return ( +
    + {groupList && + groupList.length > 0 && + groupList.map((subList: IGroupByColumn) => { + const groupByVisibilityToggle = visibilityGroupBy(subList); + + if (groupByVisibilityToggle.showGroup === false) return <>; + return ( +
    + {isNil(subGroupBy) && ( +
    + +
    + )} + + {groupByVisibilityToggle.showIssues && ( + + )} +
    + ); + })} +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx new file mode 100644 index 00000000..5f56b9c2 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -0,0 +1,36 @@ +"use client"; + +import type { FC } from "react"; +import React from "react"; +import { observer } from "mobx-react"; +import { Circle } from "lucide-react"; +// types +import type { TIssueGroupByOptions } from "@plane/types"; + +interface IHeaderGroupByCard { + groupBy: TIssueGroupByOptions | undefined; + icon?: React.ReactNode; + title: string; + count: number; +} + +export const HeaderGroupByCard: FC = observer((props) => { + const { icon, title, count } = props; + + return ( + <> +
    +
    + {icon ? icon : } +
    + +
    +
    + {title} +
    +
    {count || 0}
    +
    +
    + + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx b/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx new file mode 100644 index 00000000..fd7ba5f0 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx @@ -0,0 +1,36 @@ +import type { FC } from "react"; +import React from "react"; +import { observer } from "mobx-react"; +import { Circle, ChevronDown, ChevronUp } from "lucide-react"; +// mobx + +interface IHeaderSubGroupByCard { + icon?: React.ReactNode; + title: string; + count: number; + isExpanded: boolean; + toggleExpanded: () => void; +} + +export const HeaderSubGroupByCard: FC = observer((props) => { + const { icon, title, count, isExpanded, toggleExpanded } = props; + return ( +
    toggleExpanded()} + > +
    + {isExpanded ? : } +
    + +
    + {icon ? icon : } +
    + +
    +
    {title}
    +
    {count || 0}
    +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx b/apps/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx new file mode 100644 index 00000000..7fbcc5ae --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -0,0 +1,117 @@ +"use client"; + +import type { MutableRefObject } from "react"; +import { forwardRef, useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +//types +import type { + TGroupedIssues, + IIssueDisplayProperties, + TSubGroupedIssues, + TIssueGroupByOptions, + TPaginationData, + TLoader, +} from "@plane/types"; +import { cn } from "@plane/utils"; +// hooks +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; +// local imports +import { KanbanIssueBlocksList } from "./blocks-list"; + +interface IKanbanGroup { + groupId: string; + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + subGroupId: string; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; + scrollableContainerRef?: MutableRefObject; +} + +// Loader components +const KanbanIssueBlockLoader = forwardRef((props, ref) => ( + +)); +KanbanIssueBlockLoader.displayName = "KanbanIssueBlockLoader"; + +export const KanbanGroup = observer((props: IKanbanGroup) => { + const { + groupId, + subGroupId, + subGroupBy, + displayProperties, + groupedIssueIds, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + scrollableContainerRef, + } = props; + + // hooks + const [intersectionElement, setIntersectionElement] = useState(null); + const columnRef = useRef(null); + + const containerRef = subGroupBy && scrollableContainerRef ? scrollableContainerRef : columnRef; + + const loadMoreIssuesInThisGroup = useCallback(() => { + loadMoreIssues(groupId, subGroupId === "null" ? undefined : subGroupId); + }, [loadMoreIssues, groupId, subGroupId]); + + const isPaginating = !!getIssueLoader(groupId, subGroupId); + + useIntersectionObserver( + containerRef, + isPaginating ? null : intersectionElement, + loadMoreIssuesInThisGroup, + `0% 100% 100% 100%` + ); + + const isSubGroup = !!subGroupId && subGroupId !== "null"; + + const issueIds = isSubGroup + ? ((groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId] ?? []) + : ((groupedIssueIds as TGroupedIssues)?.[groupId] ?? []); + + const groupIssueCount = getGroupIssueCount(groupId, subGroupId, false) ?? 0; + const nextPageResults = getPaginationData(groupId, subGroupId)?.nextPageResults; + + const loadMore = isPaginating ? ( + + ) : ( +
    + {" "} + Load More ↓ +
    + ); + + const shouldLoadMore = nextPageResults === undefined ? issueIds?.length < groupIssueCount : !!nextPageResults; + + return ( +
    + + + {shouldLoadMore && (isSubGroup ? <>{loadMore} : )} +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx b/apps/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx new file mode 100644 index 00000000..2799ee28 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -0,0 +1,298 @@ +import type { MutableRefObject } from "react"; +import { useState } from "react"; +import { observer } from "mobx-react"; +// types +import type { + GroupByColumnTypes, + IGroupByColumn, + TGroupedIssues, + IIssueDisplayProperties, + TSubGroupedIssues, + TIssueGroupByOptions, + TIssueOrderByOptions, + TPaginationData, + TLoader, +} from "@plane/types"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useLabel } from "@/hooks/store/use-label"; +import { useMember } from "@/hooks/store/use-member"; +import { useModule } from "@/hooks/store/use-module"; +import { useStates } from "@/hooks/store/use-state"; +// +import { getGroupByColumns } from "../utils"; +import { KanBan } from "./default"; +import { HeaderGroupByCard } from "./headers/group-by-card"; +import { HeaderSubGroupByCard } from "./headers/sub-group-by-card"; + +export interface IKanBanSwimLanes { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + groupBy: TIssueGroupByOptions | undefined; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; + showEmptyGroup: boolean; + scrollableContainerRef?: MutableRefObject; + orderBy: TIssueOrderByOptions | undefined; +} + +export const KanBanSwimLanes: React.FC = observer((props) => { + const { + groupedIssueIds, + displayProperties, + subGroupBy, + groupBy, + orderBy, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + showEmptyGroup, + scrollableContainerRef, + } = props; + + const member = useMember(); + const label = useLabel(); + const cycle = useCycle(); + const modules = useModule(); + const state = useStates(); + + const groupByList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member); + const subGroupByList = getGroupByColumns(subGroupBy as GroupByColumnTypes, cycle, modules, label, state, member); + + if (!groupByList || !subGroupByList) return null; + + return ( +
    +
    + +
    + + {subGroupBy && ( + + )} +
    + ); +}); + +interface ISubGroupSwimlaneHeader { + subGroupBy: TIssueGroupByOptions | undefined; + groupBy: TIssueGroupByOptions | undefined; + groupList: IGroupByColumn[]; + showEmptyGroup: boolean; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; +} + +const visibilitySubGroupByGroupCount = (subGroupIssueCount: number, showEmptyGroup: boolean): boolean => { + let subGroupHeaderVisibility = true; + + if (showEmptyGroup) subGroupHeaderVisibility = true; + else { + if (subGroupIssueCount > 0) subGroupHeaderVisibility = true; + else subGroupHeaderVisibility = false; + } + + return subGroupHeaderVisibility; +}; + +const SubGroupSwimlaneHeader: React.FC = observer( + ({ subGroupBy, groupBy, groupList, showEmptyGroup, getGroupIssueCount }) => ( +
    + {groupList && + groupList.length > 0 && + groupList.map((group: IGroupByColumn) => { + const groupCount = getGroupIssueCount(group.id, undefined, false) ?? 0; + + const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount(groupCount, showEmptyGroup); + + if (subGroupByVisibilityToggle === false) return <>; + return ( +
    + +
    + ); + })} +
    + ) +); + +interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + showEmptyGroup: boolean; + displayProperties: IIssueDisplayProperties | undefined; + orderBy: TIssueOrderByOptions | undefined; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; + scrollableContainerRef?: MutableRefObject; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; +} + +const SubGroupSwimlane: React.FC = observer((props) => { + const { + groupedIssueIds, + subGroupBy, + groupBy, + groupList, + displayProperties, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + showEmptyGroup, + scrollableContainerRef, + } = props; + + return ( +
    + {groupList && + groupList.length > 0 && + groupList.map((group: IGroupByColumn) => ( + + ))} +
    + ); +}); + +interface ISubGroup { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + showEmptyGroup: boolean; + displayProperties: IIssueDisplayProperties | undefined; + groupBy: TIssueGroupByOptions | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + group: IGroupByColumn; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; + scrollableContainerRef?: MutableRefObject; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; +} + +const SubGroup: React.FC = observer((props) => { + const { + groupedIssueIds, + subGroupBy, + groupBy, + group, + displayProperties, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + showEmptyGroup, + scrollableContainerRef, + } = props; + + const [isExpanded, setIsExpanded] = useState(true); + + const toggleExpanded = () => { + setIsExpanded((prevState) => !prevState); + }; + + const visibilitySubGroupBy = ( + _list: IGroupByColumn, + subGroupCount: number + ): { showGroup: boolean; showIssues: boolean } => { + const subGroupVisibility = { + showGroup: true, + showIssues: true, + }; + if (showEmptyGroup) subGroupVisibility.showGroup = true; + else { + if (subGroupCount > 0) subGroupVisibility.showGroup = true; + else subGroupVisibility.showGroup = false; + } + return subGroupVisibility; + }; + + const issueCount = getGroupIssueCount(undefined, group.id, true) ?? 0; + const subGroupByVisibilityToggle = visibilitySubGroupBy(group, issueCount); + if (subGroupByVisibilityToggle.showGroup === false) return <>; + + return ( + <> +
    +
    +
    + +
    +
    + + {subGroupByVisibilityToggle.showIssues && isExpanded && ( +
    + +
    + )} +
    + + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/list/base-list-root.tsx b/apps/space/core/components/issues/issue-layouts/list/base-list-root.tsx new file mode 100644 index 00000000..c3d498e3 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/list/base-list-root.tsx @@ -0,0 +1,63 @@ +import { useCallback, useMemo } from "react"; +import { observer } from "mobx-react"; +// types +import type { IIssueDisplayProperties, TGroupedIssues } from "@plane/types"; +// constants +// components +import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC"; +// hooks +import { useIssue } from "@/hooks/store/use-issue"; +import { List } from "./default"; + +type Props = { + anchor: string; +}; + +export const IssuesListLayoutRoot = observer((props: Props) => { + const { anchor } = props; + // store hooks + const { + groupedIssueIds: storeGroupedIssueIds, + fetchNextPublicIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + } = useIssue(); + + const groupedIssueIds = storeGroupedIssueIds as TGroupedIssues | undefined; + // auth + const displayProperties: IIssueDisplayProperties = useMemo( + () => ({ + key: true, + state: true, + labels: true, + priority: true, + due_date: true, + }), + [] + ); + + const loadMoreIssues = useCallback( + (groupId?: string) => { + fetchNextPublicIssues(anchor, groupId); + }, + [anchor, fetchNextPublicIssues] + ); + + return ( + +
    + +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/list/block.tsx b/apps/space/core/components/issues/issue-layouts/list/block.tsx new file mode 100644 index 00000000..522d46a9 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/list/block.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useRef } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams, useSearchParams } from "next/navigation"; +// plane types +import { Tooltip } from "@plane/propel/tooltip"; +import type { IIssueDisplayProperties } from "@plane/types"; +// plane ui +// plane utils +import { cn } from "@plane/utils"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +// +import { IssueProperties } from "../properties/all-properties"; + +interface IssueBlockProps { + issueId: string; + groupId: string; + displayProperties: IIssueDisplayProperties | undefined; +} + +export const IssueBlock = observer((props: IssueBlockProps) => { + const { anchor } = useParams(); + const { issueId, displayProperties } = props; + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board"); + // ref + const issueRef = useRef(null); + // hooks + const { project_details } = usePublish(anchor.toString()); + const { getIsIssuePeeked, setPeekId, getIssueById } = useIssueDetails(); + + const handleIssuePeekOverview = () => { + setPeekId(issueId); + }; + + const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId }); + + const issue = getIssueById(issueId); + + if (!issue) return null; + + const projectIdentifier = project_details?.identifier; + + return ( +
    +
    +
    +
    + {displayProperties && displayProperties?.key && ( +
    + {projectIdentifier}-{issue.sequence_id} +
    + )} +
    + + + +

    {issue.name}

    +
    + +
    +
    +
    + +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx b/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx new file mode 100644 index 00000000..bf1b202f --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx @@ -0,0 +1,25 @@ +import type { FC, MutableRefObject } from "react"; +// types +import type { IIssueDisplayProperties } from "@plane/types"; +import { IssueBlock } from "./block"; + +interface Props { + issueIds: string[] | undefined; + groupId: string; + displayProperties?: IIssueDisplayProperties; + containerRef: MutableRefObject; +} + +export const IssueBlocksList: FC = (props) => { + const { issueIds = [], groupId, displayProperties } = props; + + return ( +
    + {issueIds && + issueIds?.length > 0 && + issueIds.map((issueId: string) => ( + + ))} +
    + ); +}; diff --git a/apps/space/core/components/issues/issue-layouts/list/default.tsx b/apps/space/core/components/issues/issue-layouts/list/default.tsx new file mode 100644 index 00000000..a10333a6 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/list/default.tsx @@ -0,0 +1,90 @@ +import { useRef } from "react"; +import { observer } from "mobx-react"; +// types +import type { + GroupByColumnTypes, + TGroupedIssues, + IIssueDisplayProperties, + TIssueGroupByOptions, + IGroupByColumn, + TPaginationData, + TLoader, +} from "@plane/types"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useLabel } from "@/hooks/store/use-label"; +import { useMember } from "@/hooks/store/use-member"; +import { useModule } from "@/hooks/store/use-module"; +import { useStates } from "@/hooks/store/use-state"; +// +import { getGroupByColumns } from "../utils"; +import { ListGroup } from "./list-group"; + +export interface IList { + groupedIssueIds: TGroupedIssues; + groupBy: TIssueGroupByOptions | undefined; + displayProperties: IIssueDisplayProperties | undefined; + showEmptyGroup?: boolean; + loadMoreIssues: (groupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; +} + +export const List: React.FC = observer((props) => { + const { + groupedIssueIds, + groupBy, + displayProperties, + showEmptyGroup, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + } = props; + + const containerRef = useRef(null); + + const member = useMember(); + const label = useLabel(); + const cycle = useCycle(); + const modules = useModule(); + const state = useStates(); + + const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member, true); + + if (!groupList) return null; + + return ( +
    + {groupList && ( + <> +
    + {groupList.map((group: IGroupByColumn) => ( + + ))} +
    + + )} +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/list/headers/group-by-card.tsx b/apps/space/core/components/issues/issue-layouts/list/headers/group-by-card.tsx new file mode 100644 index 00000000..e92e9dae --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { observer } from "mobx-react"; +import { CircleDashed } from "lucide-react"; + +interface IHeaderGroupByCard { + groupID: string; + icon?: React.ReactNode; + title: string; + count: number; + toggleListGroup: (id: string) => void; +} + +export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => { + const { groupID, icon, title, count, toggleListGroup } = props; + + return ( + <> +
    toggleListGroup(groupID)} + > +
    + {icon ?? } +
    + +
    +
    {title}
    +
    {count || 0}
    +
    +
    + + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/list/list-group.tsx b/apps/space/core/components/issues/issue-layouts/list/list-group.tsx new file mode 100644 index 00000000..f62cd023 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/list/list-group.tsx @@ -0,0 +1,140 @@ +"use client"; + +import type { MutableRefObject } from "react"; +import { Fragment, forwardRef, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// plane types +import type { + IGroupByColumn, + TIssueGroupByOptions, + IIssueDisplayProperties, + TPaginationData, + TLoader, +} from "@plane/types"; +// plane utils +import { cn } from "@plane/utils"; +// hooks +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; +// +import { IssueBlocksList } from "./blocks-list"; +import { HeaderGroupByCard } from "./headers/group-by-card"; + +interface Props { + groupIssueIds: string[] | undefined; + group: IGroupByColumn; + groupBy: TIssueGroupByOptions | undefined; + displayProperties: IIssueDisplayProperties | undefined; + containerRef: MutableRefObject; + showEmptyGroup?: boolean; + loadMoreIssues: (groupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; +} + +// List loader component +const ListLoaderItemRow = forwardRef((props, ref) => ( +
    +
    + + +
    +
    + {[...Array(6)].map((_, index) => ( + + + + ))} +
    +
    +)); +ListLoaderItemRow.displayName = "ListLoaderItemRow"; + +export const ListGroup = observer((props: Props) => { + const { + groupIssueIds = [], + group, + groupBy, + displayProperties, + containerRef, + showEmptyGroup, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + } = props; + const [isExpanded, setIsExpanded] = useState(true); + const groupRef = useRef(null); + // hooks + const { t } = useTranslation(); + + const [intersectionElement, setIntersectionElement] = useState(null); + + const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0; + const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults; + const isPaginating = !!getIssueLoader(group.id); + + useIntersectionObserver(containerRef, isPaginating ? null : intersectionElement, loadMoreIssues, `100% 0% 100% 0%`); + + const shouldLoadMore = + nextPageResults === undefined && groupIssueCount !== undefined && groupIssueIds + ? groupIssueIds.length < groupIssueCount + : !!nextPageResults; + + const loadMore = isPaginating ? ( + + ) : ( +
    loadMoreIssues(group.id)} + > + {t("common.load_more")} ↓ +
    + ); + + const validateEmptyIssueGroups = (issueCount: number = 0) => { + if (!showEmptyGroup && issueCount <= 0) return false; + return true; + }; + + const toggleListGroup = () => { + setIsExpanded((prevState) => !prevState); + }; + + const shouldExpand = (!!groupIssueCount && isExpanded) || !groupBy; + + return validateEmptyIssueGroups(groupIssueCount) ? ( +
    +
    + +
    + {shouldExpand && ( +
    + {groupIssueIds && ( + + )} + + {shouldLoadMore && (groupBy ? <>{loadMore} : )} +
    + )} +
    + ) : null; +}); diff --git a/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx b/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx new file mode 100644 index 00000000..1162331d --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { observer } from "mobx-react"; +import { Link, Paperclip } from "lucide-react"; +import { ViewsIcon } from "@plane/propel/icons"; +// plane imports +import { Tooltip } from "@plane/propel/tooltip"; +import type { IIssueDisplayProperties } from "@plane/types"; +import { cn } from "@plane/utils"; +// components +import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; +//// hooks +import type { IIssue } from "@/types/issue"; +import { IssueBlockCycle } from "./cycle"; +import { IssueBlockDate } from "./due-date"; +import { IssueBlockLabels } from "./labels"; +import { IssueBlockMembers } from "./member"; +import { IssueBlockModules } from "./modules"; +import { IssueBlockPriority } from "./priority"; +import { IssueBlockState } from "./state"; + +export interface IIssueProperties { + issue: IIssue; + displayProperties: IIssueDisplayProperties | undefined; + className: string; +} + +export const IssueProperties: React.FC = observer((props) => { + const { issue, displayProperties, className } = props; + + if (!displayProperties || !issue.project_id) return null; + + const minDate = getDate(issue.start_date); + minDate?.setDate(minDate.getDate()); + + const maxDate = getDate(issue.target_date); + maxDate?.setDate(maxDate.getDate()); + + return ( +
    + {/* basic properties */} + {/* state */} + {issue.state_id && ( + +
    + +
    +
    + )} + + {/* priority */} + +
    + +
    +
    + + {/* label */} + +
    + +
    +
    + + {/* start date */} + {issue?.start_date && ( + +
    + +
    +
    + )} + + {/* target/due date */} + {issue?.target_date && ( + +
    + +
    +
    + )} + + {/* assignee */} + +
    + +
    +
    + + {/* modules */} + {issue.module_ids && issue.module_ids.length > 0 && ( + +
    + +
    +
    + )} + + {/* cycles */} + {issue.cycle_id && ( + +
    + +
    +
    + )} + + {/* estimates */} + {/* {projectId && areEstimateEnabledByProjectId(projectId?.toString()) && ( + +
    + +
    +
    + )} */} + + {/* extra render properties */} + {/* sub-issues */} + !!properties.sub_issue_count && !!issue.sub_issues_count} + > + +
    + +
    {issue.sub_issues_count}
    +
    +
    +
    + + {/* attachments */} + !!properties.attachment_count && !!issue.attachment_count} + > + +
    + +
    {issue.attachment_count}
    +
    +
    +
    + + {/* link */} + !!properties.link && !!issue.link_count} + > + +
    + +
    {issue.link_count}
    +
    +
    +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx b/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx new file mode 100644 index 00000000..0df14f1e --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane ui +import { CycleIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +// plane utils +import { cn } from "@plane/utils"; +//hooks +import { useCycle } from "@/hooks/store/use-cycle"; + +type Props = { + cycleId: string | undefined; + shouldShowBorder?: boolean; +}; + +export const IssueBlockCycle = observer(({ cycleId, shouldShowBorder = true }: Props) => { + const { getCycleById } = useCycle(); + + const cycle = getCycleById(cycleId); + + return ( + +
    +
    + +
    {cycle?.name ?? "No Cycle"}
    +
    +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx b/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx new file mode 100644 index 00000000..2f166983 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { observer } from "mobx-react"; +import { CalendarCheck2 } from "lucide-react"; +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; +// helpers +import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; +// hooks +import { useStates } from "@/hooks/store/use-state"; + +type Props = { + due_date: string | undefined; + stateId: string | undefined; + shouldHighLight?: boolean; + shouldShowBorder?: boolean; +}; + +export const IssueBlockDate = observer((props: Props) => { + const { due_date, stateId, shouldHighLight = true, shouldShowBorder = true } = props; + const { getStateById } = useStates(); + + const state = getStateById(stateId); + + const formattedDate = renderFormattedDate(due_date); + + return ( + +
    + + {formattedDate ? formattedDate : "No Date"} +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/properties/labels.tsx b/apps/space/core/components/issues/issue-layouts/properties/labels.tsx new file mode 100644 index 00000000..12ed76d3 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/properties/labels.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { observer } from "mobx-react"; +import { Tags } from "lucide-react"; +// plane imports +import { Tooltip } from "@plane/propel/tooltip"; +// hooks +import { useLabel } from "@/hooks/store/use-label"; + +type Props = { + labelIds: string[]; + shouldShowLabel?: boolean; +}; + +export const IssueBlockLabels = observer(({ labelIds, shouldShowLabel = false }: Props) => { + const { getLabelsByIds } = useLabel(); + + const labels = getLabelsByIds(labelIds); + + const labelsString = labels.length > 0 ? labels.map((label) => label.name).join(", ") : "No Labels"; + + if (labels.length <= 0) + return ( + +
    + + {shouldShowLabel && No Labels} +
    +
    + ); + + return ( +
    + {labels.length <= 2 ? ( + <> + {labels.map((label) => ( + +
    +
    + +
    {label?.name}
    +
    +
    +
    + ))} + + ) : ( +
    + +
    + + {`${labels.length} Labels`} +
    +
    +
    + )} +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/properties/member.tsx b/apps/space/core/components/issues/issue-layouts/properties/member.tsx new file mode 100644 index 00000000..a5baae8a --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/properties/member.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { observer } from "mobx-react"; +// icons +import type { LucideIcon } from "lucide-react"; +import { Users } from "lucide-react"; +// plane ui +import { Avatar, AvatarGroup } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +// +import type { TPublicMember } from "@/types/member"; + +type Props = { + memberIds: string[]; + shouldShowBorder?: boolean; +}; + +type AvatarProps = { + showTooltip: boolean; + members: TPublicMember[]; + icon?: LucideIcon; +}; + +export const ButtonAvatars: React.FC = observer((props: AvatarProps) => { + const { showTooltip, members, icon: Icon } = props; + + if (Array.isArray(members)) { + if (members.length > 1) { + return ( + + {members.map((member) => { + if (!member) return; + return ; + })} + + ); + } else if (members.length === 1) { + return ( + + ); + } + } + + return Icon ? : ; +}); + +export const IssueBlockMembers = observer(({ memberIds, shouldShowBorder = true }: Props) => { + const { getMembersByIds } = useMember(); + + const members = getMembersByIds(memberIds); + + return ( +
    +
    +
    + + {!shouldShowBorder && members.length <= 1 && ( + {members?.[0]?.member__display_name ?? "No Assignees"} + )} +
    +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/properties/modules.tsx b/apps/space/core/components/issues/issue-layouts/properties/modules.tsx new file mode 100644 index 00000000..29c7a4ce --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/properties/modules.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane ui +import { ModuleIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +// plane utils +import { cn } from "@plane/utils"; +// hooks +import { useModule } from "@/hooks/store/use-module"; + +type Props = { + moduleIds: string[] | undefined; + shouldShowBorder?: boolean; +}; + +export const IssueBlockModules = observer(({ moduleIds, shouldShowBorder = true }: Props) => { + const { getModulesByIds } = useModule(); + + const modules = getModulesByIds(moduleIds ?? []); + + const modulesString = modules.map((module) => module.name).join(", "); + + return ( +
    + + {modules.length <= 1 ? ( +
    +
    + +
    {modules?.[0]?.name ?? "No Modules"}
    +
    +
    + ) : ( +
    +
    +
    {modules.length} Modules
    +
    +
    + )} +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/properties/priority.tsx b/apps/space/core/components/issues/issue-layouts/properties/priority.tsx new file mode 100644 index 00000000..720d9b46 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/properties/priority.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { SignalHigh } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +// types +import { PriorityIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { TIssuePriorities } from "@plane/types"; +// constants +import { cn, getIssuePriorityFilters } from "@plane/utils"; + +export const IssueBlockPriority = ({ + priority, + shouldShowName = false, +}: { + priority: TIssuePriorities | null; + shouldShowName?: boolean; +}) => { + // hooks + const { t } = useTranslation(); + const priority_detail = priority != null ? getIssuePriorityFilters(priority) : null; + + const priorityClasses = { + 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", + none: "hover:bg-custom-background-80 border-custom-border-300", + }; + + if (priority_detail === null) return <>; + + return ( + + + + ); +}; diff --git a/apps/space/core/components/issues/issue-layouts/properties/state.tsx b/apps/space/core/components/issues/issue-layouts/properties/state.tsx new file mode 100644 index 00000000..2613adc4 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/properties/state.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane ui +import { StateGroupIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +// plane utils +import { cn } from "@plane/utils"; +//hooks +import { useStates } from "@/hooks/store/use-state"; + +type Props = { + stateId: string | undefined; + shouldShowBorder?: boolean; +}; +export const IssueBlockState = observer(({ stateId, shouldShowBorder = true }: Props) => { + const { getStateById } = useStates(); + + const state = getStateById(stateId); + + return ( + +
    +
    + +
    {state?.name ?? "State"}
    +
    +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/root.tsx b/apps/space/core/components/issues/issue-layouts/root.tsx new file mode 100644 index 00000000..3f9ee8d6 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/root.tsx @@ -0,0 +1,78 @@ +"use client"; + +import type { FC } from "react"; +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// components +import { IssueAppliedFilters } from "@/components/issues/filters/applied-filters/root"; +import { IssuePeekOverview } from "@/components/issues/peek-overview"; +// hooks +import { useIssue } from "@/hooks/store/use-issue"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; +// store +import type { PublishStore } from "@/store/publish/publish.store"; +// local imports +import { SomethingWentWrongError } from "./error"; +import { IssueKanbanLayoutRoot } from "./kanban/base-kanban-root"; +import { IssuesListLayoutRoot } from "./list/base-list-root"; + +type Props = { + peekId: string | undefined; + publishSettings: PublishStore; +}; + +export const IssuesLayoutsRoot: FC = observer((props) => { + const { peekId, publishSettings } = props; + // store hooks + const { getIssueFilters } = useIssueFilter(); + const { fetchPublicIssues } = useIssue(); + const issueDetailStore = useIssueDetails(); + // derived values + const { anchor } = publishSettings; + const issueFilters = anchor ? getIssueFilters(anchor) : undefined; + // derived values + const activeLayout = issueFilters?.display_filters?.layout || undefined; + + const { error } = useSWR( + anchor ? `PUBLIC_ISSUES_${anchor}` : null, + anchor + ? () => fetchPublicIssues(anchor, "init-loader", { groupedBy: "state", canGroup: true, perPageCount: 50 }) + : null, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + + useEffect(() => { + if (peekId) { + issueDetailStore.setPeekId(peekId.toString()); + } + }, [peekId, issueDetailStore]); + + if (!anchor) return null; + + if (error) return ; + + return ( +
    + {peekId && } + {activeLayout && ( +
    + {/* applied filters */} + + + {activeLayout === "list" && ( +
    + +
    + )} + {activeLayout === "kanban" && ( +
    + +
    + )} +
    + )} +
    + ); +}); diff --git a/apps/space/core/components/issues/issue-layouts/utils.tsx b/apps/space/core/components/issues/issue-layouts/utils.tsx new file mode 100644 index 00000000..f36a76c2 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/utils.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { isNil } from "lodash-es"; +// types +import { EIconSize, ISSUE_PRIORITIES } from "@plane/constants"; +import { CycleGroupIcon, CycleIcon, ModuleIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; +import type { + GroupByColumnTypes, + IGroupByColumn, + TCycleGroups, + IIssueDisplayProperties, + TGroupedIssues, +} from "@plane/types"; +// ui +import { Avatar } from "@plane/ui"; +// components +// constants +// stores +import type { ICycleStore } from "@/store/cycle.store"; +import type { IIssueLabelStore } from "@/store/label.store"; +import type { IIssueMemberStore } from "@/store/members.store"; +import type { IIssueModuleStore } from "@/store/module.store"; +import type { IStateStore } from "@/store/state.store"; + +export const HIGHLIGHT_CLASS = "highlight"; +export const HIGHLIGHT_WITH_LINE = "highlight-with-line"; + +export const getGroupByColumns = ( + groupBy: GroupByColumnTypes | null, + cycle: ICycleStore, + module: IIssueModuleStore, + label: IIssueLabelStore, + projectState: IStateStore, + member: IIssueMemberStore, + includeNone?: boolean +): IGroupByColumn[] | undefined => { + switch (groupBy) { + case "cycle": + return getCycleColumns(cycle); + case "module": + return getModuleColumns(module); + case "state": + return getStateColumns(projectState); + case "priority": + return getPriorityColumns(); + case "labels": + return getLabelsColumns(label) as any; + case "assignees": + return getAssigneeColumns(member) as any; + case "created_by": + return getCreatedByColumns(member) as any; + default: + if (includeNone) return [{ id: `All Issues`, name: `All work items`, payload: {}, icon: undefined }]; + } +}; + +const getCycleColumns = (cycleStore: ICycleStore): IGroupByColumn[] | undefined => { + const { cycles } = cycleStore; + + if (!cycles) return; + + const cycleGroups: IGroupByColumn[] = []; + + cycles.map((cycle) => { + if (cycle) { + const cycleStatus = cycle?.status ? (cycle.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + cycleGroups.push({ + id: cycle.id, + name: cycle.name, + icon: , + payload: { cycle_id: cycle.id }, + }); + } + }); + cycleGroups.push({ + id: "None", + name: "None", + icon: , + payload: { cycle_id: null }, + }); + + return cycleGroups; +}; + +const getModuleColumns = (moduleStore: IIssueModuleStore): IGroupByColumn[] | undefined => { + const { modules } = moduleStore; + + if (!modules) return; + + const moduleGroups: IGroupByColumn[] = []; + + modules.map((moduleInfo) => { + if (moduleInfo) + moduleGroups.push({ + id: moduleInfo.id, + name: moduleInfo.name, + icon: , + payload: { module_ids: [moduleInfo.id] }, + }); + }) as any; + moduleGroups.push({ + id: "None", + name: "None", + icon: , + payload: { module_ids: [] }, + }); + + return moduleGroups as any; +}; + +const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefined => { + const { sortedStates } = projectState; + if (!sortedStates) return; + + return sortedStates.map((state) => ({ + id: state.id, + name: state.name, + icon: ( +
    + +
    + ), + payload: { state_id: state.id }, + })) as any; +}; + +const getPriorityColumns = () => { + const priorities = ISSUE_PRIORITIES; + + return priorities.map((priority) => ({ + id: priority.key, + name: priority.title, + icon: , + payload: { priority: priority.key }, + })); +}; + +const getLabelsColumns = (label: IIssueLabelStore) => { + const { labels: storeLabels } = label; + + if (!storeLabels) return; + + const labels = [...storeLabels, { id: "None", name: "None", color: "#666" }]; + + return labels.map((label) => ({ + id: label.id, + name: label.name, + icon: ( +
    + ), + payload: label?.id === "None" ? {} : { label_ids: [label.id] }, + })); +}; + +const getAssigneeColumns = (member: IIssueMemberStore) => { + const { members } = member; + + if (!members) return; + + const assigneeColumns: any = members.map((member) => ({ + id: member.id, + name: member?.member__display_name || "", + icon: , + payload: { assignee_ids: [member.id] }, + })); + + assigneeColumns.push({ id: "None", name: "None", icon: , payload: {} }); + + return assigneeColumns; +}; + +const getCreatedByColumns = (member: IIssueMemberStore) => { + const { members } = member; + + if (!members) return; + + return members.map((member) => ({ + id: member.id, + name: member?.member__display_name || "", + icon: , + payload: {}, + })); +}; + +export const getDisplayPropertiesCount = ( + displayProperties: IIssueDisplayProperties, + ignoreFields?: (keyof IIssueDisplayProperties)[] +) => { + const propertyKeys = Object.keys(displayProperties) as (keyof IIssueDisplayProperties)[]; + + let count = 0; + + for (const propertyKey of propertyKeys) { + if (ignoreFields && ignoreFields.includes(propertyKey)) continue; + if (displayProperties[propertyKey]) count++; + } + + return count; +}; + +export const getIssueBlockId = ( + issueId: string | undefined, + groupId: string | undefined, + subGroupId?: string | undefined +) => `issue_${issueId}_${groupId}_${subGroupId}`; + +/** + * returns empty Array if groupId is None + * @param groupId + * @returns + */ +export const getGroupId = (groupId: string) => { + if (groupId === "None") return []; + return [groupId]; +}; + +/** + * method that removes Null or undefined Keys from object + * @param obj + * @returns + */ +export const removeNillKeys = (obj: T) => + Object.fromEntries(Object.entries(obj ?? {}).filter(([key, value]) => key && !isNil(value))); + +/** + * This Method returns if the the grouped values are subGrouped + * @param groupedIssueIds + * @returns + */ +export const isSubGrouped = (groupedIssueIds: TGroupedIssues) => { + if (!groupedIssueIds || Array.isArray(groupedIssueIds)) { + return false; + } + + if (Array.isArray(groupedIssueIds[Object.keys(groupedIssueIds)[0]])) { + return false; + } + + return true; +}; diff --git a/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx b/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx new file mode 100644 index 00000000..159b92a4 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { IIssueDisplayProperties } from "@plane/types"; + +interface IWithDisplayPropertiesHOC { + displayProperties: IIssueDisplayProperties; + shouldRenderProperty?: (displayProperties: IIssueDisplayProperties) => boolean; + displayPropertyKey: keyof IIssueDisplayProperties | (keyof IIssueDisplayProperties)[]; + children: ReactNode; +} + +export const WithDisplayPropertiesHOC = observer( + ({ displayProperties, shouldRenderProperty, displayPropertyKey, children }: IWithDisplayPropertiesHOC) => { + let shouldDisplayPropertyFromFilters = false; + if (Array.isArray(displayPropertyKey)) + shouldDisplayPropertyFromFilters = displayPropertyKey.every((key) => !!displayProperties[key]); + else shouldDisplayPropertyFromFilters = !!displayProperties[displayPropertyKey]; + + const renderProperty = + shouldDisplayPropertyFromFilters && (shouldRenderProperty ? shouldRenderProperty(displayProperties) : true); + + if (!renderProperty) return null; + + return <>{children}; + } +); diff --git a/apps/space/core/components/issues/navbar/controls.tsx b/apps/space/core/components/issues/navbar/controls.tsx new file mode 100644 index 00000000..0616d8b5 --- /dev/null +++ b/apps/space/core/components/issues/navbar/controls.tsx @@ -0,0 +1,127 @@ +"use client"; + +import type { FC } from "react"; +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useRouter, useSearchParams } from "next/navigation"; +// components +import { IssueFiltersDropdown } from "@/components/issues/filters"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; +// store +import type { PublishStore } from "@/store/publish/publish.store"; +// types +import type { TIssueLayout } from "@/types/issue"; +// local imports +import { IssuesLayoutSelection } from "./layout-selection"; +import { NavbarTheme } from "./theme"; +import { UserAvatar } from "./user-avatar"; + +export type NavbarControlsProps = { + publishSettings: PublishStore; +}; + +export const NavbarControls: FC = observer((props) => { + // props + const { publishSettings } = props; + // router + const router = useRouter(); + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board") || undefined; + const labels = searchParams.get("labels") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const peekId = searchParams.get("peekId") || undefined; + // hooks + const { getIssueFilters, isIssueFiltersUpdated, initIssueFilters } = useIssueFilter(); + const { setPeekId } = useIssueDetails(); + // derived values + const { anchor, view_props, workspace_detail } = publishSettings; + const issueFilters = anchor ? getIssueFilters(anchor) : undefined; + const activeLayout = issueFilters?.display_filters?.layout || undefined; + + const isInIframe = useIsInIframe(); + + useEffect(() => { + if (anchor && workspace_detail) { + const viewsAcceptable: string[] = []; + let currentBoard: TIssueLayout | null = null; + + if (view_props?.list) viewsAcceptable.push("list"); + if (view_props?.kanban) viewsAcceptable.push("kanban"); + if (view_props?.calendar) viewsAcceptable.push("calendar"); + if (view_props?.gantt) viewsAcceptable.push("gantt"); + if (view_props?.spreadsheet) viewsAcceptable.push("spreadsheet"); + + if (board) { + if (viewsAcceptable.includes(board.toString())) currentBoard = board.toString() as TIssueLayout; + else { + if (viewsAcceptable && viewsAcceptable.length > 0) currentBoard = viewsAcceptable[0] as TIssueLayout; + } + } else { + if (viewsAcceptable && viewsAcceptable.length > 0) currentBoard = viewsAcceptable[0] as TIssueLayout; + } + + if (currentBoard) { + if (activeLayout === undefined || activeLayout !== currentBoard) { + const { query, queryParam } = queryParamGenerator({ board: currentBoard, peekId, priority, state, labels }); + const params: any = { + display_filters: { layout: (query?.board as string[])[0] }, + filters: { + priority: query?.priority ?? undefined, + state: query?.state ?? undefined, + labels: query?.labels ?? undefined, + }, + }; + + if (!isIssueFiltersUpdated(anchor, params)) { + initIssueFilters(anchor, params); + router.push(`/issues/${anchor}?${queryParam}`); + } + } + } + } + }, [ + anchor, + board, + labels, + state, + priority, + peekId, + activeLayout, + router, + initIssueFilters, + setPeekId, + isIssueFiltersUpdated, + view_props, + workspace_detail, + ]); + + if (!anchor) return null; + + return ( + <> + {/* issue views */} +
    + +
    + + {/* issue filters */} +
    + +
    + + {/* theming */} +
    + +
    + + {!isInIframe && } + + ); +}); diff --git a/apps/space/core/components/issues/navbar/index.ts b/apps/space/core/components/issues/navbar/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/space/core/components/issues/navbar/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/space/core/components/issues/navbar/layout-icon.tsx b/apps/space/core/components/issues/navbar/layout-icon.tsx new file mode 100644 index 00000000..e9aed2b2 --- /dev/null +++ b/apps/space/core/components/issues/navbar/layout-icon.tsx @@ -0,0 +1,14 @@ +import type { LucideProps } from "lucide-react"; +import { List, Kanban } from "lucide-react"; +import type { TIssueLayout } from "@plane/constants"; + +export const IssueLayoutIcon = ({ layout, ...props }: { layout: TIssueLayout } & LucideProps) => { + switch (layout) { + case "list": + return ; + case "kanban": + return ; + default: + return null; + } +}; diff --git a/apps/space/core/components/issues/navbar/layout-selection.tsx b/apps/space/core/components/issues/navbar/layout-selection.tsx new file mode 100644 index 00000000..8c3c2d04 --- /dev/null +++ b/apps/space/core/components/issues/navbar/layout-selection.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; +import { useRouter, useSearchParams } from "next/navigation"; +// ui +import { SITES_ISSUE_LAYOUTS } from "@plane/constants"; +// plane i18n +import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; +// mobx +import type { TIssueLayout } from "@/types/issue"; +import { IssueLayoutIcon } from "./layout-icon"; + +type Props = { + anchor: string; +}; + +export const IssuesLayoutSelection: FC = observer((props) => { + const { anchor } = props; + // hooks + const { t } = useTranslation(); + // router + const router = useRouter(); + const searchParams = useSearchParams(); + // query params + const labels = searchParams.get("labels"); + const state = searchParams.get("state"); + const priority = searchParams.get("priority"); + const peekId = searchParams.get("peekId"); + // hooks + const { layoutOptions, getIssueFilters, updateIssueFilters } = useIssueFilter(); + // derived values + const issueFilters = getIssueFilters(anchor); + const activeLayout = issueFilters?.display_filters?.layout || undefined; + + const handleCurrentBoardView = (boardView: TIssueLayout) => { + updateIssueFilters(anchor, "display_filters", "layout", boardView); + const { queryParam } = queryParamGenerator({ board: boardView, peekId, priority, state, labels }); + router.push(`/issues/${anchor}?${queryParam}`); + }; + + return ( +
    + {SITES_ISSUE_LAYOUTS.map((layout) => { + if (!layoutOptions[layout.key]) return; + + return ( + + + + ); + })} +
    + ); +}); diff --git a/apps/space/core/components/issues/navbar/root.tsx b/apps/space/core/components/issues/navbar/root.tsx new file mode 100644 index 00000000..20f407e9 --- /dev/null +++ b/apps/space/core/components/issues/navbar/root.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; +import { ProjectIcon } from "@plane/propel/icons"; +// components +import { ProjectLogo } from "@/components/common/project-logo"; +// store +import type { PublishStore } from "@/store/publish/publish.store"; +// local imports +import { NavbarControls } from "./controls"; + +type Props = { + publishSettings: PublishStore; +}; + +export const IssuesNavbarRoot: FC = observer((props) => { + const { publishSettings } = props; + // hooks + const { project_details } = publishSettings; + + return ( +
    + {/* project detail */} +
    + {project_details ? ( + + + + ) : ( + + + + )} +
    + {project_details?.name || `...`} +
    +
    + +
    + +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/navbar/theme.tsx b/apps/space/core/components/issues/navbar/theme.tsx new file mode 100644 index 00000000..2078e9d1 --- /dev/null +++ b/apps/space/core/components/issues/navbar/theme.tsx @@ -0,0 +1,33 @@ +"use client"; + +// next theme +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; + +// mobx react lite + +export const NavbarTheme = observer(() => { + const [appTheme, setAppTheme] = useState("light"); + + const { setTheme, theme } = useTheme(); + + const handleTheme = () => { + setTheme(theme === "light" ? "dark" : "light"); + }; + + useEffect(() => { + if (!theme) return; + setAppTheme(theme); + }, [theme]); + + return ( + + ); +}); diff --git a/apps/space/core/components/issues/navbar/user-avatar.tsx b/apps/space/core/components/issues/navbar/user-avatar.tsx new file mode 100644 index 00000000..b5538cf7 --- /dev/null +++ b/apps/space/core/components/issues/navbar/user-avatar.tsx @@ -0,0 +1,128 @@ +"use client"; + +import type { FC } from "react"; +import { Fragment, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; +import { usePopper } from "react-popper"; +import { LogOut } from "lucide-react"; +import { Popover, Transition } from "@headlessui/react"; +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { AuthService } from "@plane/services"; +import { Avatar } from "@plane/ui"; +import { getFileURL } from "@plane/utils"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useUser } from "@/hooks/store/use-user"; + +const authService = new AuthService(); + +export const UserAvatar: FC = observer(() => { + const pathName = usePathname(); + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board") || undefined; + const labels = searchParams.get("labels") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const peekId = searchParams.get("peekId") || undefined; + // hooks + const { data: currentUser, signOut } = useUser(); + // states + const [csrfToken, setCsrfToken] = useState(undefined); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-end", + modifiers: [ + { + name: "offset", + options: { + offset: [0, 40], + }, + }, + ], + }); + + // derived values + const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); + + return ( +
    + {currentUser?.id ? ( +
    + + + + + + +
    + {csrfToken && ( +
    + + + +
    + )} +
    +
    +
    +
    +
    + ) : ( +
    + + + +
    + )} +
    + ); +}); diff --git a/apps/space/core/components/issues/peek-overview/comment/add-comment.tsx b/apps/space/core/components/issues/peek-overview/comment/add-comment.tsx new file mode 100644 index 00000000..3a6e6f1d --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/comment/add-comment.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React, { useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useForm, Controller } from "react-hook-form"; +// plane imports +import type { EditorRefApi } from "@plane/editor"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { SitesFileService } from "@plane/services"; +import type { TIssuePublicComment } from "@plane/types"; +// editor components +import { LiteTextEditor } from "@/components/editor/lite-text-editor"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; +// services +const fileService = new SitesFileService(); + +const defaultValues: Partial = { + comment_html: "", +}; + +type Props = { + anchor: string; + disabled?: boolean; +}; + +export const AddComment: React.FC = observer((props) => { + const { anchor } = props; + // states + const [uploadedAssetIds, setUploadAssetIds] = useState([]); + // refs + const editorRef = useRef(null); + // store hooks + const { peekId: issueId, addIssueComment, uploadCommentAsset } = useIssueDetails(); + const { data: currentUser } = useUser(); + const { workspace: workspaceID } = usePublish(anchor); + // form info + const { + handleSubmit, + control, + watch, + formState: { isSubmitting }, + reset, + } = useForm({ defaultValues }); + + const onSubmit = async (formData: TIssuePublicComment) => { + if (!anchor || !issueId || isSubmitting || !formData.comment_html) return; + + await addIssueComment(anchor, issueId, formData) + .then(async (res) => { + reset(defaultValues); + editorRef.current?.clearEditor(); + if (uploadedAssetIds.length > 0) { + await fileService.updateBulkAssetsUploadStatus(anchor, res.id, { + asset_ids: uploadedAssetIds, + }); + setUploadAssetIds([]); + } + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Comment could not be posted. Please try again.", + }) + ); + }; + + // TODO: on click if he user is not logged in redirect to login page + return ( +
    +
    + ( + { + if (currentUser) handleSubmit(onSubmit)(e); + }} + anchor={anchor} + workspaceId={workspaceID?.toString() ?? ""} + ref={editorRef} + id="peek-overview-add-comment" + initialValue={ + !value || value === "" || (typeof value === "object" && Object.keys(value).length === 0) + ? watch("comment_html") + : value + } + onChange={(comment_json, comment_html) => onChange(comment_html)} + isSubmitting={isSubmitting} + placeholder="Add comment..." + uploadFile={async (blockId, file) => { + const { asset_id } = await uploadCommentAsset(file, anchor); + setUploadAssetIds((prev) => [...prev, asset_id]); + return asset_id; + }} + /> + )} + /> +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx b/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx new file mode 100644 index 00000000..6e97ed6e --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -0,0 +1,218 @@ +import React, { useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { Check, MessageSquare, MoreVertical, X } from "lucide-react"; +import { Menu, Transition } from "@headlessui/react"; +// plane imports +import type { EditorRefApi } from "@plane/editor"; +import type { TIssuePublicComment } from "@plane/types"; +import { getFileURL } from "@plane/utils"; +// components +import { LiteTextEditor } from "@/components/editor/lite-text-editor"; +import { CommentReactions } from "@/components/issues/peek-overview/comment/comment-reactions"; +// helpers +import { timeAgo } from "@/helpers/date-time.helper"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; + +type Props = { + anchor: string; + comment: TIssuePublicComment; +}; + +export const CommentCard: React.FC = observer((props) => { + const { anchor, comment } = props; + // store hooks + const { peekId, deleteIssueComment, updateIssueComment, uploadCommentAsset } = useIssueDetails(); + const { data: currentUser } = useUser(); + const { workspace: workspaceID } = usePublish(anchor); + const isInIframe = useIsInIframe(); + + // states + const [isEditing, setIsEditing] = useState(false); + // refs + const editorRef = useRef(null); + const showEditorRef = useRef(null); + // form info + const { + control, + formState: { isSubmitting }, + handleSubmit, + } = useForm({ + defaultValues: { comment_html: comment.comment_html }, + }); + + const handleDelete = () => { + if (!anchor || !peekId) return; + deleteIssueComment(anchor, peekId, comment.id); + }; + + const handleCommentUpdate = async (formData: TIssuePublicComment) => { + if (!anchor || !peekId) return; + updateIssueComment(anchor, peekId, comment.id, formData); + setIsEditing(false); + editorRef.current?.setEditorValue(formData.comment_html); + showEditorRef.current?.setEditorValue(formData.comment_html); + }; + + return ( +
    +
    + {comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? ( + // eslint-disable-next-line @next/next/no-img-element + { + ) : ( +
    + {comment.actor_detail.is_bot + ? comment?.actor_detail?.first_name?.charAt(0) + : comment?.actor_detail?.display_name?.charAt(0)} +
    + )} + + + +
    +
    +
    +
    + {comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name} +
    +

    + <>commented {timeAgo(comment.created_at)} +

    +
    +
    +
    +
    + ( + onChange(comment_html)} + isSubmitting={isSubmitting} + showSubmitButton={false} + uploadFile={async (blockId, file) => { + const { asset_id } = await uploadCommentAsset(file, anchor, comment.id); + return asset_id; + }} + /> + )} + /> +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + {!isInIframe && currentUser?.id === comment?.actor_detail?.id && ( + + {}} + className="relative grid cursor-pointer place-items-center rounded p-1 text-custom-text-200 outline-none hover:bg-custom-background-80 hover:text-custom-text-100" + > + + + + + + + {({ active }) => ( +
    + +
    + )} +
    + + {({ active }) => ( +
    + +
    + )} +
    +
    +
    +
    + )} +
    + ); +}); diff --git a/apps/space/core/components/issues/peek-overview/comment/comment-reactions.tsx b/apps/space/core/components/issues/peek-overview/comment/comment-reactions.tsx new file mode 100644 index 00000000..16544110 --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/comment/comment-reactions.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { Tooltip } from "@plane/propel/tooltip"; +// plane imports +import { cn } from "@plane/utils"; +// ui +import { ReactionSelector } from "@/components/ui"; +// helpers +import { groupReactions, renderEmoji } from "@/helpers/emoji.helper"; +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; + +type Props = { + anchor: string; + commentId: string; +}; + +export const CommentReactions: React.FC = observer((props) => { + const { anchor, commentId } = props; + const router = useRouter(); + const pathName = usePathname(); + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const labels = searchParams.get("labels") || undefined; + + // hooks + const { addCommentReaction, removeCommentReaction, details, peekId } = useIssueDetails(); + const { data: user } = useUser(); + const isInIframe = useIsInIframe(); + + const commentReactions = peekId ? details[peekId].comments.find((c) => c.id === commentId)?.comment_reactions : []; + const groupedReactions = peekId ? groupReactions(commentReactions ?? [], "reaction") : {}; + + const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id); + + const handleAddReaction = (reactionHex: string) => { + if (!anchor || !peekId) return; + addCommentReaction(anchor, peekId, commentId, reactionHex); + }; + + const handleRemoveReaction = (reactionHex: string) => { + if (!anchor || !peekId) return; + removeCommentReaction(anchor, peekId, commentId, reactionHex); + }; + + const handleReactionClick = (reactionHex: string) => { + const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex); + + if (userReaction) handleRemoveReaction(reactionHex); + else handleAddReaction(reactionHex); + }; + + // derived values + const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); + + return ( +
    + {!isInIframe && ( + { + if (user) handleReactionClick(value); + else router.push(`/?next_path=${pathName}?${queryParam}`); + }} + position="top" + selected={userReactions?.map((r) => r.reaction)} + size="md" + /> + )} + + {Object.keys(groupedReactions || {}).map((reaction) => { + const reactions = groupedReactions?.[reaction] ?? []; + const REACTIONS_LIMIT = 1000; + + if (reactions.length > 0) + return ( + + {reactions + .map((r) => r?.actor_detail?.display_name) + .splice(0, REACTIONS_LIMIT) + .join(", ")} + {reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"} +
    + } + > + + + ); + })} +
    + ); +}); diff --git a/apps/space/core/components/issues/peek-overview/full-screen-peek-view.tsx b/apps/space/core/components/issues/peek-overview/full-screen-peek-view.tsx new file mode 100644 index 00000000..2b40085a --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/full-screen-peek-view.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane imports +import { Loader } from "@plane/ui"; +// types +import type { IIssue } from "@/types/issue"; +// local imports +import { PeekOverviewHeader } from "./header"; +import { PeekOverviewIssueActivity } from "./issue-activity"; +import { PeekOverviewIssueDetails } from "./issue-details"; +import { PeekOverviewIssueProperties } from "./issue-properties"; + +type Props = { + anchor: string; + handleClose: () => void; + issueDetails: IIssue | undefined; +}; + +export const FullScreenPeekView: React.FC = observer((props) => { + const { anchor, handleClose, issueDetails } = props; + + return ( +
    +
    +
    + +
    + {issueDetails ? ( +
    + {/* issue title and description */} +
    + +
    + {/* divider */} +
    + {/* issue activity/comments */} +
    + +
    +
    + ) : ( + + +
    + + + +
    +
    + )} +
    +
    + {/* issue properties */} +
    + {issueDetails ? ( + + ) : ( + + + + + + + )} +
    +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/peek-overview/header.tsx b/apps/space/core/components/issues/peek-overview/header.tsx new file mode 100644 index 00000000..0bf33066 --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/header.tsx @@ -0,0 +1,129 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { Link2, MoveRight } from "lucide-react"; +import { Listbox, Transition } from "@headlessui/react"; +// ui +import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +// helpers +import { copyTextToClipboard } from "@/helpers/string.helper"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import useClipboardWritePermission from "@/hooks/use-clipboard-write-permission"; +// types +import type { IIssue, IPeekMode } from "@/types/issue"; + +type Props = { + handleClose: () => void; + issueDetails: IIssue | undefined; +}; + +const PEEK_MODES: { + key: IPeekMode; + icon: any; + label: string; +}[] = [ + { key: "side", icon: SidePanelIcon, label: "Side Peek" }, + { + key: "modal", + icon: CenterPanelIcon, + label: "Modal", + }, + { + key: "full", + icon: FullScreenPanelIcon, + label: "Full Screen", + }, +]; + +export const PeekOverviewHeader: React.FC = observer((props) => { + const { handleClose } = props; + + const { peekMode, setPeekMode } = useIssueDetails(); + const isClipboardWriteAllowed = useClipboardWritePermission(); + + const handleCopyLink = () => { + const urlToCopy = window.location.href; + + copyTextToClipboard(urlToCopy).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Link copied!", + message: "Work item link copied to clipboard.", + }); + }); + }; + + const Icon = PEEK_MODES.find((m) => m.key === peekMode)?.icon ?? SidePanelIcon; + + return ( + <> +
    +
    + {peekMode === "side" && ( + + )} + setPeekMode(val)} + className="relative flex-shrink-0 text-left" + > + + + + + + +
    + {PEEK_MODES.map((mode) => ( + + `cursor-pointer select-none truncate rounded px-1 py-1.5 ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > +
    + + {mode.label} +
    +
    + ))} +
    +
    +
    +
    +
    + {isClipboardWriteAllowed && (peekMode === "side" || peekMode === "modal") && ( +
    + +
    + )} +
    + + ); +}); diff --git a/apps/space/core/components/issues/peek-overview/index.ts b/apps/space/core/components/issues/peek-overview/index.ts new file mode 100644 index 00000000..eec9d1e6 --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/index.ts @@ -0,0 +1 @@ +export * from "./layout"; diff --git a/apps/space/core/components/issues/peek-overview/issue-activity.tsx b/apps/space/core/components/issues/peek-overview/issue-activity.tsx new file mode 100644 index 00000000..c31ca24f --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/issue-activity.tsx @@ -0,0 +1,70 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +// plane imports +import { Button } from "@plane/propel/button"; +// components +import { AddComment } from "@/components/issues/peek-overview/comment/add-comment"; +import { CommentCard } from "@/components/issues/peek-overview/comment/comment-detail-card"; +import { Icon } from "@/components/ui"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; +// types +import type { IIssue } from "@/types/issue"; + +type Props = { + anchor: string; + issueDetails: IIssue; +}; + +export const PeekOverviewIssueActivity: React.FC = observer((props) => { + const { anchor } = props; + // router + const pathname = usePathname(); + // store hooks + const { details, peekId } = useIssueDetails(); + const { data: currentUser } = useUser(); + const { canComment } = usePublish(anchor); + // derived values + const comments = details[peekId || ""]?.comments || []; + const isInIframe = useIsInIframe(); + + return ( +
    +

    Comments

    +
    +
    + {comments.map((comment) => ( + + ))} +
    + {!isInIframe && + (currentUser ? ( + <> + {canComment && ( +
    + +
    + )} + + ) : ( +
    +

    + + Sign in to add your comment +

    + + + +
    + ))} +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/peek-overview/issue-details.tsx b/apps/space/core/components/issues/peek-overview/issue-details.tsx new file mode 100644 index 00000000..2ca097b2 --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/issue-details.tsx @@ -0,0 +1,40 @@ +import { observer } from "mobx-react"; +// plane imports +import { RichTextEditor } from "@/components/editor/rich-text-editor"; +import { usePublish } from "@/hooks/store/publish"; +// types +import type { IIssue } from "@/types/issue"; +// local imports +import { IssueReactions } from "./issue-reaction"; + +type Props = { + anchor: string; + issueDetails: IIssue; +}; + +export const PeekOverviewIssueDetails: React.FC = observer((props) => { + const { anchor, issueDetails } = props; + // store hooks + const { project_details, workspace: workspaceID } = usePublish(anchor); + // derived values + const description = issueDetails.description_html; + + return ( +
    +
    + {project_details?.identifier}-{issueDetails?.sequence_id} +
    +

    {issueDetails.name}

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

    " && ( + + )} + +
    + ); +}); diff --git a/apps/space/core/components/issues/peek-overview/issue-properties.tsx b/apps/space/core/components/issues/peek-overview/issue-properties.tsx new file mode 100644 index 00000000..6ea937d1 --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/issue-properties.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { CalendarCheck2, Signal } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { DoubleCircleIcon, StateGroupIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { cn, getIssuePriorityFilters } from "@plane/utils"; +// components +import { Icon } from "@/components/ui"; +// helpers +import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; +import { copyTextToClipboard, addSpaceIfCamelCase } from "@/helpers/string.helper"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useStates } from "@/hooks/store/use-state"; +// types +import type { IIssue, IPeekMode } from "@/types/issue"; + +type Props = { + issueDetails: IIssue; + mode?: IPeekMode; +}; + +export const PeekOverviewIssueProperties: React.FC = observer(({ issueDetails, mode }) => { + // hooks + const { t } = useTranslation(); + const { getStateById } = useStates(); + const state = getStateById(issueDetails?.state_id ?? undefined); + + const { anchor } = useParams(); + + const { project_details } = usePublish(anchor?.toString()); + + const priority = issueDetails.priority ? getIssuePriorityFilters(issueDetails.priority) : null; + + const handleCopyLink = () => { + const urlToCopy = window.location.href; + + copyTextToClipboard(urlToCopy).then(() => { + setToast({ + type: TOAST_TYPE.INFO, + title: "Link copied!", + message: "Work item link copied to clipboard", + }); + }); + }; + + return ( +
    + {mode === "full" && ( +
    +
    + {project_details?.identifier}-{issueDetails.sequence_id} +
    +
    + +
    +
    + )} +
    +
    +
    + + State +
    +
    + + {addSpaceIfCamelCase(state?.name ?? "")} +
    +
    + +
    +
    + + Priority +
    +
    +
    + {priority && ( + + + + )} + {t(priority?.titleTranslationKey || "common.none")} +
    +
    +
    + +
    +
    + + Due date +
    +
    + {issueDetails.target_date ? ( +
    + + {renderFormattedDate(issueDetails.target_date)} +
    + ) : ( + Empty + )} +
    +
    +
    +
    + ); +}); diff --git a/apps/space/core/components/issues/peek-overview/issue-reaction.tsx b/apps/space/core/components/issues/peek-overview/issue-reaction.tsx new file mode 100644 index 00000000..970f7ef5 --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/issue-reaction.tsx @@ -0,0 +1,33 @@ +import { observer } from "mobx-react"; +// components +import { IssueEmojiReactions } from "@/components/issues/reactions/issue-emoji-reactions"; +import { IssueVotes } from "@/components/issues/reactions/issue-vote-reactions"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; + +type Props = { + anchor: string; +}; + +export const IssueReactions: React.FC = observer((props) => { + const { anchor } = props; + // store hooks + const { canVote, canReact } = usePublish(anchor); + const isInIframe = useIsInIframe(); + + return ( +
    + {canVote && ( +
    + +
    + )} + {!isInIframe && canReact && ( +
    + +
    + )} +
    + ); +}); diff --git a/apps/space/core/components/issues/peek-overview/layout.tsx b/apps/space/core/components/issues/peek-overview/layout.tsx new file mode 100644 index 00000000..817700bf --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/layout.tsx @@ -0,0 +1,136 @@ +"use client"; + +import type { FC } from "react"; +import { Fragment, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Dialog, Transition } from "@headlessui/react"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +// local imports +import { FullScreenPeekView } from "./full-screen-peek-view"; +import { SidePeekView } from "./side-peek-view"; + +type TIssuePeekOverview = { + anchor: string; + peekId: string; + handlePeekClose?: () => void; +}; + +export const IssuePeekOverview: FC = observer((props) => { + const { anchor, peekId, handlePeekClose } = props; + const router = useRouter(); + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const labels = searchParams.get("labels") || undefined; + // states + const [isSidePeekOpen, setIsSidePeekOpen] = useState(false); + const [isModalPeekOpen, setIsModalPeekOpen] = useState(false); + // store + const issueDetailStore = useIssueDetails(); + + const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined; + + useEffect(() => { + if (anchor && peekId) { + issueDetailStore.fetchIssueDetails(anchor, peekId.toString()); + } + }, [anchor, issueDetailStore, peekId]); + + const handleClose = () => { + // if close logic is passed down, call that instead of the below logic + if (handlePeekClose) { + handlePeekClose(); + return; + } + + issueDetailStore.setPeekId(null); + let queryParams: any = { + board, + }; + if (priority && priority.length > 0) queryParams = { ...queryParams, priority: priority }; + if (state && state.length > 0) queryParams = { ...queryParams, state: state }; + if (labels && labels.length > 0) queryParams = { ...queryParams, labels: labels }; + queryParams = new URLSearchParams(queryParams).toString(); + router.push(`/issues/${anchor}?${queryParams}`); + }; + + useEffect(() => { + if (peekId) { + if (issueDetailStore.peekMode === "side") { + setIsSidePeekOpen(true); + setIsModalPeekOpen(false); + } else { + setIsModalPeekOpen(true); + setIsSidePeekOpen(false); + } + } else { + setIsSidePeekOpen(false); + setIsModalPeekOpen(false); + } + }, [peekId, issueDetailStore.peekMode]); + + return ( + <> + + + + + + + + + + + + +
    + + + +
    + {issueDetailStore.peekMode === "modal" && ( + + )} + {issueDetailStore.peekMode === "full" && ( + + )} +
    +
    +
    +
    +
    + + ); +}); diff --git a/apps/space/core/components/issues/peek-overview/side-peek-view.tsx b/apps/space/core/components/issues/peek-overview/side-peek-view.tsx new file mode 100644 index 00000000..04798908 --- /dev/null +++ b/apps/space/core/components/issues/peek-overview/side-peek-view.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane imports +import { Loader } from "@plane/ui"; +// store hooks +import { usePublish } from "@/hooks/store/publish"; +// types +import type { IIssue } from "@/types/issue"; +// local imports +import { PeekOverviewHeader } from "./header"; +import { PeekOverviewIssueActivity } from "./issue-activity"; +import { PeekOverviewIssueDetails } from "./issue-details"; +import { PeekOverviewIssueProperties } from "./issue-properties"; + +type Props = { + anchor: string; + handleClose: () => void; + issueDetails: IIssue | undefined; +}; + +export const SidePeekView: React.FC = observer((props) => { + const { anchor, handleClose, issueDetails } = props; + // store hooks + const { canComment } = usePublish(anchor); + + return ( +
    +
    + +
    + {issueDetails ? ( +
    + {/* issue title and description */} +
    + +
    + {/* issue properties */} +
    + +
    + {/* divider */} +
    + {/* issue activity/comments */} + {canComment && ( +
    + +
    + )} +
    + ) : ( + + +
    + + + +
    +
    + )} +
    + ); +}); diff --git a/apps/space/core/components/issues/reactions/issue-emoji-reactions.tsx b/apps/space/core/components/issues/reactions/issue-emoji-reactions.tsx new file mode 100644 index 00000000..ecf01210 --- /dev/null +++ b/apps/space/core/components/issues/reactions/issue-emoji-reactions.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { observer } from "mobx-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +// lib +import { Tooltip } from "@plane/propel/tooltip"; +import { ReactionSelector } from "@/components/ui"; +// helpers +import { groupReactions, renderEmoji } from "@/helpers/emoji.helper"; +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; + +type IssueEmojiReactionsProps = { + anchor: string; + issueIdFromProps?: string; + size?: "md" | "sm"; +}; + +export const IssueEmojiReactions: React.FC = observer((props) => { + const { anchor, issueIdFromProps, size = "md" } = props; + // router + const router = useRouter(); + const pathName = usePathname(); + const searchParams = useSearchParams(); + // query params + const peekId = searchParams.get("peekId") || undefined; + const board = searchParams.get("board") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const labels = searchParams.get("labels") || undefined; + // store hooks + const issueDetailsStore = useIssueDetails(); + const { data: user } = useUser(); + + const issueId = issueIdFromProps ?? issueDetailsStore.peekId; + const reactions = issueDetailsStore.details[issueId ?? ""]?.reaction_items ?? []; + const groupedReactions = groupReactions(reactions, "reaction"); + + const userReactions = reactions.filter((r) => r.actor_details?.id === user?.id); + + const handleAddReaction = (reactionHex: string) => { + if (!issueId) return; + issueDetailsStore.addIssueReaction(anchor, issueId, reactionHex); + }; + + const handleRemoveReaction = (reactionHex: string) => { + if (!issueId) return; + issueDetailsStore.removeIssueReaction(anchor, issueId, reactionHex); + }; + + const handleReactionClick = (reactionHex: string) => { + const userReaction = userReactions?.find((r) => r.actor_details?.id === user?.id && r.reaction === reactionHex); + if (userReaction) handleRemoveReaction(reactionHex); + else handleAddReaction(reactionHex); + }; + + // derived values + const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); + const reactionDimensions = size === "sm" ? "h-6 px-2 py-1" : "h-full px-2 py-1"; + + return ( + <> + { + if (user) handleReactionClick(value); + else router.push(`/?next_path=${pathName}?${queryParam}`); + }} + selected={userReactions?.map((r) => r.reaction)} + size={size} + /> + {Object.keys(groupedReactions || {}).map((reaction) => { + const reactions = groupedReactions?.[reaction] ?? []; + const REACTIONS_LIMIT = 1000; + + if (reactions.length > 0) + return ( + + {reactions + ?.map((r) => r?.actor_details?.display_name) + ?.splice(0, REACTIONS_LIMIT) + ?.join(", ")} + {reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"} +
    + } + > + + + ); + })} + + ); +}); diff --git a/apps/space/core/components/issues/reactions/issue-vote-reactions.tsx b/apps/space/core/components/issues/reactions/issue-vote-reactions.tsx new file mode 100644 index 00000000..ce8642bb --- /dev/null +++ b/apps/space/core/components/issues/reactions/issue-vote-reactions.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +// plane imports +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; + +type TIssueVotes = { + anchor: string; + issueIdFromProps?: string; + size?: "md" | "sm"; +}; + +export const IssueVotes: React.FC = observer((props) => { + const { anchor, issueIdFromProps, size = "md" } = props; + // states + const [isSubmitting, setIsSubmitting] = useState(false); + // router + const router = useRouter(); + const pathName = usePathname(); + const searchParams = useSearchParams(); + // query params + const peekId = searchParams.get("peekId") || undefined; + const board = searchParams.get("board") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const labels = searchParams.get("labels") || undefined; + // store hooks + const issueDetailsStore = useIssueDetails(); + const { data: user } = useUser(); + + const isInIframe = useIsInIframe(); + + const issueId = issueIdFromProps ?? issueDetailsStore.peekId; + + const votes = issueDetailsStore.details[issueId ?? ""]?.vote_items ?? []; + + const allUpVotes = votes.filter((vote) => vote.vote === 1); + const allDownVotes = votes.filter((vote) => vote.vote === -1); + + const isUpVotedByUser = allUpVotes.some((vote) => vote.actor_details?.id === user?.id); + const isDownVotedByUser = allDownVotes.some((vote) => vote.actor_details?.id === user?.id); + + const handleVote = async (e: any, voteValue: 1 | -1) => { + if (!issueId) return; + + setIsSubmitting(true); + + const actionPerformed = votes?.find((vote) => vote.actor_details?.id === user?.id && vote.vote === voteValue); + + if (actionPerformed) await issueDetailsStore.removeIssueVote(anchor, issueId); + else { + await issueDetailsStore.addIssueVote(anchor, issueId, { + vote: voteValue, + }); + } + + setIsSubmitting(false); + }; + + const VOTES_LIMIT = 1000; + + // derived values + const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); + const votingDimensions = size === "sm" ? "px-1 h-6 min-w-9" : "px-2 h-7"; + + return ( +
    + {/* upvote button 👇 */} + + {allUpVotes.length > 0 ? ( + <> + {allUpVotes + .map((r) => r.actor_details?.display_name) + .splice(0, VOTES_LIMIT) + .join(", ")} + {allUpVotes.length > VOTES_LIMIT && " and " + (allUpVotes.length - VOTES_LIMIT) + " more"} + + ) : ( + "No upvotes yet" + )} +
    + } + > + + + + {/* downvote button 👇 */} + + {allDownVotes.length > 0 ? ( + <> + {allDownVotes + .map((r) => r.actor_details.display_name) + .splice(0, VOTES_LIMIT) + .join(", ")} + {allDownVotes.length > VOTES_LIMIT && " and " + (allDownVotes.length - VOTES_LIMIT) + " more"} + + ) : ( + "No downvotes yet" + )} +
    + } + > + + +
    + ); +}); diff --git a/apps/space/core/components/ui/icon.tsx b/apps/space/core/components/ui/icon.tsx new file mode 100644 index 00000000..7ddc7684 --- /dev/null +++ b/apps/space/core/components/ui/icon.tsx @@ -0,0 +1,10 @@ +import React from "react"; + +type Props = { + iconName: string; + className?: string; +}; + +export const Icon: React.FC = ({ iconName, className = "" }) => ( + {iconName} +); diff --git a/apps/space/core/components/ui/index.ts b/apps/space/core/components/ui/index.ts new file mode 100644 index 00000000..ccd2303c --- /dev/null +++ b/apps/space/core/components/ui/index.ts @@ -0,0 +1,2 @@ +export * from "./icon"; +export * from "./reaction-selector"; diff --git a/apps/space/core/components/ui/not-found.tsx b/apps/space/core/components/ui/not-found.tsx new file mode 100644 index 00000000..89fbca66 --- /dev/null +++ b/apps/space/core/components/ui/not-found.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +// images +import Image404 from "@/public/404.svg"; + +export const PageNotFound = () => ( +
    +
    +
    +
    + 404- Page not found +
    +
    +

    Oops! Something went wrong.

    +

    + Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is + temporarily unavailable. +

    +
    +
    +
    +
    +); diff --git a/apps/space/core/components/ui/reaction-selector.tsx b/apps/space/core/components/ui/reaction-selector.tsx new file mode 100644 index 00000000..9b999a61 --- /dev/null +++ b/apps/space/core/components/ui/reaction-selector.tsx @@ -0,0 +1,80 @@ +import { Fragment } from "react"; + +// headless ui +import { Popover, Transition } from "@headlessui/react"; + +// helper +import { Icon } from "@/components/ui"; +import { renderEmoji } from "@/helpers/emoji.helper"; + +// icons + +const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"]; + +interface Props { + onSelect: (emoji: string) => void; + position?: "top" | "bottom"; + selected?: string[]; + size?: "sm" | "md" | "lg"; +} + +export const ReactionSelector: React.FC = (props) => { + const { onSelect, position, selected = [], size } = props; + + return ( + + {({ open, close: closePopover }) => ( + <> + + + + + + + +
    +
    + {reactionEmojis.map((emoji) => ( + + ))} +
    +
    +
    +
    + + )} +
    + ); +}; diff --git a/apps/space/core/components/views/auth.tsx b/apps/space/core/components/views/auth.tsx new file mode 100644 index 00000000..756c047d --- /dev/null +++ b/apps/space/core/components/views/auth.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { AuthRoot } from "@/components/account/auth-forms"; +import { PoweredBy } from "@/components/common/powered-by"; +// local imports +import { AuthHeader } from "./header"; + +export const AuthView = () => ( +
    + + + +
    +); diff --git a/apps/space/core/components/views/header.tsx b/apps/space/core/components/views/header.tsx new file mode 100644 index 00000000..80ba76de --- /dev/null +++ b/apps/space/core/components/views/header.tsx @@ -0,0 +1,13 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { PlaneLockup } from "@plane/propel/icons"; + +export const AuthHeader = () => ( +
    + + + +
    +); diff --git a/apps/space/core/components/views/index.ts b/apps/space/core/components/views/index.ts new file mode 100644 index 00000000..97ccf764 --- /dev/null +++ b/apps/space/core/components/views/index.ts @@ -0,0 +1 @@ +export * from "./auth"; diff --git a/apps/space/core/hooks/store/publish/index.ts b/apps/space/core/hooks/store/publish/index.ts new file mode 100644 index 00000000..a7b42ad5 --- /dev/null +++ b/apps/space/core/hooks/store/publish/index.ts @@ -0,0 +1,2 @@ +export * from "./use-publish-list"; +export * from "./use-publish"; diff --git a/apps/space/core/hooks/store/publish/use-publish-list.ts b/apps/space/core/hooks/store/publish/use-publish-list.ts new file mode 100644 index 00000000..94ad0080 --- /dev/null +++ b/apps/space/core/hooks/store/publish/use-publish-list.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IPublishListStore } from "@/store/publish/publish_list.store"; + +export const usePublishList = (): IPublishListStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("usePublishList must be used within StoreProvider"); + return context.publishList; +}; diff --git a/apps/space/core/hooks/store/publish/use-publish.ts b/apps/space/core/hooks/store/publish/use-publish.ts new file mode 100644 index 00000000..957e9fc7 --- /dev/null +++ b/apps/space/core/hooks/store/publish/use-publish.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { PublishStore } from "@/store/publish/publish.store"; + +export const usePublish = (anchor: string): PublishStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("usePublish must be used within StoreProvider"); + return context.publishList.publishMap?.[anchor] ?? {}; +}; diff --git a/apps/space/core/hooks/store/use-cycle.ts b/apps/space/core/hooks/store/use-cycle.ts new file mode 100644 index 00000000..eea623ee --- /dev/null +++ b/apps/space/core/hooks/store/use-cycle.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { ICycleStore } from "@/store/cycle.store"; + +export const useCycle = (): ICycleStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useCycle must be used within StoreProvider"); + return context.cycle; +}; diff --git a/apps/space/core/hooks/store/use-instance.ts b/apps/space/core/hooks/store/use-instance.ts new file mode 100644 index 00000000..6dbfda6a --- /dev/null +++ b/apps/space/core/hooks/store/use-instance.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IInstanceStore } from "@/store/instance.store"; + +export const useInstance = (): IInstanceStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.instance; +}; diff --git a/apps/space/core/hooks/store/use-issue-details.tsx b/apps/space/core/hooks/store/use-issue-details.tsx new file mode 100644 index 00000000..6456a73c --- /dev/null +++ b/apps/space/core/hooks/store/use-issue-details.tsx @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueDetailStore } from "@/store/issue-detail.store"; + +export const useIssueDetails = (): IIssueDetailStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.issueDetail; +}; diff --git a/apps/space/core/hooks/store/use-issue-filter.ts b/apps/space/core/hooks/store/use-issue-filter.ts new file mode 100644 index 00000000..bd9043fd --- /dev/null +++ b/apps/space/core/hooks/store/use-issue-filter.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueFilterStore } from "@/store/issue-filters.store"; + +export const useIssueFilter = (): IIssueFilterStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.issueFilter; +}; diff --git a/apps/space/core/hooks/store/use-issue.ts b/apps/space/core/hooks/store/use-issue.ts new file mode 100644 index 00000000..0061c760 --- /dev/null +++ b/apps/space/core/hooks/store/use-issue.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueStore } from "@/store/issue.store"; + +export const useIssue = (): IIssueStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useIssue must be used within StoreProvider"); + return context.issue; +}; diff --git a/apps/space/core/hooks/store/use-label.ts b/apps/space/core/hooks/store/use-label.ts new file mode 100644 index 00000000..fd004f58 --- /dev/null +++ b/apps/space/core/hooks/store/use-label.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueLabelStore } from "@/store/label.store"; + +export const useLabel = (): IIssueLabelStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useLabel must be used within StoreProvider"); + return context.label; +}; diff --git a/apps/space/core/hooks/store/use-member.ts b/apps/space/core/hooks/store/use-member.ts new file mode 100644 index 00000000..334f852c --- /dev/null +++ b/apps/space/core/hooks/store/use-member.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueMemberStore } from "@/store/members.store"; + +export const useMember = (): IIssueMemberStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useMember must be used within StoreProvider"); + return context.member; +}; diff --git a/apps/space/core/hooks/store/use-module.ts b/apps/space/core/hooks/store/use-module.ts new file mode 100644 index 00000000..1ee7a7c4 --- /dev/null +++ b/apps/space/core/hooks/store/use-module.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueModuleStore } from "@/store/module.store"; + +export const useModule = (): IIssueModuleStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useModule must be used within StoreProvider"); + return context.module; +}; diff --git a/apps/space/core/hooks/store/use-state.ts b/apps/space/core/hooks/store/use-state.ts new file mode 100644 index 00000000..b7ada164 --- /dev/null +++ b/apps/space/core/hooks/store/use-state.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IStateStore } from "@/store/state.store"; + +export const useStates = (): IStateStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useState must be used within StoreProvider"); + return context.state; +}; diff --git a/apps/space/core/hooks/store/use-user-profile.ts b/apps/space/core/hooks/store/use-user-profile.ts new file mode 100644 index 00000000..37d3571f --- /dev/null +++ b/apps/space/core/hooks/store/use-user-profile.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IProfileStore } from "@/store/profile.store"; + +export const useUserProfile = (): IProfileStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.user.profile; +}; diff --git a/apps/space/core/hooks/store/use-user.ts b/apps/space/core/hooks/store/use-user.ts new file mode 100644 index 00000000..93e1cad9 --- /dev/null +++ b/apps/space/core/hooks/store/use-user.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IUserStore } from "@/store/user.store"; + +export const useUser = (): IUserStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUser must be used within StoreProvider"); + return context.user; +}; diff --git a/apps/space/core/hooks/use-clipboard-write-permission.tsx b/apps/space/core/hooks/use-clipboard-write-permission.tsx new file mode 100644 index 00000000..1f89b829 --- /dev/null +++ b/apps/space/core/hooks/use-clipboard-write-permission.tsx @@ -0,0 +1,29 @@ +import { useState, useEffect } from "react"; + +const useClipboardWritePermission = () => { + const [isClipboardWriteAllowed, setClipboardWriteAllowed] = useState(false); + + useEffect(() => { + const checkClipboardWriteAccess = () => { + navigator.permissions + //eslint-disable-next-line no-undef + .query({ name: "clipboard-write" as PermissionName }) + .then((result) => { + if (result.state === "granted") { + setClipboardWriteAllowed(true); + } else { + setClipboardWriteAllowed(false); + } + }) + .catch(() => { + setClipboardWriteAllowed(false); + }); + }; + + checkClipboardWriteAccess(); + }, []); + + return isClipboardWriteAllowed; +}; + +export default useClipboardWritePermission; diff --git a/apps/space/core/hooks/use-intersection-observer.tsx b/apps/space/core/hooks/use-intersection-observer.tsx new file mode 100644 index 00000000..0fb1a266 --- /dev/null +++ b/apps/space/core/hooks/use-intersection-observer.tsx @@ -0,0 +1,44 @@ +import type { RefObject } from "react"; +import { useEffect } from "react"; + +export type UseIntersectionObserverProps = { + containerRef: RefObject | undefined; + elementRef: HTMLElement | null; + callback: () => void; + rootMargin?: string; +}; + +export const useIntersectionObserver = ( + containerRef: RefObject, + elementRef: HTMLElement | null, + callback: (() => void) | undefined, + rootMargin?: string +) => { + useEffect(() => { + if (elementRef) { + const observer = new IntersectionObserver( + (entries) => { + if (entries[entries.length - 1].isIntersecting) { + if (callback) { + callback(); + } + } + }, + { + root: containerRef?.current, + rootMargin, + } + ); + observer.observe(elementRef); + return () => { + if (elementRef) { + // eslint-disable-next-line react-hooks/exhaustive-deps + observer.unobserve(elementRef); + } + }; + } + // When i am passing callback as a dependency, it is causing infinite loop, + // Please make sure you fix this eslint lint disable error with caution + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rootMargin, callback, elementRef, containerRef.current]); +}; diff --git a/apps/space/core/hooks/use-is-in-iframe.tsx b/apps/space/core/hooks/use-is-in-iframe.tsx new file mode 100644 index 00000000..2b582364 --- /dev/null +++ b/apps/space/core/hooks/use-is-in-iframe.tsx @@ -0,0 +1,17 @@ +import { useState, useEffect } from "react"; + +const useIsInIframe = () => { + const [isInIframe, setIsInIframe] = useState(false); + + useEffect(() => { + const checkIfInIframe = () => { + setIsInIframe(window.self !== window.top); + }; + + checkIfInIframe(); + }, []); + + return isInIframe; +}; + +export default useIsInIframe; diff --git a/apps/space/core/hooks/use-mention.tsx b/apps/space/core/hooks/use-mention.tsx new file mode 100644 index 00000000..fc66eda1 --- /dev/null +++ b/apps/space/core/hooks/use-mention.tsx @@ -0,0 +1,43 @@ +import { useRef, useEffect } from "react"; +import useSWR from "swr"; +// plane imports +import { UserService } from "@plane/services"; +import type { IUser } from "@plane/types"; + +export const useMention = () => { + const userService = new UserService(); + const { data: user, isLoading: userDataLoading } = useSWR("currentUser", async () => userService.me()); + + const userRef = useRef(); + + useEffect(() => { + if (userRef) { + userRef.current = user; + } + }, [user]); + + const waitForUserDate = async () => + new Promise((resolve) => { + const checkData = () => { + if (userRef.current) { + resolve(userRef.current); + } else { + setTimeout(checkData, 100); + } + }; + checkData(); + }); + + const mentionHighlights = async () => { + if (!userDataLoading && userRef.current) { + return [userRef.current.id]; + } else { + const user = await waitForUserDate(); + return [user.id]; + } + }; + + return { + mentionHighlights, + }; +}; diff --git a/apps/space/core/hooks/use-timer.tsx b/apps/space/core/hooks/use-timer.tsx new file mode 100644 index 00000000..1edf4931 --- /dev/null +++ b/apps/space/core/hooks/use-timer.tsx @@ -0,0 +1,19 @@ +import { useState, useEffect } from "react"; + +const TIMER = 30; + +const useTimer = (initialValue: number = TIMER) => { + const [timer, setTimer] = useState(initialValue); + + useEffect(() => { + const interval = setInterval(() => { + setTimer((prev) => prev - 1); + }, 1000); + + return () => clearInterval(interval); + }, []); + + return { timer, setTimer }; +}; + +export default useTimer; diff --git a/apps/space/core/lib/instance-provider.tsx b/apps/space/core/lib/instance-provider.tsx new file mode 100644 index 00000000..8ea98808 --- /dev/null +++ b/apps/space/core/lib/instance-provider.tsx @@ -0,0 +1,73 @@ +"use client"; + +import type { ReactNode } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +import { SPACE_BASE_PATH } from "@plane/constants"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { InstanceFailureView } from "@/components/instance/instance-failure-view"; +// hooks +import { useInstance } from "@/hooks/store/use-instance"; +import { useUser } from "@/hooks/store/use-user"; +// assets +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; + +export const InstanceProvider = observer(({ children }: { children: ReactNode }) => { + const { fetchInstanceInfo, instance, error } = useInstance(); + const { fetchCurrentUser } = useUser(); + const { resolvedTheme } = useTheme(); + + const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; + + useSWR("INSTANCE_INFO", () => fetchInstanceInfo(), { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + }); + useSWR("CURRENT_USER", () => fetchCurrentUser(), { + shouldRetryOnError: false, + revalidateOnFocus: true, + revalidateIfStale: true, + }); + + if (!instance && !error) + return ( +
    + +
    + ); + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + if (error) { + return ( +
    +
    +
    +
    + + Plane logo + +
    +
    +
    + Plane background pattern +
    +
    +
    + +
    +
    +
    +
    + ); + } + + return children; +}); diff --git a/apps/space/core/lib/store-provider.tsx b/apps/space/core/lib/store-provider.tsx new file mode 100644 index 00000000..b017f90c --- /dev/null +++ b/apps/space/core/lib/store-provider.tsx @@ -0,0 +1,36 @@ +"use client"; + +import type { ReactNode } from "react"; +import { createContext } from "react"; +// plane web store +import { RootStore } from "@/plane-web/store/root.store"; + +let rootStore = new RootStore(); + +export const StoreContext = createContext(rootStore); + +function initializeStore() { + const singletonRootStore = rootStore ?? new RootStore(); + // For SSG and SSR always create a new store + if (typeof window === "undefined") return singletonRootStore; + // Create the store once in the client + if (!rootStore) rootStore = singletonRootStore; + return singletonRootStore; +} + +export type StoreProviderProps = { + children: ReactNode; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initialState?: any; +}; + +export const StoreProvider = ({ children, initialState = undefined }: StoreProviderProps) => { + const store = initializeStore(); + // If your page has Next.js data fetching methods that use a Mobx store, it will + // get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details + if (initialState) { + store.hydrate(initialState); + } + + return {children}; +}; diff --git a/apps/space/core/lib/toast-provider.tsx b/apps/space/core/lib/toast-provider.tsx new file mode 100644 index 00000000..e76c7e01 --- /dev/null +++ b/apps/space/core/lib/toast-provider.tsx @@ -0,0 +1,19 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useTheme } from "next-themes"; +// plane imports +import { Toast } from "@plane/propel/toast"; +import { resolveGeneralTheme } from "@plane/utils"; + +export const ToastProvider = ({ children }: { children: ReactNode }) => { + // themes + const { resolvedTheme } = useTheme(); + + return ( + <> + + {children} + + ); +}; diff --git a/apps/space/core/store/cycle.store.ts b/apps/space/core/store/cycle.store.ts new file mode 100644 index 00000000..0f71d884 --- /dev/null +++ b/apps/space/core/store/cycle.store.ts @@ -0,0 +1,42 @@ +import { action, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesCycleService } from "@plane/services"; +import type { TPublicCycle } from "@/types/cycle"; +// store +import type { CoreRootStore } from "./root.store"; + +export interface ICycleStore { + // observables + cycles: TPublicCycle[] | undefined; + // computed actions + getCycleById: (cycleId: string | undefined) => TPublicCycle | undefined; + // fetch actions + fetchCycles: (anchor: string) => Promise; +} + +export class CycleStore implements ICycleStore { + cycles: TPublicCycle[] | undefined = undefined; + cycleService: SitesCycleService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + cycles: observable, + // fetch action + fetchCycles: action, + }); + this.cycleService = new SitesCycleService(); + this.rootStore = _rootStore; + } + + getCycleById = (cycleId: string | undefined) => this.cycles?.find((cycle) => cycle.id === cycleId); + + fetchCycles = async (anchor: string) => { + const cyclesResponse = await this.cycleService.list(anchor); + runInAction(() => { + this.cycles = cyclesResponse; + }); + return cyclesResponse; + }; +} diff --git a/apps/space/core/store/helpers/base-issues.store.ts b/apps/space/core/store/helpers/base-issues.store.ts new file mode 100644 index 00000000..01d4d706 --- /dev/null +++ b/apps/space/core/store/helpers/base-issues.store.ts @@ -0,0 +1,515 @@ +import { concat, get, set, uniq, update } from "lodash-es"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// plane imports +import { ALL_ISSUES } from "@plane/constants"; +import { SitesIssueService } from "@plane/services"; +import type { + TIssueGroupByOptions, + TGroupedIssues, + TSubGroupedIssues, + TLoader, + IssuePaginationOptions, + TIssues, + TIssuePaginationData, + TGroupedIssueCount, + TPaginationData, +} from "@plane/types"; +// types +import type { IIssue, TIssuesResponse } from "@/types/issue"; +import type { CoreRootStore } from "../root.store"; +// constants +// helpers + +export type TIssueDisplayFilterOptions = Exclude | "target_date"; + +export enum EIssueGroupedAction { + ADD = "ADD", + DELETE = "DELETE", + REORDER = "REORDER", +} + +export interface IBaseIssuesStore { + // observable + loader: Record; + // actions + addIssue(issues: IIssue[], shouldReplace?: boolean): void; + // helper methods + groupedIssueIds: TGroupedIssues | TSubGroupedIssues | undefined; // object to store Issue Ids based on group or subgroup + groupedIssueCount: TGroupedIssueCount; // map of groupId/subgroup and issue count of that particular group/subgroup + issuePaginationData: TIssuePaginationData; // map of groupId/subgroup and pagination Data of that particular group/subgroup + + // helper methods + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; + getPaginationData(groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined; + getIssueLoader(groupId?: string, subGroupId?: string): TLoader; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; +} + +export const ISSUE_FILTER_DEFAULT_DATA: Record = { + project: "project_id", + cycle: "cycle_id", + module: "module_ids", + state: "state_id", + "state_detail.group": "state_group" as keyof IIssue, // state_detail.group is only being used for state_group display, + priority: "priority", + labels: "label_ids", + created_by: "created_by", + assignees: "assignee_ids", + target_date: "target_date", +}; + +export abstract class BaseIssuesStore implements IBaseIssuesStore { + loader: Record = {}; + groupedIssueIds: TIssues | undefined = undefined; + issuePaginationData: TIssuePaginationData = {}; + groupedIssueCount: TGroupedIssueCount = {}; + // + paginationOptions: IssuePaginationOptions | undefined = undefined; + + issueService; + // root store + rootIssueStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observable + loader: observable, + groupedIssueIds: observable, + issuePaginationData: observable, + groupedIssueCount: observable, + + paginationOptions: observable, + // action + storePreviousPaginationValues: action.bound, + + onfetchIssues: action.bound, + onfetchNexIssues: action.bound, + clear: action.bound, + setLoader: action.bound, + }); + this.rootIssueStore = _rootStore; + this.issueService = new SitesIssueService(); + } + + getIssueIds = (groupId?: string, subGroupId?: string) => { + const groupedIssueIds = this.groupedIssueIds; + + if (!groupedIssueIds) return undefined; + + const allIssues = groupedIssueIds[ALL_ISSUES] ?? []; + if (allIssues && Array.isArray(allIssues)) { + return allIssues as string[]; + } + + if (groupId && groupedIssueIds?.[groupId] && Array.isArray(groupedIssueIds[groupId])) { + return (groupedIssueIds[groupId] ?? []) as string[]; + } + + if (groupId && subGroupId) { + return ((groupedIssueIds as TSubGroupedIssues)[groupId]?.[subGroupId] ?? []) as string[]; + } + + return undefined; + }; + + /** + * @description This method will add issues to the issuesMap + * @param {IIssue[]} issues + * @returns {void} + */ + addIssue = (issues: IIssue[], shouldReplace = false) => { + if (issues && issues.length <= 0) return; + runInAction(() => { + issues.forEach((issue) => { + if (!this.rootIssueStore.issueDetail.getIssueById(issue.id) || shouldReplace) + set(this.rootIssueStore.issueDetail.details, issue.id, issue); + }); + }); + }; + + /** + * Store the pagination data required for next subsequent issue pagination calls + * @param prevCursor cursor value of previous page + * @param nextCursor cursor value of next page + * @param nextPageResults boolean to indicate if the next page results exist i.e, have we reached end of pages + * @param groupId groupId and subGroupId to add the pagination data for the particular group/subgroup + * @param subGroupId + */ + setPaginationData( + prevCursor: string, + nextCursor: string, + nextPageResults: boolean, + groupId?: string, + subGroupId?: string + ) { + const cursorObject = { + prevCursor, + nextCursor, + nextPageResults, + }; + + set(this.issuePaginationData, [this.getGroupKey(groupId, subGroupId)], cursorObject); + } + + /** + * Sets the loader value of the particular groupId/subGroupId, or to ALL_ISSUES if both are undefined + * @param loaderValue + * @param groupId + * @param subGroupId + */ + setLoader(loaderValue: TLoader, groupId?: string, subGroupId?: string) { + runInAction(() => { + set(this.loader, this.getGroupKey(groupId, subGroupId), loaderValue); + }); + } + + /** + * gets the Loader value of particular group/subgroup/ALL_ISSUES + */ + getIssueLoader = (groupId?: string, subGroupId?: string) => get(this.loader, this.getGroupKey(groupId, subGroupId)); + + /** + * gets the pagination data of particular group/subgroup/ALL_ISSUES + */ + getPaginationData = computedFn( + (groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined => + get(this.issuePaginationData, [this.getGroupKey(groupId, subGroupId)]) + ); + + /** + * gets the issue count of particular group/subgroup/ALL_ISSUES + * + * if isSubGroupCumulative is true, sum up all the issueCount of the subGroupId, across all the groupIds + */ + getGroupIssueCount = computedFn( + ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ): number | undefined => { + if (isSubGroupCumulative && subGroupId) { + const groupIssuesKeys = Object.keys(this.groupedIssueCount); + let subGroupCumulativeCount = 0; + + for (const groupKey of groupIssuesKeys) { + if (groupKey.includes(`_${subGroupId}`)) subGroupCumulativeCount += this.groupedIssueCount[groupKey]; + } + + return subGroupCumulativeCount; + } + + return get(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)]); + } + ); + + /** + * This Method is called after fetching the first paginated issues + * + * This method updates the appropriate issue list based on if groupByKey or subGroupByKey are defined + * If both groupByKey and subGroupByKey are not defined, then the issue list are added to another group called ALL_ISSUES + * @param issuesResponse Paginated Response received from the API + * @param options Pagination options + * @param workspaceSlug + * @param projectId + * @param id Id can be anything from cycleId, moduleId, viewId or userId based on the store + */ + onfetchIssues(issuesResponse: TIssuesResponse, options: IssuePaginationOptions) { + // Process the Issue Response to get the following data from it + const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse); + + // The Issue list is added to the main Issue Map + this.addIssue(issueList); + + // Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts + runInAction(() => { + this.updateGroupedIssueIds(groupedIssues, groupedIssueCount); + this.loader[this.getGroupKey()] = undefined; + }); + + // store Pagination options for next subsequent calls and data like next cursor etc + this.storePreviousPaginationValues(issuesResponse, options); + } + + /** + * This Method is called on the subsequent pagination calls after the first initial call + * + * This method updates the appropriate issue list based on if groupId or subgroupIds are Passed + * @param issuesResponse Paginated Response received from the API + * @param groupId + * @param subGroupId + */ + onfetchNexIssues(issuesResponse: TIssuesResponse, groupId?: string, subGroupId?: string) { + // Process the Issue Response to get the following data from it + const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse); + + // The Issue list is added to the main Issue Map + this.addIssue(issueList); + + // Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts + runInAction(() => { + this.updateGroupedIssueIds(groupedIssues, groupedIssueCount, groupId, subGroupId); + this.loader[this.getGroupKey(groupId, subGroupId)] = undefined; + }); + + // store Pagination data like next cursor etc + this.storePreviousPaginationValues(issuesResponse, undefined, groupId, subGroupId); + } + + /** + * Method called to clear out the current store + */ + clear(shouldClearPaginationOptions = true) { + runInAction(() => { + this.groupedIssueIds = undefined; + this.issuePaginationData = {}; + this.groupedIssueCount = {}; + if (shouldClearPaginationOptions) { + this.paginationOptions = undefined; + } + }); + } + + /** + * This method processes the issueResponse to provide data that can be used to update the store + * @param issueResponse + * @returns issueList, list of issue Data + * @returns groupedIssues, grouped issue Ids + * @returns groupedIssueCount, object containing issue counts of individual groups + */ + processIssueResponse(issueResponse: TIssuesResponse): { + issueList: IIssue[]; + groupedIssues: TIssues; + groupedIssueCount: TGroupedIssueCount; + } { + const issueResult = issueResponse?.results; + + // if undefined return empty objects + if (!issueResult) + return { + issueList: [], + groupedIssues: {}, + groupedIssueCount: {}, + }; + + //if is an array then it's an ungrouped response. return values with groupId as ALL_ISSUES + if (Array.isArray(issueResult)) { + return { + issueList: issueResult, + groupedIssues: { + [ALL_ISSUES]: issueResult.map((issue) => issue.id), + }, + groupedIssueCount: { + [ALL_ISSUES]: issueResponse.total_count, + }, + }; + } + + const issueList: IIssue[] = []; + const groupedIssues: TGroupedIssues | TSubGroupedIssues = {}; + const groupedIssueCount: TGroupedIssueCount = {}; + + // update total issue count to ALL_ISSUES + set(groupedIssueCount, [ALL_ISSUES], issueResponse.total_count); + + // loop through all the groupIds from issue Result + for (const groupId in issueResult) { + const groupIssuesObject = issueResult[groupId]; + const groupIssueResult = groupIssuesObject?.results; + + // if groupIssueResult is undefined then continue the loop + if (!groupIssueResult) continue; + + // set grouped Issue count of the current groupId + set(groupedIssueCount, [groupId], groupIssuesObject.total_results); + + // if groupIssueResult, the it is not subGrouped + if (Array.isArray(groupIssueResult)) { + // add the result to issueList + issueList.push(...groupIssueResult); + // set the issue Ids to the groupId path + set( + groupedIssues, + [groupId], + groupIssueResult.map((issue) => issue.id) + ); + continue; + } + + // loop through all the subGroupIds from issue Result + for (const subGroupId in groupIssueResult) { + const subGroupIssuesObject = groupIssueResult[subGroupId]; + const subGroupIssueResult = subGroupIssuesObject?.results; + + // if subGroupIssueResult is undefined then continue the loop + if (!subGroupIssueResult) continue; + + // set sub grouped Issue count of the current groupId + set(groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], subGroupIssuesObject.total_results); + + if (Array.isArray(subGroupIssueResult)) { + // add the result to issueList + issueList.push(...subGroupIssueResult); + // set the issue Ids to the [groupId, subGroupId] path + set( + groupedIssues, + [groupId, subGroupId], + subGroupIssueResult.map((issue) => issue.id) + ); + + continue; + } + } + } + + return { issueList, groupedIssues, groupedIssueCount }; + } + + /** + * This method is used to update the grouped issue Ids to it's respected lists and also to update group Issue Counts + * @param groupedIssues Object that contains list of issueIds with respect to their groups/subgroups + * @param groupedIssueCount Object the contains the issue count of each groups + * @param groupId groupId string + * @param subGroupId subGroupId string + * @returns updates the store with the values + */ + updateGroupedIssueIds( + groupedIssues: TIssues, + groupedIssueCount: TGroupedIssueCount, + groupId?: string, + subGroupId?: string + ) { + // if groupId exists and groupedIssues has ALL_ISSUES as a group, + // then it's an individual group/subgroup pagination + if (groupId && groupedIssues[ALL_ISSUES] && Array.isArray(groupedIssues[ALL_ISSUES])) { + const issueGroup = groupedIssues[ALL_ISSUES]; + const issueGroupCount = groupedIssueCount[ALL_ISSUES]; + const issuesPath = [groupId]; + // issuesPath is the path for the issue List in the Grouped Issue List + // issuePath is either [groupId] for grouped pagination or [groupId, subGroupId] for subGrouped pagination + if (subGroupId) issuesPath.push(subGroupId); + + // update the issue Count of the particular group/subGroup + set(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], issueGroupCount); + + // update the issue list in the issuePath + this.updateIssueGroup(issueGroup, issuesPath); + return; + } + + // if not in the above condition the it's a complete grouped pagination not individual group/subgroup pagination + // update total issue count as ALL_ISSUES count in `groupedIssueCount` object + set(this.groupedIssueCount, [ALL_ISSUES], groupedIssueCount[ALL_ISSUES]); + + // loop through the groups of groupedIssues. + for (const groupId in groupedIssues) { + const issueGroup = groupedIssues[groupId]; + const issueGroupCount = groupedIssueCount[groupId]; + + // update the groupId's issue count + set(this.groupedIssueCount, [groupId], issueGroupCount); + + // This updates the group issue list in the store, if the issueGroup is a string + const storeUpdated = this.updateIssueGroup(issueGroup, [groupId]); + // if issueGroup is indeed a string, continue + if (storeUpdated) continue; + + // if issueGroup is not a string, loop through the sub group Issues + for (const subGroupId in issueGroup) { + const issueSubGroup = (issueGroup as TGroupedIssues)[subGroupId]; + const issueSubGroupCount = groupedIssueCount[this.getGroupKey(groupId, subGroupId)]; + + // update the subGroupId's issue count + set(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], issueSubGroupCount); + // This updates the subgroup issue list in the store + this.updateIssueGroup(issueSubGroup, [groupId, subGroupId]); + } + } + } + + /** + * This Method is used to update the issue Id list at the particular issuePath + * @param groupedIssueIds could be an issue Id List for grouped issues or an object that contains a issue Id list in case of subGrouped + * @param issuePath array of string, to identify the path of the issueList to be updated with the above issue Id list + * @returns a boolean that indicates if the groupedIssueIds is indeed a array Id list, in which case the issue Id list is added to the store at issuePath + */ + updateIssueGroup(groupedIssueIds: TGroupedIssues | string[], issuePath: string[]): boolean { + if (!groupedIssueIds) return true; + + // if groupedIssueIds is an array, update the `groupedIssueIds` store at the issuePath + if (groupedIssueIds && Array.isArray(groupedIssueIds)) { + update(this, ["groupedIssueIds", ...issuePath], (issueIds: string[] = []) => + uniq(concat(issueIds, groupedIssueIds as string[])) + ); + // return true to indicate the store has been updated + return true; + } + + // return false to indicate the store has been updated and the groupedIssueIds is likely Object for subGrouped Issues + return false; + } + + /** + * This method is used to update the count of the issues at the path with the increment + * @param path issuePath, corresponding key is to be incremented + * @param increment + */ + updateIssueCount(accumulatedUpdatesForCount: { [key: string]: EIssueGroupedAction }) { + const updateKeys = Object.keys(accumulatedUpdatesForCount); + for (const updateKey of updateKeys) { + const update = accumulatedUpdatesForCount[updateKey]; + if (!update) continue; + + const increment = update === EIssueGroupedAction.ADD ? 1 : -1; + // get current count at the key + const issueCount = get(this.groupedIssueCount, updateKey) ?? 0; + // update the count at the key + set(this.groupedIssueCount, updateKey, issueCount + increment); + } + } + + /** + * This Method is called to store the pagination options and paginated data from response + * @param issuesResponse issue list response + * @param options pagination options to be stored for next page call + * @param groupId + * @param subGroupId + */ + storePreviousPaginationValues = ( + issuesResponse: TIssuesResponse, + options?: IssuePaginationOptions, + groupId?: string, + subGroupId?: string + ) => { + if (options) this.paginationOptions = options; + + this.setPaginationData( + issuesResponse.prev_cursor, + issuesResponse.next_cursor, + issuesResponse.next_page_results, + groupId, + subGroupId + ); + }; + + /** + * returns, + * A compound key, if both groupId & subGroupId are defined + * groupId, only if groupId is defined + * ALL_ISSUES, if both groupId & subGroupId are not defined + * @param groupId + * @param subGroupId + * @returns + */ + getGroupKey = (groupId?: string, subGroupId?: string) => { + if (groupId && subGroupId && subGroupId !== "null") return `${groupId}_${subGroupId}`; + + if (groupId) return groupId; + + return ALL_ISSUES; + }; +} diff --git a/apps/space/core/store/helpers/filter.helpers.ts b/apps/space/core/store/helpers/filter.helpers.ts new file mode 100644 index 00000000..342f1ee7 --- /dev/null +++ b/apps/space/core/store/helpers/filter.helpers.ts @@ -0,0 +1,73 @@ +import { EIssueGroupByToServerOptions, EServerGroupByToFilterOptions } from "@plane/constants"; +import type { IssuePaginationOptions, TIssueParams } from "@plane/types"; + +/** + * This Method is used to construct the url params along with paginated values + * @param filterParams params generated from filters + * @param options pagination options + * @param cursor cursor if exists + * @param groupId groupId if to fetch By group + * @param subGroupId groupId if to fetch By sub group + * @returns + */ +export const getPaginationParams = ( + filterParams: Partial> | undefined, + options: IssuePaginationOptions, + cursor: string | undefined, + groupId?: string, + subGroupId?: string +) => { + // if cursor exists, use the cursor. If it doesn't exist construct the cursor based on per page count + const pageCursor = cursor ? cursor : groupId ? `${options.perPageCount}:1:0` : `${options.perPageCount}:0:0`; + + // pagination params + const paginationParams: Partial> = { + ...filterParams, + cursor: pageCursor, + per_page: options.perPageCount.toString(), + }; + + // If group by is specifically sent through options, like that for calendar layout, use that to group + if (options.groupedBy) { + paginationParams.group_by = EIssueGroupByToServerOptions[options.groupedBy]; + } + + // If group by is specifically sent through options, like that for calendar layout, use that to group + if (options.subGroupedBy) { + paginationParams.sub_group_by = EIssueGroupByToServerOptions[options.subGroupedBy]; + } + + // If group by is specifically sent through options, like that for calendar layout, use that to group + if (options.orderBy) { + paginationParams.order_by = options.orderBy; + } + + // If before and after dates are sent from option to filter by then, add them to filter the options + if (options.after && options.before) { + paginationParams["target_date"] = `${options.after};after,${options.before};before`; + } + + // If groupId is passed down, add a filter param for that group Id + if (groupId) { + const groupBy = paginationParams["group_by"] as EIssueGroupByToServerOptions | undefined; + delete paginationParams["group_by"]; + + if (groupBy) { + const groupByFilterOption = EServerGroupByToFilterOptions[groupBy]; + paginationParams[groupByFilterOption] = groupId; + } + } + + // If subGroupId is passed down, add a filter param for that subGroup Id + if (subGroupId) { + const subGroupBy = paginationParams["sub_group_by"] as EIssueGroupByToServerOptions | undefined; + delete paginationParams["sub_group_by"]; + + if (subGroupBy) { + const subGroupByFilterOption = EServerGroupByToFilterOptions[subGroupBy]; + paginationParams[subGroupByFilterOption] = subGroupId; + } + } + + return paginationParams; +}; diff --git a/apps/space/core/store/instance.store.ts b/apps/space/core/store/instance.store.ts new file mode 100644 index 00000000..a1a49118 --- /dev/null +++ b/apps/space/core/store/instance.store.ts @@ -0,0 +1,77 @@ +import { set } from "lodash-es"; +import { observable, action, makeObservable, runInAction } from "mobx"; +// plane imports +import { InstanceService } from "@plane/services"; +import type { IInstance, IInstanceConfig } from "@plane/types"; +// store +import type { CoreRootStore } from "@/store/root.store"; + +type TError = { + status: string; + message: string; + data?: { + is_activated: boolean; + is_setup_done: boolean; + }; +}; + +export interface IInstanceStore { + // observables + isLoading: boolean; + instance: IInstance | undefined; + config: IInstanceConfig | undefined; + error: TError | undefined; + // action + fetchInstanceInfo: () => Promise; + hydrate: (data: IInstance) => void; +} + +export class InstanceStore implements IInstanceStore { + isLoading: boolean = true; + instance: IInstance | undefined = undefined; + config: IInstanceConfig | undefined = undefined; + error: TError | undefined = undefined; + // services + instanceService; + + constructor(private store: CoreRootStore) { + makeObservable(this, { + // observable + isLoading: observable.ref, + instance: observable, + config: observable, + error: observable, + // actions + fetchInstanceInfo: action, + hydrate: action, + }); + // services + this.instanceService = new InstanceService(); + } + + hydrate = (data: IInstance) => set(this, "instance", data); + + /** + * @description fetching instance information + */ + fetchInstanceInfo = async () => { + try { + this.isLoading = true; + this.error = undefined; + const instanceInfo = await this.instanceService.info(); + runInAction(() => { + this.isLoading = false; + this.instance = instanceInfo.instance; + this.config = instanceInfo.config; + }); + } catch (_error) { + runInAction(() => { + this.isLoading = false; + this.error = { + status: "error", + message: "Failed to fetch instance info", + }; + }); + } + }; +} diff --git a/apps/space/core/store/issue-detail.store.ts b/apps/space/core/store/issue-detail.store.ts new file mode 100644 index 00000000..a9b2431a --- /dev/null +++ b/apps/space/core/store/issue-detail.store.ts @@ -0,0 +1,441 @@ +import { isEmpty, set } from "lodash-es"; +import { makeObservable, observable, action, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +import { v4 as uuidv4 } from "uuid"; +// plane imports +import { SitesFileService, SitesIssueService } from "@plane/services"; +import type { TFileSignedURLResponse, TIssuePublicComment } from "@plane/types"; +import { EFileAssetType } from "@plane/types"; +// store +import type { CoreRootStore } from "@/store/root.store"; +// types +import type { IIssue, IPeekMode, IVote } from "@/types/issue"; + +export interface IIssueDetailStore { + loader: boolean; + error: any; + // observables + peekId: string | null; + peekMode: IPeekMode; + details: { + [key: string]: IIssue; + }; + // computed actions + getIsIssuePeeked: (issueID: string) => boolean; + // actions + getIssueById: (issueId: string) => IIssue | undefined; + setPeekId: (issueID: string | null) => void; + setPeekMode: (mode: IPeekMode) => void; + // issue actions + fetchIssueDetails: (anchor: string, issueID: string) => void; + // comment actions + 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 + addIssueReaction: (anchor: string, issueID: string, reactionHex: string) => void; + removeIssueReaction: (anchor: string, issueID: string, reactionHex: string) => void; + // vote actions + addIssueVote: (anchor: string, issueID: string, data: { vote: 1 | -1 }) => Promise; + removeIssueVote: (anchor: string, issueID: string) => Promise; +} + +export class IssueDetailStore implements IIssueDetailStore { + loader: boolean = false; + error: any = null; + // observables + peekId: string | null = null; + peekMode: IPeekMode = "side"; + details: { + [key: string]: IIssue; + } = {}; + // root store + rootStore: CoreRootStore; + // services + issueService: SitesIssueService; + fileService: SitesFileService; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + loader: observable.ref, + error: observable.ref, + // observables + peekId: observable.ref, + peekMode: observable.ref, + details: observable, + // actions + setPeekId: action, + setPeekMode: action, + // issue actions + fetchIssueDetails: action, + // comment actions + addIssueComment: action, + updateIssueComment: action, + deleteIssueComment: action, + uploadCommentAsset: action, + uploadIssueAsset: action, + addCommentReaction: action, + removeCommentReaction: action, + // reaction actions + addIssueReaction: action, + removeIssueReaction: action, + // vote actions + addIssueVote: action, + removeIssueVote: action, + }); + this.rootStore = _rootStore; + this.issueService = new SitesIssueService(); + this.fileService = new SitesFileService(); + } + + setPeekId = (issueID: string | null) => { + this.peekId = issueID; + }; + + setPeekMode = (mode: IPeekMode) => { + this.peekMode = mode; + }; + + getIsIssuePeeked = (issueID: string) => this.peekId === issueID; + + /** + * @description This method will return the issue from the issuesMap + * @param {string} issueId + * @returns {IIssue | undefined} + */ + getIssueById = computedFn((issueId: string) => { + if (!issueId || isEmpty(this.details) || !this.details[issueId]) return undefined; + return this.details[issueId]; + }); + + /** + * Retrieves issue from API + * @param anchorId ] + * @param issueId + * @returns + */ + fetchIssueById = async (anchorId: string, issueId: string) => { + try { + const issueDetails = await this.issueService.retrieve(anchorId, issueId); + + runInAction(() => { + set(this.details, [issueId], issueDetails); + }); + + return issueDetails; + } catch (e) { + console.error(`Error fetching issue details for issueId ${issueId}: `, e); + } + }; + + /** + * @description fetc + * @param {string} anchor + * @param {string} issueID + */ + fetchIssueDetails = async (anchor: string, issueID: string) => { + try { + this.loader = true; + this.error = null; + + const issueDetails = await this.fetchIssueById(anchor, issueID); + const commentsResponse = await this.issueService.listComments(anchor, issueID); + + if (issueDetails) { + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...(this.details[issueID] ?? issueDetails), + comments: commentsResponse, + }, + }; + }); + } + } catch (error) { + this.loader = false; + this.error = error; + } + }; + + addIssueComment = async (anchor: string, issueID: string, data: any) => { + try { + const issueDetails = this.getIssueById(issueID); + const issueCommentResponse = await this.issueService.addComment(anchor, issueID, data); + if (issueDetails) { + runInAction(() => { + set(this.details, [issueID, "comments"], [...(issueDetails?.comments ?? []), issueCommentResponse]); + }); + } + return issueCommentResponse; + } catch (error) { + console.log("Failed to add issue comment"); + throw error; + } + }; + + updateIssueComment = async (anchor: string, issueID: string, commentID: string, data: any) => { + try { + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: this.details[issueID].comments.map((c) => ({ + ...c, + ...(c.id === commentID ? data : {}), + })), + }, + }; + }); + + await this.issueService.updateComment(anchor, issueID, commentID, data); + } catch (_error) { + const issueComments = await this.issueService.listComments(anchor, issueID); + + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: issueComments, + }, + }; + }); + } + }; + + deleteIssueComment = async (anchor: string, issueID: string, commentID: string) => { + try { + await this.issueService.removeComment(anchor, issueID, commentID); + const remainingComments = this.details[issueID].comments.filter((c) => c.id != commentID); + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: remainingComments, + }, + }; + }); + } catch (_error) { + console.log("Failed to add issue vote"); + } + }; + + 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(), + comment: commentID, + reaction: reactionHex, + actor_detail: this.rootStore.user.currentActor, + }; + const newComments = this.details[issueID].comments.map((comment) => ({ + ...comment, + comment_reactions: + comment.id === commentID ? [...comment.comment_reactions, newReaction] : comment.comment_reactions, + })); + + try { + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: [...newComments], + }, + }; + }); + + await this.issueService.addCommentReaction(anchor, commentID, { + reaction: reactionHex, + }); + } catch (_error) { + const issueComments = await this.issueService.listComments(anchor, issueID); + + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: issueComments, + }, + }; + }); + } + }; + + removeCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => { + try { + const comment = this.details[issueID].comments.find((c) => c.id === commentID); + const newCommentReactions = comment?.comment_reactions.filter((r) => r.reaction !== reactionHex) ?? []; + + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: this.details[issueID].comments.map((c) => ({ + ...c, + comment_reactions: c.id === commentID ? newCommentReactions : c.comment_reactions, + })), + }, + }; + }); + + await this.issueService.removeCommentReaction(anchor, commentID, reactionHex); + } catch (_error) { + const issueComments = await this.issueService.listComments(anchor, issueID); + + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: issueComments, + }, + }; + }); + } + }; + + addIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { + try { + runInAction(() => { + set( + this.details, + [issueID, "reaction_items"], + [ + ...this.details[issueID].reaction_items, + { + reaction: reactionHex, + actor_details: this.rootStore.user.currentActor, + }, + ] + ); + }); + + await this.issueService.addReaction(anchor, issueID, { + reaction: reactionHex, + }); + } catch (_error) { + console.log("Failed to add issue vote"); + const issueReactions = await this.issueService.listReactions(anchor, issueID); + runInAction(() => { + set(this.details, [issueID, "reaction_items"], issueReactions); + }); + } + }; + + removeIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { + try { + const newReactions = this.details[issueID].reaction_items.filter( + (_r) => !(_r.reaction === reactionHex && _r.actor_details.id === this.rootStore.user.data?.id) + ); + + runInAction(() => { + set(this.details, [issueID, "reaction_items"], newReactions); + }); + + await this.issueService.removeReaction(anchor, issueID, reactionHex); + } catch (_error) { + console.log("Failed to remove issue reaction"); + const reactions = await this.issueService.listReactions(anchor, issueID); + runInAction(() => { + set(this.details, [issueID, "reaction_items"], reactions); + }); + } + }; + + addIssueVote = async (anchor: string, issueID: string, data: { vote: 1 | -1 }) => { + const publishSettings = this.rootStore.publishList?.publishMap?.[anchor]; + const projectID = publishSettings?.project; + const workspaceSlug = publishSettings?.workspace_detail?.slug; + if (!projectID || !workspaceSlug) throw new Error("Publish settings not found"); + + const newVote: IVote = { + actor_details: this.rootStore.user.currentActor, + vote: data.vote, + }; + + const filteredVotes = this.details[issueID].vote_items.filter( + (v) => v.actor_details?.id !== this.rootStore.user.data?.id + ); + + try { + runInAction(() => { + runInAction(() => { + set(this.details, [issueID, "vote_items"], [...filteredVotes, newVote]); + }); + }); + + await this.issueService.addVote(anchor, issueID, data); + } catch (_error) { + console.log("Failed to add issue vote"); + const issueVotes = await this.issueService.listVotes(anchor, issueID); + + runInAction(() => { + set(this.details, [issueID, "vote_items"], issueVotes); + }); + } + }; + + removeIssueVote = async (anchor: string, issueID: string) => { + const newVotes = this.details[issueID].vote_items.filter( + (v) => v.actor_details?.id !== this.rootStore.user.data?.id + ); + + try { + runInAction(() => { + set(this.details, [issueID, "vote_items"], newVotes); + }); + + await this.issueService.removeVote(anchor, issueID); + } catch (_error) { + console.log("Failed to remove issue vote"); + const issueVotes = await this.issueService.listVotes(anchor, issueID); + + runInAction(() => { + set(this.details, [issueID, "vote_items"], issueVotes); + }); + } + }; +} diff --git a/apps/space/core/store/issue-filters.store.ts b/apps/space/core/store/issue-filters.store.ts new file mode 100644 index 00000000..a48b07a7 --- /dev/null +++ b/apps/space/core/store/issue-filters.store.ts @@ -0,0 +1,158 @@ +import { cloneDeep, isEqual, set } from "lodash-es"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// plane internal +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@plane/constants"; +import type { IssuePaginationOptions, TIssueParams } from "@plane/types"; +// store +import type { CoreRootStore } from "@/store/root.store"; +// types +import type { + TIssueLayoutOptions, + TIssueFilters, + TIssueQueryFilters, + TIssueQueryFiltersParams, + TIssueFilterKeys, +} from "@/types/issue"; +import { getPaginationParams } from "./helpers/filter.helpers"; + +export interface IIssueFilterStore { + // observables + layoutOptions: TIssueLayoutOptions; + filters: { [anchor: string]: TIssueFilters } | undefined; + // computed + isIssueFiltersUpdated: (anchor: string, filters: TIssueFilters) => boolean; + // helpers + getIssueFilters: (anchor: string) => TIssueFilters | undefined; + getAppliedFilters: (anchor: string) => TIssueQueryFiltersParams | undefined; + // actions + updateLayoutOptions: (layout: TIssueLayoutOptions) => void; + initIssueFilters: (anchor: string, filters: TIssueFilters, shouldFetchIssues?: boolean) => void; + updateIssueFilters: ( + anchor: string, + filterKind: K, + filterKey: keyof TIssueFilters[K], + filters: TIssueFilters[K][typeof filterKey] + ) => Promise; + getFilterParams: ( + options: IssuePaginationOptions, + anchor: string, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; +} + +export class IssueFilterStore implements IIssueFilterStore { + // observables + layoutOptions: TIssueLayoutOptions = { + list: true, + kanban: false, + calendar: false, + gantt: false, + spreadsheet: false, + }; + filters: { [anchor: string]: TIssueFilters } | undefined = undefined; + + constructor(private store: CoreRootStore) { + makeObservable(this, { + // observables + layoutOptions: observable, + filters: observable, + // actions + updateLayoutOptions: action, + initIssueFilters: action, + updateIssueFilters: action, + }); + } + + // helper methods + computedFilter = (filters: TIssueQueryFilters, filteredParams: TIssueFilterKeys[]) => { + const computedFilters: TIssueQueryFiltersParams = {}; + + Object.keys(filters).map((key) => { + const currentFilterKey = key as TIssueFilterKeys; + const filterValue = filters[currentFilterKey] as any; + + if (filterValue !== undefined && filteredParams.includes(currentFilterKey)) { + if (Array.isArray(filterValue)) computedFilters[currentFilterKey] = filterValue.join(","); + else if (typeof filterValue === "string" || typeof filterValue === "boolean") + computedFilters[currentFilterKey] = filterValue.toString(); + } + }); + + return computedFilters; + }; + + // computed + getIssueFilters = computedFn((anchor: string) => { + const currentFilters = this.filters?.[anchor]; + return currentFilters; + }); + + getAppliedFilters = computedFn((anchor: string) => { + const issueFilters = this.getIssueFilters(anchor); + if (!issueFilters) return undefined; + + const currentLayout = issueFilters?.display_filters?.layout; + if (!currentLayout) return undefined; + + const currentFilters: TIssueQueryFilters = { + priority: issueFilters?.filters?.priority || undefined, + state: issueFilters?.filters?.state || undefined, + labels: issueFilters?.filters?.labels || undefined, + }; + const filteredParams = ISSUE_DISPLAY_FILTERS_BY_LAYOUT?.[currentLayout]?.filters || []; + const currentFilterQueryParams: TIssueQueryFiltersParams = this.computedFilter(currentFilters, filteredParams); + + return currentFilterQueryParams; + }); + + isIssueFiltersUpdated = computedFn((anchor: string, userFilters: TIssueFilters) => { + const issueFilters = this.getIssueFilters(anchor); + if (!issueFilters) return false; + const currentUserFilters = cloneDeep(userFilters?.filters || {}); + const currentIssueFilters = cloneDeep(issueFilters?.filters || {}); + return isEqual(currentUserFilters, currentIssueFilters); + }); + + // actions + updateLayoutOptions = (options: TIssueLayoutOptions) => set(this, ["layoutOptions"], options); + + initIssueFilters = async (anchor: string, initFilters: TIssueFilters, shouldFetchIssues: boolean = false) => { + if (this.filters === undefined) runInAction(() => (this.filters = {})); + if (this.filters && initFilters) set(this.filters, [anchor], initFilters); + + if (shouldFetchIssues) await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation"); + }; + + getFilterParams = computedFn( + ( + options: IssuePaginationOptions, + anchor: string, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.getAppliedFilters(anchor); + const paginationParams = getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + + updateIssueFilters = async ( + anchor: string, + filterKind: K, + filterKey: keyof TIssueFilters[K], + filterValue: TIssueFilters[K][typeof filterKey] + ) => { + if (!filterKind || !filterKey || !filterValue) return; + if (this.filters === undefined) runInAction(() => (this.filters = {})); + + runInAction(() => { + if (this.filters) set(this.filters, [anchor, filterKind, filterKey], filterValue); + }); + + if (filterKey !== "layout") await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation"); + }; +} diff --git a/apps/space/core/store/issue.store.ts b/apps/space/core/store/issue.store.ts new file mode 100644 index 00000000..ed4112a8 --- /dev/null +++ b/apps/space/core/store/issue.store.ts @@ -0,0 +1,112 @@ +import { action, makeObservable, runInAction } from "mobx"; +// plane imports +import { SitesIssueService } from "@plane/services"; +import type { IssuePaginationOptions, TLoader } from "@plane/types"; +// store +import type { CoreRootStore } from "@/store/root.store"; +// types +import { BaseIssuesStore } from "./helpers/base-issues.store"; +import type { IBaseIssuesStore } from "./helpers/base-issues.store"; + +export interface IIssueStore extends IBaseIssuesStore { + // actions + fetchPublicIssues: ( + anchor: string, + loadType: TLoader, + options: IssuePaginationOptions, + isExistingPaginationOptions?: boolean + ) => Promise; + fetchNextPublicIssues: (anchor: string, groupId?: string, subGroupId?: string) => Promise; + fetchPublicIssuesWithExistingPagination: (anchor: string, loadType?: TLoader) => Promise; +} + +export class IssueStore extends BaseIssuesStore implements IIssueStore { + // root store + rootStore: CoreRootStore; + // services + issueService: SitesIssueService; + + constructor(_rootStore: CoreRootStore) { + super(_rootStore); + makeObservable(this, { + // actions + fetchPublicIssues: action, + fetchNextPublicIssues: action, + fetchPublicIssuesWithExistingPagination: action, + }); + + this.rootStore = _rootStore; + this.issueService = new SitesIssueService(); + } + + /** + * @description fetch issues, states and labels + * @param {string} anchor + * @param params + */ + fetchPublicIssues = async ( + anchor: string, + loadType: TLoader = "init-loader", + options: IssuePaginationOptions, + isExistingPaginationOptions: boolean = false + ) => { + try { + // set loader and clear store + runInAction(() => { + this.setLoader(loadType); + }); + this.clear(!isExistingPaginationOptions); + + const params = this.rootStore.issueFilter.getFilterParams(options, anchor, undefined, undefined, undefined); + + const response = await this.issueService.list(anchor, params); + + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options); + } catch (error) { + this.setLoader(undefined); + throw error; + } + }; + + fetchNextPublicIssues = async (anchor: string, groupId?: string, subGroupId?: string) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; + try { + // set Loader + this.setLoader("pagination", groupId, subGroupId); + + // get params from stored pagination options + const params = this.rootStore.issueFilter.getFilterParams( + this.paginationOptions, + anchor, + cursorObject?.nextCursor, + groupId, + subGroupId + ); + // call the fetch issues API with the params for next page in issues + const response = await this.issueService.list(anchor, params); + + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); + } catch (error) { + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); + throw error; + } + }; + + /** + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param projectId + * @param loadType + * @returns + */ + fetchPublicIssuesWithExistingPagination = async (anchor: string, loadType: TLoader = "mutation") => { + if (!this.paginationOptions) return; + return await this.fetchPublicIssues(anchor, loadType, this.paginationOptions, true); + }; +} diff --git a/apps/space/core/store/label.store.ts b/apps/space/core/store/label.store.ts new file mode 100644 index 00000000..53ffcbc6 --- /dev/null +++ b/apps/space/core/store/label.store.ts @@ -0,0 +1,65 @@ +import { set } from "lodash-es"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesLabelService } from "@plane/services"; +import type { IIssueLabel } from "@plane/types"; +// store +import type { CoreRootStore } from "./root.store"; + +export interface IIssueLabelStore { + // observables + labels: IIssueLabel[] | undefined; + // computed actions + getLabelById: (labelId: string | undefined) => IIssueLabel | undefined; + getLabelsByIds: (labelIds: string[]) => IIssueLabel[]; + // fetch actions + fetchLabels: (anchor: string) => Promise; +} + +export class LabelStore implements IIssueLabelStore { + labelMap: Record = {}; + labelService: SitesLabelService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + labelMap: observable, + // computed + labels: computed, + // fetch action + fetchLabels: action, + }); + this.labelService = new SitesLabelService(); + this.rootStore = _rootStore; + } + + get labels() { + return Object.values(this.labelMap); + } + + getLabelById = (labelId: string | undefined) => (labelId ? this.labelMap[labelId] : undefined); + + getLabelsByIds = (labelIds: string[]) => { + const currLabels = []; + for (const labelId of labelIds) { + const label = this.getLabelById(labelId); + if (label) { + currLabels.push(label); + } + } + + return currLabels; + }; + + fetchLabels = async (anchor: string) => { + const labelsResponse = await this.labelService.list(anchor); + runInAction(() => { + this.labelMap = {}; + for (const label of labelsResponse) { + set(this.labelMap, [label.id], label); + } + }); + return labelsResponse; + }; +} diff --git a/apps/space/core/store/members.store.ts b/apps/space/core/store/members.store.ts new file mode 100644 index 00000000..45abdc71 --- /dev/null +++ b/apps/space/core/store/members.store.ts @@ -0,0 +1,69 @@ +import { set } from "lodash-es"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesMemberService } from "@plane/services"; +import type { TPublicMember } from "@/types/member"; +import type { CoreRootStore } from "./root.store"; + +export interface IIssueMemberStore { + // observables + members: TPublicMember[] | undefined; + // computed actions + getMemberById: (memberId: string | undefined) => TPublicMember | undefined; + getMembersByIds: (memberIds: string[]) => TPublicMember[]; + // fetch actions + fetchMembers: (anchor: string) => Promise; +} + +export class MemberStore implements IIssueMemberStore { + memberMap: Record = {}; + memberService: SitesMemberService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + memberMap: observable, + // computed + members: computed, + // fetch action + fetchMembers: action, + }); + this.memberService = new SitesMemberService(); + this.rootStore = _rootStore; + } + + get members() { + return Object.values(this.memberMap); + } + + getMemberById = (memberId: string | undefined) => (memberId ? this.memberMap[memberId] : undefined); + + getMembersByIds = (memberIds: string[]) => { + const currMembers = []; + for (const memberId of memberIds) { + const member = this.getMemberById(memberId); + if (member) { + currMembers.push(member); + } + } + + return currMembers; + }; + + fetchMembers = async (anchor: string) => { + try { + const membersResponse = await this.memberService.list(anchor); + runInAction(() => { + this.memberMap = {}; + for (const member of membersResponse) { + set(this.memberMap, [member.member], member); + } + }); + return membersResponse; + } catch (error) { + console.error("Failed to fetch members:", error); + return []; + } + }; +} diff --git a/apps/space/core/store/module.store.ts b/apps/space/core/store/module.store.ts new file mode 100644 index 00000000..0635d6f8 --- /dev/null +++ b/apps/space/core/store/module.store.ts @@ -0,0 +1,71 @@ +import { set } from "lodash-es"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesModuleService } from "@plane/services"; +// types +import type { TPublicModule } from "@/types/modules"; +// root store +import type { CoreRootStore } from "./root.store"; + +export interface IIssueModuleStore { + // observables + modules: TPublicModule[] | undefined; + // computed actions + getModuleById: (moduleId: string | undefined) => TPublicModule | undefined; + getModulesByIds: (moduleIds: string[]) => TPublicModule[]; + // fetch actions + fetchModules: (anchor: string) => Promise; +} + +export class ModuleStore implements IIssueModuleStore { + moduleMap: Record = {}; + moduleService: SitesModuleService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + moduleMap: observable, + // computed + modules: computed, + // fetch action + fetchModules: action, + }); + this.moduleService = new SitesModuleService(); + this.rootStore = _rootStore; + } + + get modules() { + return Object.values(this.moduleMap); + } + + getModuleById = (moduleId: string | undefined) => (moduleId ? this.moduleMap[moduleId] : undefined); + + getModulesByIds = (moduleIds: string[]) => { + const currModules = []; + for (const moduleId of moduleIds) { + const issueModule = this.getModuleById(moduleId); + if (issueModule) { + currModules.push(issueModule); + } + } + + return currModules; + }; + + fetchModules = async (anchor: string) => { + try { + const modulesResponse = await this.moduleService.list(anchor); + runInAction(() => { + this.moduleMap = {}; + for (const issueModule of modulesResponse) { + set(this.moduleMap, [issueModule.id], issueModule); + } + }); + return modulesResponse; + } catch (error) { + console.error("Failed to fetch members:", error); + return []; + } + }; +} diff --git a/apps/space/core/store/profile.store.ts b/apps/space/core/store/profile.store.ts new file mode 100644 index 00000000..009b46ca --- /dev/null +++ b/apps/space/core/store/profile.store.ts @@ -0,0 +1,138 @@ +import { set } from "lodash-es"; +import { action, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { UserService } from "@plane/services"; +import type { TUserProfile } from "@plane/types"; +import { EStartOfTheWeek } from "@plane/types"; +// store +import type { CoreRootStore } from "@/store/root.store"; + +type TError = { + status: string; + message: string; +}; + +export interface IProfileStore { + // observables + isLoading: boolean; + error: TError | undefined; + data: TUserProfile; + // actions + fetchUserProfile: () => Promise; + updateUserProfile: (data: Partial) => Promise; +} + +export class ProfileStore implements IProfileStore { + isLoading: boolean = false; + error: TError | undefined = undefined; + data: TUserProfile = { + id: undefined, + user: undefined, + role: undefined, + last_workspace_id: undefined, + theme: { + theme: undefined, + text: undefined, + palette: undefined, + primary: undefined, + background: undefined, + darkPalette: undefined, + sidebarText: undefined, + sidebarBackground: undefined, + }, + onboarding_step: { + workspace_join: false, + profile_complete: false, + workspace_create: false, + workspace_invite: false, + }, + is_onboarded: false, + is_tour_completed: false, + use_case: undefined, + billing_address_country: undefined, + billing_address: undefined, + has_billing_address: false, + has_marketing_email_consent: false, + created_at: "", + updated_at: "", + language: "", + start_of_the_week: EStartOfTheWeek.SUNDAY, + }; + + // services + userService: UserService; + + constructor(public store: CoreRootStore) { + makeObservable(this, { + // observables + isLoading: observable.ref, + error: observable, + data: observable, + // actions + fetchUserProfile: action, + updateUserProfile: action, + }); + // services + this.userService = new UserService(); + } + + // actions + /** + * @description fetches user profile information + * @returns {Promise} + */ + fetchUserProfile = async () => { + try { + runInAction(() => { + this.isLoading = true; + this.error = undefined; + }); + const userProfile = await this.userService.profile(); + runInAction(() => { + this.isLoading = false; + this.data = userProfile; + }); + return userProfile; + } catch (_error) { + runInAction(() => { + this.isLoading = false; + this.error = { + status: "user-profile-fetch-error", + message: "Failed to fetch user profile", + }; + }); + } + }; + + /** + * @description updated the user profile information + * @param {Partial} data + * @returns {Promise} + */ + updateUserProfile = async (data: Partial) => { + const currentUserProfileData = this.data; + try { + if (currentUserProfileData) { + Object.keys(data).forEach((key: string) => { + const userKey: keyof TUserProfile = key as keyof TUserProfile; + if (this.data) set(this.data, userKey, data[userKey]); + }); + } + const userProfile = await this.userService.updateProfile(data); + return userProfile; + } catch (_error) { + if (currentUserProfileData) { + Object.keys(currentUserProfileData).forEach((key: string) => { + const userKey: keyof TUserProfile = key as keyof TUserProfile; + if (this.data) set(this.data, userKey, currentUserProfileData[userKey]); + }); + } + runInAction(() => { + this.error = { + status: "user-profile-update-error", + message: "Failed to update user profile", + }; + }); + } + }; +} diff --git a/apps/space/core/store/publish/publish.store.ts b/apps/space/core/store/publish/publish.store.ts new file mode 100644 index 00000000..49148d55 --- /dev/null +++ b/apps/space/core/store/publish/publish.store.ts @@ -0,0 +1,117 @@ +import { observable, makeObservable, computed } from "mobx"; +// types +import type { + IWorkspaceLite, + TProjectDetails, + TPublishEntityType, + TProjectPublishSettings, + TProjectPublishViewProps, +} from "@plane/types"; +// store +import type { CoreRootStore } from "../root.store"; + +export interface IPublishStore extends TProjectPublishSettings { + // computed + workspaceSlug: string | undefined; + canComment: boolean; + canReact: boolean; + canVote: boolean; +} + +export class PublishStore implements IPublishStore { + // observables + anchor: string | undefined; + is_comments_enabled: boolean; + created_at: string | undefined; + created_by: string | undefined; + entity_identifier: string | undefined; + entity_name: TPublishEntityType | undefined; + id: string | undefined; + inbox: unknown; + project: string | undefined; + project_details: TProjectDetails | undefined; + is_reactions_enabled: boolean; + updated_at: string | undefined; + updated_by: string | undefined; + view_props: TProjectPublishViewProps | undefined; + is_votes_enabled: boolean; + workspace: string | undefined; + workspace_detail: IWorkspaceLite | undefined; + + constructor( + private store: CoreRootStore, + publishSettings: TProjectPublishSettings + ) { + this.anchor = publishSettings.anchor; + this.is_comments_enabled = publishSettings.is_comments_enabled; + this.created_at = publishSettings.created_at; + this.created_by = publishSettings.created_by; + this.entity_identifier = publishSettings.entity_identifier; + this.entity_name = publishSettings.entity_name; + this.id = publishSettings.id; + this.inbox = publishSettings.inbox; + this.project = publishSettings.project; + this.project_details = publishSettings.project_details; + this.is_reactions_enabled = publishSettings.is_reactions_enabled; + this.updated_at = publishSettings.updated_at; + this.updated_by = publishSettings.updated_by; + this.view_props = publishSettings.view_props; + this.is_votes_enabled = publishSettings.is_votes_enabled; + this.workspace = publishSettings.workspace; + this.workspace_detail = publishSettings.workspace_detail; + + makeObservable(this, { + // observables + anchor: observable.ref, + is_comments_enabled: observable.ref, + created_at: observable.ref, + created_by: observable.ref, + entity_identifier: observable.ref, + entity_name: observable.ref, + id: observable.ref, + inbox: observable, + project: observable.ref, + project_details: observable, + is_reactions_enabled: observable.ref, + updated_at: observable.ref, + updated_by: observable.ref, + view_props: observable, + is_votes_enabled: observable.ref, + workspace: observable.ref, + workspace_detail: observable, + // computed + workspaceSlug: computed, + canComment: computed, + canReact: computed, + canVote: computed, + }); + } + + /** + * @description returns the workspace slug from the workspace details + */ + get workspaceSlug() { + return this?.workspace_detail?.slug ?? undefined; + } + + /** + * @description returns whether commenting is enabled or not + */ + get canComment() { + return !!this.is_comments_enabled; + } + + /** + * @description returns whether reacting is enabled or not + */ + get canReact() { + return !!this.is_reactions_enabled; + } + + /** + * @description returns whether voting is enabled or not + */ + get canVote() { + return !!this.is_votes_enabled; + } +} diff --git a/apps/space/core/store/publish/publish_list.store.ts b/apps/space/core/store/publish/publish_list.store.ts new file mode 100644 index 00000000..9cb9085f --- /dev/null +++ b/apps/space/core/store/publish/publish_list.store.ts @@ -0,0 +1,47 @@ +import { set } from "lodash-es"; +import { makeObservable, observable, runInAction, action } from "mobx"; +// plane imports +import { SitesProjectPublishService } from "@plane/services"; +import type { TProjectPublishSettings } from "@plane/types"; +// store +import { PublishStore } from "@/store/publish/publish.store"; +import type { CoreRootStore } from "@/store/root.store"; + +export interface IPublishListStore { + // observables + publishMap: Record; // anchor => PublishStore + // actions + fetchPublishSettings: (pageId: string) => Promise; +} + +export class PublishListStore implements IPublishListStore { + // observables + publishMap: Record = {}; // anchor => PublishStore + // service + publishService; + + constructor(private rootStore: CoreRootStore) { + makeObservable(this, { + // observables + publishMap: observable, + // actions + fetchPublishSettings: action, + }); + // services + this.publishService = new SitesProjectPublishService(); + } + + /** + * @description fetch publish settings + * @param {string} anchor + */ + fetchPublishSettings = async (anchor: string) => { + const response = await this.publishService.retrieveSettingsByAnchor(anchor); + runInAction(() => { + if (response.anchor) { + set(this.publishMap, [response.anchor], new PublishStore(this.rootStore, response)); + } + }); + return response; + }; +} diff --git a/apps/space/core/store/root.store.ts b/apps/space/core/store/root.store.ts new file mode 100644 index 00000000..047e8582 --- /dev/null +++ b/apps/space/core/store/root.store.ts @@ -0,0 +1,76 @@ +import { enableStaticRendering } from "mobx-react"; +// store imports +import type { IInstanceStore } from "@/store/instance.store"; +import { InstanceStore } from "@/store/instance.store"; +import type { IIssueDetailStore } from "@/store/issue-detail.store"; +import { IssueDetailStore } from "@/store/issue-detail.store"; +import type { IIssueStore } from "@/store/issue.store"; +import { IssueStore } from "@/store/issue.store"; +import type { IUserStore } from "@/store/user.store"; +import { UserStore } from "@/store/user.store"; +import type { ICycleStore } from "./cycle.store"; +import { CycleStore } from "./cycle.store"; +import type { IIssueFilterStore } from "./issue-filters.store"; +import { IssueFilterStore } from "./issue-filters.store"; +import type { IIssueLabelStore } from "./label.store"; +import { LabelStore } from "./label.store"; +import type { IIssueMemberStore } from "./members.store"; +import { MemberStore } from "./members.store"; +import type { IIssueModuleStore } from "./module.store"; +import { ModuleStore } from "./module.store"; +import type { IPublishListStore } from "./publish/publish_list.store"; +import { PublishListStore } from "./publish/publish_list.store"; +import type { IStateStore } from "./state.store"; +import { StateStore } from "./state.store"; + +enableStaticRendering(typeof window === "undefined"); + +export class CoreRootStore { + instance: IInstanceStore; + user: IUserStore; + issue: IIssueStore; + issueDetail: IIssueDetailStore; + state: IStateStore; + label: IIssueLabelStore; + module: IIssueModuleStore; + member: IIssueMemberStore; + cycle: ICycleStore; + issueFilter: IIssueFilterStore; + publishList: IPublishListStore; + + constructor() { + this.instance = new InstanceStore(this); + this.user = new UserStore(this); + this.issue = new IssueStore(this); + this.issueDetail = new IssueDetailStore(this); + this.state = new StateStore(this); + this.label = new LabelStore(this); + this.module = new ModuleStore(this); + this.member = new MemberStore(this); + this.cycle = new CycleStore(this); + this.issueFilter = new IssueFilterStore(this); + this.publishList = new PublishListStore(this); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hydrate = (data: any) => { + if (!data) return; + this.instance.hydrate(data?.instance || undefined); + this.user.hydrate(data?.user || undefined); + }; + + reset() { + localStorage.setItem("theme", "system"); + this.instance = new InstanceStore(this); + this.user = new UserStore(this); + this.issue = new IssueStore(this); + this.issueDetail = new IssueDetailStore(this); + this.state = new StateStore(this); + this.label = new LabelStore(this); + this.module = new ModuleStore(this); + this.member = new MemberStore(this); + this.cycle = new CycleStore(this); + this.issueFilter = new IssueFilterStore(this); + this.publishList = new PublishListStore(this); + } +} diff --git a/apps/space/core/store/state.store.ts b/apps/space/core/store/state.store.ts new file mode 100644 index 00000000..1de0fd6a --- /dev/null +++ b/apps/space/core/store/state.store.ts @@ -0,0 +1,54 @@ +import { clone } from "lodash-es"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesStateService } from "@plane/services"; +import type { IState } from "@plane/types"; +// helpers +import { sortStates } from "@/helpers/state.helper"; +// store +import type { CoreRootStore } from "./root.store"; + +export interface IStateStore { + // observables + states: IState[] | undefined; + //computed + sortedStates: IState[] | undefined; + // computed actions + getStateById: (stateId: string | undefined) => IState | undefined; + // fetch actions + fetchStates: (anchor: string) => Promise; +} + +export class StateStore implements IStateStore { + states: IState[] | undefined = undefined; + stateService: SitesStateService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + states: observable, + // computed + sortedStates: computed, + // fetch action + fetchStates: action, + }); + this.stateService = new SitesStateService(); + this.rootStore = _rootStore; + } + + get sortedStates() { + if (!this.states) return; + return sortStates(clone(this.states)); + } + + getStateById = (stateId: string | undefined) => this.states?.find((state) => state.id === stateId); + + fetchStates = async (anchor: string) => { + const statesResponse = await this.stateService.list(anchor); + runInAction(() => { + this.states = statesResponse; + }); + return statesResponse; + }; +} diff --git a/apps/space/core/store/user.store.ts b/apps/space/core/store/user.store.ts new file mode 100644 index 00000000..611afa48 --- /dev/null +++ b/apps/space/core/store/user.store.ts @@ -0,0 +1,184 @@ +import { AxiosError } from "axios"; +import { set } from "lodash-es"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { UserService } from "@plane/services"; +import type { ActorDetail, IUser } from "@plane/types"; +// store types +import type { IProfileStore } from "@/store/profile.store"; +import { ProfileStore } from "@/store/profile.store"; +// store +import type { CoreRootStore } from "@/store/root.store"; + +type TUserErrorStatus = { + status: string; + message: string; +}; + +export interface IUserStore { + // observables + isAuthenticated: boolean; + isInitializing: boolean; + error: TUserErrorStatus | undefined; + data: IUser | undefined; + // store observables + profile: IProfileStore; + // computed + currentActor: ActorDetail; + // actions + fetchCurrentUser: () => Promise; + updateCurrentUser: (data: Partial) => Promise; + hydrate: (data: IUser | undefined) => void; + reset: () => void; + signOut: () => Promise; +} + +export class UserStore implements IUserStore { + // observables + isAuthenticated: boolean = false; + isInitializing: boolean = true; + error: TUserErrorStatus | undefined = undefined; + data: IUser | undefined = undefined; + // store observables + profile: IProfileStore; + // service + userService: UserService; + + constructor(private store: CoreRootStore) { + // stores + this.profile = new ProfileStore(store); + // service + this.userService = new UserService(); + // observables + makeObservable(this, { + // observables + isAuthenticated: observable.ref, + isInitializing: observable.ref, + error: observable, + // model observables + data: observable, + profile: observable, + // computed + currentActor: computed, + // actions + fetchCurrentUser: action, + updateCurrentUser: action, + reset: action, + signOut: action, + }); + } + + // computed + get currentActor(): ActorDetail { + return { + id: this.data?.id, + first_name: this.data?.first_name, + last_name: this.data?.last_name, + display_name: this.data?.display_name, + avatar_url: this.data?.avatar_url || undefined, + is_bot: false, + }; + } + + // actions + /** + * @description fetches the current user + * @returns {Promise} + */ + fetchCurrentUser = async (): Promise => { + try { + runInAction(() => { + if (this.data === undefined && !this.error) this.isInitializing = true; + this.error = undefined; + }); + const user = await this.userService.me(); + if (user && user?.id) { + await this.profile.fetchUserProfile(); + runInAction(() => { + this.data = user; + this.isInitializing = false; + this.isAuthenticated = true; + }); + } else + runInAction(() => { + this.data = user; + this.isInitializing = false; + this.isAuthenticated = false; + }); + return user; + } catch (error) { + runInAction(() => { + this.isInitializing = false; + this.isAuthenticated = false; + this.error = { + status: "user-fetch-error", + message: "Failed to fetch current user", + }; + if (error instanceof AxiosError && error.status === 401) { + this.data = undefined; + } + }); + throw error; + } + }; + + /** + * @description updates the current user + * @param data + * @returns {Promise} + */ + updateCurrentUser = async (data: Partial): Promise => { + const currentUserData = this.data; + try { + if (currentUserData) { + Object.keys(data).forEach((key: string) => { + const userKey: keyof IUser = key as keyof IUser; + if (this.data) set(this.data, userKey, data[userKey]); + }); + } + const user = await this.userService.update(data); + return user; + } catch (error) { + if (currentUserData) { + Object.keys(currentUserData).forEach((key: string) => { + const userKey: keyof IUser = key as keyof IUser; + if (this.data) set(this.data, userKey, currentUserData[userKey]); + }); + } + runInAction(() => { + this.error = { + status: "user-update-error", + message: "Failed to update current user", + }; + }); + throw error; + } + }; + + hydrate = (data: IUser | undefined): void => { + if (!data) return; + this.data = { ...this.data, ...data }; + }; + + /** + * @description resets the user store + * @returns {void} + */ + reset = (): void => { + runInAction(() => { + this.isAuthenticated = false; + this.isInitializing = false; + this.error = undefined; + this.data = undefined; + this.profile = new ProfileStore(this.store); + }); + }; + + /** + * @description signs out the current user + * @returns {Promise} + */ + signOut = async (): Promise => { + this.store.reset(); + }; +} diff --git a/apps/space/core/types/auth.ts b/apps/space/core/types/auth.ts new file mode 100644 index 00000000..19a61687 --- /dev/null +++ b/apps/space/core/types/auth.ts @@ -0,0 +1,25 @@ +export enum EAuthModes { + SIGN_IN = "SIGN_IN", + SIGN_UP = "SIGN_UP", +} + +export enum EAuthSteps { + EMAIL = "EMAIL", + PASSWORD = "PASSWORD", + UNIQUE_CODE = "UNIQUE_CODE", +} + +export interface ICsrfTokenData { + csrf_token: string; +} + +// email check types starts +export interface IEmailCheckData { + email: string; +} + +export interface IEmailCheckResponse { + is_password_autoset: boolean; + existing: boolean; +} +// email check types ends diff --git a/apps/space/core/types/cycle.d.ts b/apps/space/core/types/cycle.d.ts new file mode 100644 index 00000000..edf8f31a --- /dev/null +++ b/apps/space/core/types/cycle.d.ts @@ -0,0 +1,5 @@ +export type TPublicCycle = { + id: string; + name: string; + status: string; +}; diff --git a/apps/space/core/types/intake.d.ts b/apps/space/core/types/intake.d.ts new file mode 100644 index 00000000..9cf2934f --- /dev/null +++ b/apps/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/apps/space/core/types/issue.d.ts b/apps/space/core/types/issue.d.ts new file mode 100644 index 00000000..66542317 --- /dev/null +++ b/apps/space/core/types/issue.d.ts @@ -0,0 +1,118 @@ +import type { ActorDetail, TIssue, TIssuePriorities, TStateGroups, TIssuePublicComment } from "@plane/types"; + +export type TIssueLayout = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt"; +export type TIssueLayoutOptions = { + [key in TIssueLayout]: boolean; +}; + +export type TIssueFilterPriorityObject = { + key: TIssuePriorities; + title: string; + className: string; + icon: string; +}; + +export type TIssueFilterKeys = "priority" | "state" | "labels"; + +export type TDisplayFilters = { + layout: TIssueLayout; +}; + +export type TFilters = { + state: TStateGroups[]; + priority: TIssuePriorities[]; + labels: string[]; +}; + +export type TIssueFilters = { + display_filters: TDisplayFilters; + filters: TFilters; +}; + +export type TIssueQueryFilters = Partial; + +export type TIssueQueryFiltersParams = Partial>; + +export interface IIssue + extends Pick< + TIssue, + | "description_html" + | "created_at" + | "updated_at" + | "created_by" + | "id" + | "name" + | "priority" + | "state_id" + | "project_id" + | "sequence_id" + | "sort_order" + | "start_date" + | "target_date" + | "cycle_id" + | "module_ids" + | "label_ids" + | "assignee_ids" + | "attachment_count" + | "sub_issues_count" + | "link_count" + | "estimate_point" + > { + comments: TIssuePublicComment[]; + reaction_items: IIssueReaction[]; + vote_items: IVote[]; +} + +export type IPeekMode = "side" | "modal" | "full"; + +type TIssueResponseResults = + | IIssue[] + | { + [key: string]: { + results: + | IIssue[] + | { + [key: string]: { + results: IIssue[]; + total_results: number; + }; + }; + total_results: number; + }; + }; + +export type TIssuesResponse = { + grouped_by: string; + next_cursor: string; + prev_cursor: string; + next_page_results: boolean; + prev_page_results: boolean; + total_count: number; + count: number; + total_pages: number; + extra_stats: null; + results: TIssueResponseResults; +}; + +export interface IIssueLabel { + id: string; + name: string; + color: string; + parent: string | null; +} + +export interface IVote { + vote: -1 | 1; + actor_details: ActorDetail; +} + +export interface IIssueReaction { + actor_details: ActorDetail; + reaction: string; +} + +export interface IIssueFilterOptions { + state?: string[] | null; + labels?: string[] | null; + priority?: string[] | null; +} diff --git a/apps/space/core/types/member.d.ts b/apps/space/core/types/member.d.ts new file mode 100644 index 00000000..721ccd98 --- /dev/null +++ b/apps/space/core/types/member.d.ts @@ -0,0 +1,10 @@ +export type TPublicMember = { + id: string; + member: string; + member__avatar: string; + member__first_name: string; + member__last_name: string; + member__display_name: string; + project: string; + workspace: string; +}; diff --git a/apps/space/core/types/modules.d.ts b/apps/space/core/types/modules.d.ts new file mode 100644 index 00000000..8bc35ce6 --- /dev/null +++ b/apps/space/core/types/modules.d.ts @@ -0,0 +1,4 @@ +export type TPublicModule = { + id: string; + name: string; +}; diff --git a/apps/space/ee/components/editor/index.ts b/apps/space/ee/components/editor/index.ts new file mode 100644 index 00000000..f8506c1d --- /dev/null +++ b/apps/space/ee/components/editor/index.ts @@ -0,0 +1 @@ +export * from "ce/components/editor"; diff --git a/apps/space/ee/components/issue-layouts/root.tsx b/apps/space/ee/components/issue-layouts/root.tsx new file mode 100644 index 00000000..d785c5c1 --- /dev/null +++ b/apps/space/ee/components/issue-layouts/root.tsx @@ -0,0 +1 @@ +export * from "ce/components/issue-layouts/root"; diff --git a/apps/space/ee/components/navbar/index.tsx b/apps/space/ee/components/navbar/index.tsx new file mode 100644 index 00000000..960fa250 --- /dev/null +++ b/apps/space/ee/components/navbar/index.tsx @@ -0,0 +1 @@ +export * from "ce/components/navbar"; diff --git a/apps/space/ee/hooks/store/index.ts b/apps/space/ee/hooks/store/index.ts new file mode 100644 index 00000000..6ce80b4f --- /dev/null +++ b/apps/space/ee/hooks/store/index.ts @@ -0,0 +1 @@ +export * from "ce/hooks/store"; diff --git a/apps/space/ee/store/root.store.ts b/apps/space/ee/store/root.store.ts new file mode 100644 index 00000000..c514c4c2 --- /dev/null +++ b/apps/space/ee/store/root.store.ts @@ -0,0 +1 @@ +export * from "ce/store/root.store"; diff --git a/apps/space/helpers/authentication.helper.tsx b/apps/space/helpers/authentication.helper.tsx new file mode 100644 index 00000000..8c8f09c5 --- /dev/null +++ b/apps/space/helpers/authentication.helper.tsx @@ -0,0 +1,397 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +// helpers +import { SUPPORT_EMAIL } from "./common.helper"; + +export enum EPageTypes { + INIT = "INIT", + PUBLIC = "PUBLIC", + NON_AUTHENTICATED = "NON_AUTHENTICATED", + ONBOARDING = "ONBOARDING", + AUTHENTICATED = "AUTHENTICATED", +} + +export enum EErrorAlertType { + BANNER_ALERT = "BANNER_ALERT", + TOAST_ALERT = "TOAST_ALERT", + INLINE_FIRST_NAME = "INLINE_FIRST_NAME", + INLINE_EMAIL = "INLINE_EMAIL", + INLINE_PASSWORD = "INLINE_PASSWORD", + INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", +} + +export enum EAuthenticationErrorCodes { + // Global + INSTANCE_NOT_CONFIGURED = "5000", + INVALID_EMAIL = "5005", + EMAIL_REQUIRED = "5010", + SIGNUP_DISABLED = "5015", + // Password strength + INVALID_PASSWORD = "5020", + SMTP_NOT_CONFIGURED = "5025", + // Sign Up + USER_ALREADY_EXIST = "5030", + AUTHENTICATION_FAILED_SIGN_UP = "5035", + REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040", + INVALID_EMAIL_SIGN_UP = "5045", + INVALID_EMAIL_MAGIC_SIGN_UP = "5050", + MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055", + // Sign In + USER_ACCOUNT_DEACTIVATED = "5019", + USER_DOES_NOT_EXIST = "5060", + AUTHENTICATION_FAILED_SIGN_IN = "5065", + REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070", + INVALID_EMAIL_SIGN_IN = "5075", + INVALID_EMAIL_MAGIC_SIGN_IN = "5080", + MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085", + // Both Sign in and Sign up for magic + INVALID_MAGIC_CODE_SIGN_IN = "5090", + INVALID_MAGIC_CODE_SIGN_UP = "5092", + EXPIRED_MAGIC_CODE_SIGN_IN = "5095", + EXPIRED_MAGIC_CODE_SIGN_UP = "5097", + EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN = "5100", + EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP = "5102", + // Oauth + OAUTH_NOT_CONFIGURED = "5104", + GOOGLE_NOT_CONFIGURED = "5105", + GITHUB_NOT_CONFIGURED = "5110", + GITLAB_NOT_CONFIGURED = "5111", + GOOGLE_OAUTH_PROVIDER_ERROR = "5115", + GITHUB_OAUTH_PROVIDER_ERROR = "5120", + GITLAB_OAUTH_PROVIDER_ERROR = "5121", + // Reset Password + INVALID_PASSWORD_TOKEN = "5125", + EXPIRED_PASSWORD_TOKEN = "5130", + // Change password + INCORRECT_OLD_PASSWORD = "5135", + MISSING_PASSWORD = "5138", + INVALID_NEW_PASSWORD = "5140", + // set password + PASSWORD_ALREADY_SET = "5145", + // Admin + ADMIN_ALREADY_EXIST = "5150", + REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155", + INVALID_ADMIN_EMAIL = "5160", + INVALID_ADMIN_PASSWORD = "5165", + REQUIRED_ADMIN_EMAIL_PASSWORD = "5170", + ADMIN_AUTHENTICATION_FAILED = "5175", + ADMIN_USER_ALREADY_EXIST = "5180", + ADMIN_USER_DOES_NOT_EXIST = "5185", +} + +export type TAuthErrorInfo = { + type: EErrorAlertType; + code: EAuthenticationErrorCodes; + title: string; + message: ReactNode; +}; + +const errorCodeMessages: { + [key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; +} = { + // global + [EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED]: { + title: `Instance not configured`, + message: () => `Instance not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.SIGNUP_DISABLED]: { + title: `Sign up disabled`, + message: () => `Sign up disabled. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.INVALID_PASSWORD]: { + title: `Invalid password`, + message: () => `Invalid password. Please try again.`, + }, + [EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED]: { + title: `SMTP not configured`, + message: () => `SMTP not configured. Please contact your administrator.`, + }, + + // email check in both sign up and sign in + [EAuthenticationErrorCodes.INVALID_EMAIL]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.EMAIL_REQUIRED]: { + title: `Email required`, + message: () => `Email required. Please try again.`, + }, + + // sign up + [EAuthenticationErrorCodes.USER_ALREADY_EXIST]: { + title: `User already exists`, + message: (email = undefined) => ( +
    + Your account is already registered.  + + Sign In + +  now. +
    + ), + }, + [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED]: { + title: `Email and code required`, + message: () => `Email and code required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + + // sign in + [EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: { + title: `User account deactivated`, + message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`, + }, + + [EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: { + title: `User does not exist`, + message: (email = undefined) => ( +
    + No account found.  + + Create one + +  to get started. +
    + ), + }, + [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED]: { + title: `Email and code required`, + message: () => `Email and code required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + + // Both Sign in and Sign up + [EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN]: { + title: `Authentication failed`, + message: () => `Invalid magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP]: { + title: `Authentication failed`, + message: () => `Invalid magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + + // Oauth + [EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED]: { + title: `OAuth not configured`, + message: () => `OAuth not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: { + title: `Google not configured`, + message: () => `Google not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED]: { + title: `GitHub not configured`, + message: () => `GitHub not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED]: { + title: `GitLab not configured`, + message: () => `GitLab not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: { + title: `Google OAuth provider error`, + message: () => `Google OAuth provider error. Please try again.`, + }, + [EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR]: { + title: `GitHub OAuth provider error`, + message: () => `GitHub OAuth provider error. Please try again.`, + }, + [EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR]: { + title: `GitLab OAuth provider error`, + message: () => `GitLab OAuth provider error. Please try again.`, + }, + + // Reset Password + [EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: { + title: `Invalid password token`, + message: () => `Invalid password token. Please try again.`, + }, + [EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN]: { + title: `Expired password token`, + message: () => `Expired password token. Please try again.`, + }, + + // Change password + [EAuthenticationErrorCodes.MISSING_PASSWORD]: { + title: `Password required`, + message: () => `Password required. Please try again.`, + }, + [EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD]: { + title: `Incorrect old password`, + message: () => `Incorrect old password. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_NEW_PASSWORD]: { + title: `Invalid new password`, + message: () => `Invalid new password. Please try again.`, + }, + + // set password + [EAuthenticationErrorCodes.PASSWORD_ALREADY_SET]: { + title: `Password already set`, + message: () => `Password already set. Please try again.`, + }, + + // admin + [EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: { + title: `Admin already exists`, + message: () => `Admin already exists. Please try again.`, + }, + [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { + title: `Email, password and first name required`, + message: () => `Email, password and first name required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: { + title: `Invalid admin email`, + message: () => `Invalid admin email. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: { + title: `Invalid admin password`, + message: () => `Invalid admin password. Please try again.`, + }, + [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: { + title: `Admin user already exists`, + message: () => ( +
    + Admin user already exists.  + + Sign In + +  now. +
    + ), + }, + [EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { + title: `Admin user does not exist`, + message: () => ( +
    + Admin user does not exist.  + + Sign In + +  now. +
    + ), + }, +}; + +export const authErrorHandler = ( + errorCode: EAuthenticationErrorCodes, + email?: string | undefined +): TAuthErrorInfo | undefined => { + const bannerAlertErrorCodes = [ + EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED, + EAuthenticationErrorCodes.INVALID_EMAIL, + EAuthenticationErrorCodes.EMAIL_REQUIRED, + EAuthenticationErrorCodes.SIGNUP_DISABLED, + EAuthenticationErrorCodes.INVALID_PASSWORD, + EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED, + EAuthenticationErrorCodes.USER_ALREADY_EXIST, + EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP, + EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP, + EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP, + EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED, + EAuthenticationErrorCodes.USER_DOES_NOT_EXIST, + EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN, + EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, + EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED, + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP, + EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED, + EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED, + EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED, + EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED, + EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR, + EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR, + EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR, + EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN, + EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN, + EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD, + EAuthenticationErrorCodes.INVALID_NEW_PASSWORD, + EAuthenticationErrorCodes.PASSWORD_ALREADY_SET, + EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST, + EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, + EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL, + EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD, + EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD, + EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED, + EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST, + EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED, + ]; + + if (bannerAlertErrorCodes.includes(errorCode)) + return { + type: EErrorAlertType.BANNER_ALERT, + code: errorCode, + title: errorCodeMessages[errorCode]?.title || "Error", + message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", + }; + + return undefined; +}; diff --git a/apps/space/helpers/common.helper.ts b/apps/space/helpers/common.helper.ts new file mode 100644 index 00000000..cbb90199 --- /dev/null +++ b/apps/space/helpers/common.helper.ts @@ -0,0 +1,10 @@ +import { clsx } from "clsx"; +import type { ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || ""; + +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); + +export const resolveGeneralTheme = (resolvedTheme: string | undefined) => + resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system"; diff --git a/apps/space/helpers/date-time.helper.ts b/apps/space/helpers/date-time.helper.ts new file mode 100644 index 00000000..f0bb6489 --- /dev/null +++ b/apps/space/helpers/date-time.helper.ts @@ -0,0 +1,59 @@ +import { format, isValid } from "date-fns"; +import { isNumber } from "lodash-es"; + +export const timeAgo = (time: any) => { + switch (typeof time) { + case "number": + break; + case "string": + time = +new Date(time); + break; + case "object": + if (time.constructor === Date) time = time.getTime(); + break; + default: + time = +new Date(); + } +}; + +/** + * This method returns a date from string of type yyyy-mm-dd + * This method is recommended to use instead of new Date() as this does not introduce any timezone offsets + * @param date + * @returns date or undefined + */ +export const getDate = (date: string | Date | undefined | null): Date | undefined => { + try { + if (!date || date === "") return; + + if (typeof date !== "string" && !(date instanceof String)) return date; + + const [yearString, monthString, dayString] = date.substring(0, 10).split("-"); + const year = parseInt(yearString); + const month = parseInt(monthString); + const day = parseInt(dayString); + if (!isNumber(year) || !isNumber(month) || !isNumber(day)) return; + + return new Date(year, month - 1, day); + } catch (_err) { + return undefined; + } +}; + +/** + * @returns {string | null} formatted date in the format of MMM dd, yyyy + * @description Returns date in the formatted format + * @param {Date | string} date + * @example renderFormattedDate("2024-01-01") // Jan 01, 2024 + */ +export const renderFormattedDate = (date: string | Date | undefined | null): string | null => { + // Parse the date to check if it is valid + const parsedDate = getDate(date); + // return if undefined + if (!parsedDate) return null; + // Check if the parsed date is valid before formatting + if (!isValid(parsedDate)) return null; // Return null for invalid dates + // Format the date in format (MMM dd, yyyy) + const formattedDate = format(parsedDate, "MMM dd, yyyy"); + return formattedDate; +}; diff --git a/apps/space/helpers/editor.helper.ts b/apps/space/helpers/editor.helper.ts new file mode 100644 index 00000000..43b265af --- /dev/null +++ b/apps/space/helpers/editor.helper.ts @@ -0,0 +1,65 @@ +// plane imports +import { MAX_FILE_SIZE } from "@plane/constants"; +import type { TFileHandler } from "@plane/editor"; +import { SitesFileService } from "@plane/services"; +import { getFileURL } from "@plane/utils"; +// services +const sitesFileService = new SitesFileService(); + +/** + * @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: TFileHandler["upload"]; + 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; + + const getAssetSrc = async (path: string) => { + if (!path) return ""; + if (path?.startsWith("http")) { + return path; + } else { + return getEditorAssetSrc(anchor, path) ?? ""; + } + }; + + return { + checkIfAssetExists: async () => true, + assetsUploadStatus: {}, + getAssetDownloadSrc: getAssetSrc, + getAssetSrc: getAssetSrc, + upload: uploadFile, + delete: async (src: string) => { + if (src?.startsWith("http")) { + await sitesFileService.deleteOldEditorAsset(workspaceId, src); + } else { + await sitesFileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? ""); + } + }, + cancel: sitesFileService.cancelUpload, + restore: async (src: string) => { + if (src?.startsWith("http")) { + await sitesFileService.restoreOldEditorAsset(workspaceId, src); + } else { + await sitesFileService.restoreNewAsset(anchor, src); + } + }, + validation: { + maxFileSize: MAX_FILE_SIZE, + }, + }; +}; diff --git a/apps/space/helpers/emoji.helper.tsx b/apps/space/helpers/emoji.helper.tsx new file mode 100644 index 00000000..1619d6c0 --- /dev/null +++ b/apps/space/helpers/emoji.helper.tsx @@ -0,0 +1,33 @@ +export const renderEmoji = ( + emoji: + | string + | { + name: string; + color: string; + } +) => { + if (!emoji) return; + + if (typeof emoji === "object") + return ( + + {emoji.name} + + ); + else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji)); +}; + +export const groupReactions = (reactions: T[], key: string) => { + const groupedReactions = reactions.reduce( + (acc: { [key: string]: T[] }, reaction: any) => { + if (!acc[reaction[key]]) { + acc[reaction[key]] = []; + } + acc[reaction[key]].push(reaction); + return acc; + }, + {} as { [key: string]: T[] } + ); + + return groupedReactions; +}; diff --git a/apps/space/helpers/file.helper.ts b/apps/space/helpers/file.helper.ts new file mode 100644 index 00000000..0d396bbd --- /dev/null +++ b/apps/space/helpers/file.helper.ts @@ -0,0 +1,25 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; + +/** + * @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 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/apps/space/helpers/issue.helper.ts b/apps/space/helpers/issue.helper.ts new file mode 100644 index 00000000..7971b4a5 --- /dev/null +++ b/apps/space/helpers/issue.helper.ts @@ -0,0 +1,29 @@ +import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays"; +// plane internal +import { STATE_GROUPS } from "@plane/constants"; +import type { TStateGroups } from "@plane/types"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; + +/** + * @description check if the issue due date should be highlighted + * @param date + * @param stateGroup + * @returns boolean + */ +export const shouldHighlightIssueDueDate = ( + date: string | Date | null, + stateGroup: TStateGroups | undefined +): boolean => { + if (!date || !stateGroup) return false; + // if the issue is completed or cancelled, don't highlight the due date + if ([STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateGroup)) return false; + + const parsedDate = getDate(date); + if (!parsedDate) return false; + + const targetDateDistance = differenceInCalendarDays(parsedDate, new Date()); + + // if the issue is overdue, highlight the due date + return targetDateDistance <= 0; +}; diff --git a/apps/space/helpers/query-param-generator.ts b/apps/space/helpers/query-param-generator.ts new file mode 100644 index 00000000..888462f1 --- /dev/null +++ b/apps/space/helpers/query-param-generator.ts @@ -0,0 +1,24 @@ +type TQueryParamValue = string | string[] | boolean | number | bigint | undefined | null; + +export const queryParamGenerator = (queryObject: Record) => { + const queryParamObject: Record = {}; + const queryParam = new URLSearchParams(); + + Object.entries(queryObject).forEach(([key, value]) => { + if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") { + queryParamObject[key] = value; + queryParam.append(key, value.toString()); + } else if (typeof value === "string" && value.length > 0) { + queryParamObject[key] = value.split(","); + queryParam.append(key, value); + } else if (Array.isArray(value) && value.length > 0) { + queryParamObject[key] = value; + queryParam.append(key, value.toString()); + } + }); + + return { + query: queryParamObject, + queryParam: queryParam.toString(), + }; +}; diff --git a/apps/space/helpers/state.helper.ts b/apps/space/helpers/state.helper.ts new file mode 100644 index 00000000..f5a8a88e --- /dev/null +++ b/apps/space/helpers/state.helper.ts @@ -0,0 +1,13 @@ +import { STATE_GROUPS } from "@plane/constants"; +import type { IState } from "@plane/types"; + +export const sortStates = (states: IState[]) => { + if (!states || states.length === 0) return; + + return states.sort((stateA, stateB) => { + if (stateA.group === stateB.group) { + return stateA.sequence - stateB.sequence; + } + return Object.keys(STATE_GROUPS).indexOf(stateA.group) - Object.keys(STATE_GROUPS).indexOf(stateB.group); + }); +}; diff --git a/apps/space/helpers/string.helper.ts b/apps/space/helpers/string.helper.ts new file mode 100644 index 00000000..3eec9b9a --- /dev/null +++ b/apps/space/helpers/string.helper.ts @@ -0,0 +1,75 @@ +export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2"); + +const fallbackCopyTextToClipboard = (text: string) => { + const textArea = document.createElement("textarea"); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = "0"; + textArea.style.left = "0"; + textArea.style.position = "fixed"; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this. + // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand + document.execCommand("copy"); + } catch (_err) {} + + document.body.removeChild(textArea); +}; + +export const copyTextToClipboard = async (text: string) => { + if (!navigator.clipboard) { + fallbackCopyTextToClipboard(text); + return; + } + await navigator.clipboard.writeText(text); +}; + +/** + * @returns {boolean} true if email is valid, false otherwise + * @description Returns true if email is valid, false otherwise + * @param {string} email string to check if it is a valid email + * @example checkEmailIsValid("hello world") => false + * @example checkEmailIsValid("example@plane.so") => true + */ +export const checkEmailValidity = (email: string): boolean => { + if (!email) return false; + + const isEmailValid = + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + email + ); + + return isEmailValid; +}; + +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/apps/space/next.config.js b/apps/space/next.config.js new file mode 100644 index 00000000..a736f4f6 --- /dev/null +++ b/apps/space/next.config.js @@ -0,0 +1,43 @@ +/** @type {import('next').NextConfig} */ + +const nextConfig = { + trailingSlash: true, + output: "standalone", + basePath: process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "", + reactStrictMode: false, + swcMinify: true, + async headers() { + return [ + { + source: "/", + headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }], // clickjacking protection + }, + ]; + }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "**", + }, + ], + unoptimized: true, + }, + experimental: { + optimizePackageImports: [ + "@plane/constants", + "@plane/editor", + "@plane/hooks", + "@plane/i18n", + "@plane/logger", + "@plane/propel", + "@plane/services", + "@plane/shared-state", + "@plane/types", + "@plane/ui", + "@plane/utils", + ], + }, +}; + +module.exports = nextConfig; diff --git a/apps/space/package.json b/apps/space/package.json new file mode 100644 index 00000000..f582fb07 --- /dev/null +++ b/apps/space/package.json @@ -0,0 +1,65 @@ +{ + "name": "space", + "version": "1.1.0", + "private": true, + "license": "AGPL-3.0", + "scripts": { + "dev": "next dev -p 3002", + "build": "next build", + "start": "next start", + "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", + "check:lint": "eslint . --max-warnings 28", + "check:types": "tsc --noEmit", + "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", + "fix:lint": "eslint . --fix", + "fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"" + }, + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@headlessui/react": "^1.7.13", + "@mui/material": "^5.14.1", + "@plane/constants": "workspace:*", + "@plane/editor": "workspace:*", + "@plane/i18n": "workspace:*", + "@plane/propel": "workspace:*", + "@plane/services": "workspace:*", + "@plane/types": "workspace:*", + "@plane/ui": "workspace:*", + "@plane/utils": "workspace:*", + "@popperjs/core": "^2.11.8", + "axios": "catalog:", + "clsx": "^2.0.0", + "date-fns": "^4.1.0", + "dotenv": "^16.3.1", + "lodash-es": "catalog:", + "lowlight": "^2.9.0", + "lucide-react": "catalog:", + "mobx": "catalog:", + "mobx-react": "catalog:", + "mobx-utils": "catalog:", + "next": "catalog:", + "next-themes": "^0.2.1", + "nprogress": "^0.2.0", + "react": "catalog:", + "react-dom": "catalog:", + "react-dropzone": "^14.2.3", + "react-hook-form": "7.51.5", + "react-popper": "^2.3.0", + "sharp": "catalog:", + "swr": "catalog:", + "tailwind-merge": "^2.0.0", + "uuid": "catalog:" + }, + "devDependencies": { + "@plane/eslint-config": "workspace:*", + "@plane/tailwind-config": "workspace:*", + "@plane/typescript-config": "workspace:*", + "@types/lodash-es": "catalog:", + "@types/node": "catalog:", + "@types/nprogress": "^0.2.0", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "typescript": "catalog:" + } +} diff --git a/apps/space/postcss.config.js b/apps/space/postcss.config.js new file mode 100644 index 00000000..9b1e55fc --- /dev/null +++ b/apps/space/postcss.config.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = require("@plane/tailwind-config/postcss.config.js"); diff --git a/apps/space/public/404.svg b/apps/space/public/404.svg new file mode 100644 index 00000000..4c298417 --- /dev/null +++ b/apps/space/public/404.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/space/public/auth/background-pattern-dark.svg b/apps/space/public/auth/background-pattern-dark.svg new file mode 100644 index 00000000..c258cbab --- /dev/null +++ b/apps/space/public/auth/background-pattern-dark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/space/public/auth/background-pattern.svg b/apps/space/public/auth/background-pattern.svg new file mode 100644 index 00000000..5fcbeec2 --- /dev/null +++ b/apps/space/public/auth/background-pattern.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/space/public/favicon/android-chrome-192x192.png b/apps/space/public/favicon/android-chrome-192x192.png new file mode 100644 index 00000000..4a005e54 Binary files /dev/null and b/apps/space/public/favicon/android-chrome-192x192.png differ diff --git a/apps/space/public/favicon/android-chrome-512x512.png b/apps/space/public/favicon/android-chrome-512x512.png new file mode 100644 index 00000000..27fafe82 Binary files /dev/null and b/apps/space/public/favicon/android-chrome-512x512.png differ diff --git a/apps/space/public/favicon/apple-touch-icon.png b/apps/space/public/favicon/apple-touch-icon.png new file mode 100644 index 00000000..a6312678 Binary files /dev/null and b/apps/space/public/favicon/apple-touch-icon.png differ diff --git a/apps/space/public/favicon/favicon-16x16.png b/apps/space/public/favicon/favicon-16x16.png new file mode 100644 index 00000000..af59ef01 Binary files /dev/null and b/apps/space/public/favicon/favicon-16x16.png differ diff --git a/apps/space/public/favicon/favicon-32x32.png b/apps/space/public/favicon/favicon-32x32.png new file mode 100644 index 00000000..16a1271a Binary files /dev/null and b/apps/space/public/favicon/favicon-32x32.png differ diff --git a/apps/space/public/favicon/favicon.ico b/apps/space/public/favicon/favicon.ico new file mode 100644 index 00000000..613b1a31 Binary files /dev/null and b/apps/space/public/favicon/favicon.ico differ diff --git a/apps/space/public/favicon/site.webmanifest b/apps/space/public/favicon/site.webmanifest new file mode 100644 index 00000000..1d410578 --- /dev/null +++ b/apps/space/public/favicon/site.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/apps/space/public/images/logo-spinner-dark.gif b/apps/space/public/images/logo-spinner-dark.gif new file mode 100644 index 00000000..8bd08325 Binary files /dev/null and b/apps/space/public/images/logo-spinner-dark.gif differ diff --git a/apps/space/public/images/logo-spinner-light.gif b/apps/space/public/images/logo-spinner-light.gif new file mode 100644 index 00000000..8b571031 Binary files /dev/null and b/apps/space/public/images/logo-spinner-light.gif differ diff --git a/apps/space/public/instance/instance-failure-dark.svg b/apps/space/public/instance/instance-failure-dark.svg new file mode 100644 index 00000000..58d69170 --- /dev/null +++ b/apps/space/public/instance/instance-failure-dark.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/space/public/instance/instance-failure.svg b/apps/space/public/instance/instance-failure.svg new file mode 100644 index 00000000..a5986228 --- /dev/null +++ b/apps/space/public/instance/instance-failure.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/space/public/instance/intake-sent-dark.png b/apps/space/public/instance/intake-sent-dark.png new file mode 100644 index 00000000..70a62730 Binary files /dev/null and b/apps/space/public/instance/intake-sent-dark.png differ diff --git a/apps/space/public/instance/intake-sent-light.png b/apps/space/public/instance/intake-sent-light.png new file mode 100644 index 00000000..9425c072 Binary files /dev/null and b/apps/space/public/instance/intake-sent-light.png differ diff --git a/apps/space/public/instance/plane-instance-not-ready.webp b/apps/space/public/instance/plane-instance-not-ready.webp new file mode 100644 index 00000000..a0efca52 Binary files /dev/null and b/apps/space/public/instance/plane-instance-not-ready.webp differ diff --git a/apps/space/public/instance/plane-takeoff.png b/apps/space/public/instance/plane-takeoff.png new file mode 100644 index 00000000..417ff829 Binary files /dev/null and b/apps/space/public/instance/plane-takeoff.png differ diff --git a/apps/space/public/logos/github-black.png b/apps/space/public/logos/github-black.png new file mode 100644 index 00000000..7a7a8247 Binary files /dev/null and b/apps/space/public/logos/github-black.png differ diff --git a/apps/space/public/logos/github-dark.svg b/apps/space/public/logos/github-dark.svg new file mode 100644 index 00000000..a0cb35c3 --- /dev/null +++ b/apps/space/public/logos/github-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/space/public/logos/github-square.svg b/apps/space/public/logos/github-square.svg new file mode 100644 index 00000000..a7836db8 --- /dev/null +++ b/apps/space/public/logos/github-square.svg @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/apps/space/public/logos/github-white.svg b/apps/space/public/logos/github-white.svg new file mode 100644 index 00000000..90fe34d8 --- /dev/null +++ b/apps/space/public/logos/github-white.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/apps/space/public/logos/gitlab-logo.svg b/apps/space/public/logos/gitlab-logo.svg new file mode 100644 index 00000000..dab4d8b7 --- /dev/null +++ b/apps/space/public/logos/gitlab-logo.svg @@ -0,0 +1 @@ + diff --git a/apps/space/public/logos/google-logo.svg b/apps/space/public/logos/google-logo.svg new file mode 100644 index 00000000..088288fa --- /dev/null +++ b/apps/space/public/logos/google-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/space/public/plane-logo.svg b/apps/space/public/plane-logo.svg new file mode 100644 index 00000000..11179417 --- /dev/null +++ b/apps/space/public/plane-logo.svg @@ -0,0 +1,94 @@ + + + + diff --git a/apps/space/public/plane-logos/black-horizontal-with-blue-logo.png b/apps/space/public/plane-logos/black-horizontal-with-blue-logo.png new file mode 100644 index 00000000..c14505a6 Binary files /dev/null and b/apps/space/public/plane-logos/black-horizontal-with-blue-logo.png differ diff --git a/apps/space/public/plane-logos/blue-without-text-new.png b/apps/space/public/plane-logos/blue-without-text-new.png new file mode 100644 index 00000000..ea94aec7 Binary files /dev/null and b/apps/space/public/plane-logos/blue-without-text-new.png differ diff --git a/apps/space/public/plane-logos/blue-without-text.png b/apps/space/public/plane-logos/blue-without-text.png new file mode 100644 index 00000000..ea94aec7 Binary files /dev/null and b/apps/space/public/plane-logos/blue-without-text.png differ diff --git a/apps/space/public/plane-logos/white-horizontal-with-blue-logo.png b/apps/space/public/plane-logos/white-horizontal-with-blue-logo.png new file mode 100644 index 00000000..97560fb9 Binary files /dev/null and b/apps/space/public/plane-logos/white-horizontal-with-blue-logo.png differ diff --git a/apps/space/public/plane-logos/white-horizontal.svg b/apps/space/public/plane-logos/white-horizontal.svg new file mode 100644 index 00000000..13e2dbb9 --- /dev/null +++ b/apps/space/public/plane-logos/white-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/space/public/project-not-published.svg b/apps/space/public/project-not-published.svg new file mode 100644 index 00000000..db4b404d --- /dev/null +++ b/apps/space/public/project-not-published.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/space/public/robots.txt b/apps/space/public/robots.txt new file mode 100644 index 00000000..77470cb3 --- /dev/null +++ b/apps/space/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/apps/space/public/site.webmanifest.json b/apps/space/public/site.webmanifest.json new file mode 100644 index 00000000..8885d137 --- /dev/null +++ b/apps/space/public/site.webmanifest.json @@ -0,0 +1,13 @@ +{ + "name": "Plane Space", + "short_name": "Plane Space", + "description": "Plane helps you plan your work items, cycles, and product modules.", + "start_url": ".", + "display": "standalone", + "background_color": "#f9fafb", + "theme_color": "#3f76ff", + "icons": [ + { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/apps/space/public/something-went-wrong.svg b/apps/space/public/something-went-wrong.svg new file mode 100644 index 00000000..bd51f7f4 --- /dev/null +++ b/apps/space/public/something-went-wrong.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/space/public/user-logged-in.svg b/apps/space/public/user-logged-in.svg new file mode 100644 index 00000000..e20b49e8 --- /dev/null +++ b/apps/space/public/user-logged-in.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/space/styles/globals.css b/apps/space/styles/globals.css new file mode 100644 index 00000000..5f2e91ed --- /dev/null +++ b/apps/space/styles/globals.css @@ -0,0 +1,497 @@ +@import "@plane/propel/styles/fonts"; +@import "@plane/editor/styles"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + font-family: "Inter", sans-serif; + } + + :root { + color-scheme: light !important; + + --color-primary-10: 229, 243, 250; + --color-primary-20: 216, 237, 248; + --color-primary-30: 199, 229, 244; + --color-primary-40: 169, 214, 239; + --color-primary-50: 144, 202, 234; + --color-primary-60: 109, 186, 227; + --color-primary-70: 75, 170, 221; + --color-primary-80: 41, 154, 214; + --color-primary-90: 34, 129, 180; + --color-primary-100: 0, 99, 153; + --color-primary-200: 0, 92, 143; + --color-primary-300: 0, 86, 133; + --color-primary-400: 0, 77, 117; + --color-primary-500: 0, 66, 102; + --color-primary-600: 0, 53, 82; + --color-primary-700: 0, 43, 66; + --color-primary-800: 0, 33, 51; + --color-primary-900: 0, 23, 36; + + --color-background-100: 255, 255, 255; /* primary bg */ + --color-background-90: 247, 247, 247; /* secondary bg */ + --color-background-80: 232, 232, 232; /* tertiary bg */ + + --color-text-100: 23, 23, 23; /* primary text */ + --color-text-200: 58, 58, 58; /* secondary text */ + --color-text-300: 82, 82, 82; /* tertiary text */ + --color-text-400: 163, 163, 163; /* placeholder text */ + + --color-scrollbar: 163, 163, 163; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + + --color-shadow-2xs: + 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06), + 0px 1px 2px 0px rgba(23, 23, 23, 0.14); + --color-shadow-xs: + 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12), + 0px 1px 8px -1px rgba(16, 24, 40, 0.1); + --color-shadow-sm: + 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12); + --color-shadow-rg: + 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08), + 0px 1px 12px 0px rgba(16, 24, 40, 0.04); + --color-shadow-md: + 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), + 0px 1px 16px 0px rgba(16, 24, 40, 0.12); + --color-shadow-lg: + 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12), + 0px 1px 24px 0px rgba(16, 24, 40, 0.12); + --color-shadow-xl: + 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16), + 0px 0px 52px 0px rgba(16, 24, 40, 0.16); + --color-shadow-2xl: + 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12), + 0px 1px 32px 0px rgba(16, 24, 40, 0.12); + --color-shadow-3xl: + 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), + 0px 1px 48px 0px rgba(16, 24, 40, 0.12); + --color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05); + + --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ + --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ + --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */ + + --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */ + --color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */ + --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */ + --color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */ + + --color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */ + --color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */ + --color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */ + --color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */ + + --color-sidebar-shadow-2xs: var(--color-shadow-2xs); + --color-sidebar-shadow-xs: var(--color-shadow-xs); + --color-sidebar-shadow-sm: var(--color-shadow-sm); + --color-sidebar-shadow-rg: var(--color-shadow-rg); + --color-sidebar-shadow-md: var(--color-shadow-md); + --color-sidebar-shadow-lg: var(--color-shadow-lg); + --color-sidebar-shadow-xl: var(--color-shadow-xl); + --color-sidebar-shadow-2xl: var(--color-shadow-2xl); + --color-sidebar-shadow-3xl: var(--color-shadow-3xl); + --color-sidebar-shadow-4xl: var(--color-shadow-4xl); + + /* toast theme */ + --color-toast-success-text: 178, 221, 181; + --color-toast-error-text: 206, 44, 49; + --color-toast-warning-text: 255, 186, 24; + --color-toast-info-text: 141, 164, 239; + --color-toast-loading-text: 255, 255, 255; + --color-toast-secondary-text: 185, 187, 198; + --color-toast-tertiary-text: 139, 141, 152; + + --color-toast-success-background: 46, 46, 46; + --color-toast-error-background: 46, 46, 46; + --color-toast-warning-background: 46, 46, 46; + --color-toast-info-background: 46, 46, 46; + --color-toast-loading-background: 46, 46, 46; + + --color-toast-success-border: 42, 126, 59; + --color-toast-error-border: 100, 23, 35; + --color-toast-warning-border: 79, 52, 34; + --color-toast-info-border: 58, 91, 199; + --color-toast-loading-border: 96, 100, 108; + } + + [data-theme="light"], + [data-theme="light-contrast"] { + color-scheme: light !important; + + --color-background-100: 255, 255, 255; /* primary bg */ + --color-background-90: 247, 247, 247; /* secondary bg */ + --color-background-80: 232, 232, 232; /* tertiary bg */ + } + + [data-theme="light"] { + --color-text-100: 23, 23, 23; /* primary text */ + --color-text-200: 58, 58, 58; /* secondary text */ + --color-text-300: 82, 82, 82; /* tertiary text */ + --color-text-400: 163, 163, 163; /* placeholder text */ + + --color-scrollbar: 163, 163, 163; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + + /* onboarding colors */ + --gradient-onboarding-100: linear-gradient(106deg, #f2f6ff 29.8%, #e1eaff 99.34%); + --gradient-onboarding-200: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%); + --gradient-onboarding-300: linear-gradient(164deg, #fff 4.25%, rgba(255, 255, 255, 0.06) 93.5%); + --gradient-onboarding-400: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%); + + --color-onboarding-text-100: 23, 23, 23; + --color-onboarding-text-200: 58, 58, 58; + --color-onboarding-text-300: 82, 82, 82; + --color-onboarding-text-400: 163, 163, 163; + + --color-onboarding-background-100: 236, 241, 255; + --color-onboarding-background-200: 255, 255, 255; + --color-onboarding-background-300: 236, 241, 255; + --color-onboarding-background-400: 177, 206, 250; + + --color-onboarding-border-100: 229, 229, 229; + --color-onboarding-border-200: 217, 228, 255; + --color-onboarding-border-300: 229, 229, 229, 0.5; + + --color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(126, 139, 171, 0.1); + + /* toast theme */ + --color-toast-success-text: 62, 155, 79; + --color-toast-error-text: 220, 62, 66; + --color-toast-warning-text: 255, 186, 24; + --color-toast-info-text: 51, 88, 212; + --color-toast-loading-text: 28, 32, 36; + --color-toast-secondary-text: 128, 131, 141; + --color-toast-tertiary-text: 96, 100, 108; + + --color-toast-success-background: 253, 253, 254; + --color-toast-error-background: 255, 252, 252; + --color-toast-warning-background: 254, 253, 251; + --color-toast-info-background: 253, 253, 254; + --color-toast-loading-background: 253, 253, 254; + + --color-toast-success-border: 218, 241, 219; + --color-toast-error-border: 255, 219, 220; + --color-toast-warning-border: 255, 247, 194; + --color-toast-info-border: 210, 222, 255; + --color-toast-loading-border: 224, 225, 230; + } + + [data-theme="light-contrast"] { + --color-text-100: 11, 11, 11; /* primary text */ + --color-text-200: 38, 38, 38; /* secondary text */ + --color-text-300: 58, 58, 58; /* tertiary text */ + --color-text-400: 115, 115, 115; /* placeholder text */ + + --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ + + --color-border-100: 34, 34, 34; /* subtle border= 1 */ + --color-border-200: 38, 38, 38; /* subtle border- 2 */ + --color-border-300: 46, 46, 46; /* strong border- 1 */ + --color-border-400: 58, 58, 58; /* strong border- 2 */ + } + + [data-theme="dark"], + [data-theme="dark-contrast"] { + color-scheme: dark !important; + + --color-primary-10: 8, 31, 43; + --color-primary-20: 10, 37, 51; + --color-primary-30: 13, 49, 69; + --color-primary-40: 16, 58, 81; + --color-primary-50: 18, 68, 94; + --color-primary-60: 23, 86, 120; + --color-primary-70: 28, 104, 146; + --color-primary-80: 31, 116, 163; + --color-primary-90: 34, 129, 180; + --color-primary-100: 40, 146, 204; + --color-primary-200: 41, 154, 214; + --color-primary-300: 75, 170, 221; + --color-primary-400: 109, 186, 227; + --color-primary-500: 144, 202, 234; + --color-primary-600: 169, 214, 239; + --color-primary-700: 199, 229, 244; + --color-primary-800: 216, 237, 248; + --color-primary-900: 229, 243, 250; + + --color-background-100: 25, 25, 25; /* primary bg */ + --color-background-90: 32, 32, 32; /* secondary bg */ + --color-background-80: 44, 44, 44; /* tertiary bg */ + + --color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.5); + --color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), 0px 2px 4px 0px rgba(0, 0, 0, 0.5); + --color-shadow-sm: 0px 0px 4px 0px rgba(0, 0, 0, 0.2), 0px 2px 6px 0px rgba(0, 0, 0, 0.5); + --color-shadow-rg: 0px 0px 6px 0px rgba(0, 0, 0, 0.2), 0px 4px 6px 0px rgba(0, 0, 0, 0.5); + --color-shadow-md: 0px 2px 8px 0px rgba(0, 0, 0, 0.2), 0px 4px 8px 0px rgba(0, 0, 0, 0.5); + --color-shadow-lg: 0px 4px 12px 0px rgba(0, 0, 0, 0.25), 0px 4px 10px 0px rgba(0, 0, 0, 0.55); + --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); + } + + [data-theme="dark"] { + --color-text-100: 229, 229, 229; /* primary text */ + --color-text-200: 163, 163, 163; /* secondary text */ + --color-text-300: 115, 115, 115; /* tertiary text */ + --color-text-400: 82, 82, 82; /* placeholder text */ + + --color-scrollbar: 82, 82, 82; /* scrollbar thumb */ + + --color-border-100: 34, 34, 34; /* subtle border= 1 */ + --color-border-200: 38, 38, 38; /* subtle border- 2 */ + --color-border-300: 46, 46, 46; /* strong border- 1 */ + --color-border-400: 58, 58, 58; /* strong border- 2 */ + + /* onboarding colors */ + --gradient-onboarding-100: linear-gradient(106deg, #18191b 25.17%, #18191b 99.34%); + --gradient-onboarding-200: linear-gradient(129deg, rgba(47, 49, 53, 0.8) -22.23%, rgba(33, 34, 37, 0.8) 62.98%); + --gradient-onboarding-300: linear-gradient(167deg, rgba(47, 49, 53, 0.45) 19.22%, #212225 98.48%); + + --color-onboarding-text-100: 237, 238, 240; + --color-onboarding-text-200: 176, 180, 187; + --color-onboarding-text-300: 118, 123, 132; + --color-onboarding-text-400: 105, 110, 119; + + --color-onboarding-background-100: 54, 58, 64; + --color-onboarding-background-200: 40, 42, 45; + --color-onboarding-background-300: 40, 42, 45; + --color-onboarding-background-400: 67, 72, 79; + + --color-onboarding-border-100: 54, 58, 64; + --color-onboarding-border-200: 54, 58, 64; + --color-onboarding-border-300: 34, 35, 38, 0.5; + + --color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(39, 44, 56, 0.1); + } + + [data-theme="dark-contrast"] { + --color-text-100: 250, 250, 250; /* primary text */ + --color-text-200: 241, 241, 241; /* secondary text */ + --color-text-300: 212, 212, 212; /* tertiary text */ + --color-text-400: 115, 115, 115; /* placeholder text */ + + --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + } + + [data-theme="light"], + [data-theme="dark"], + [data-theme="light-contrast"], + [data-theme="dark-contrast"] { + --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ + --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ + --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */ + + --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */ + --color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */ + --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */ + --color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */ + + --color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */ + --color-sidebar-border-200: var(--color-border-200); /* subtle sidebar border- 2 */ + --color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */ + --color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */ + } + + /* stickies and editor colors */ + :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 */ + + /* background colors */ + --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; + /* end background colors */ + } + /* background colors */ + [data-theme*="light"] { + --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"] { + --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 background colors */ +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + font-variant-ligatures: none; + -webkit-font-variant-ligatures: none; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; +} + +body { + color: rgba(var(--color-text-100)); +} + +::-webkit-scrollbar { + width: 5px; + height: 5px; + border-radius: 5px; +} + +::-webkit-scrollbar-track { + background-color: rgba(var(--color-background-100)); +} + +::-webkit-scrollbar-thumb { + border-radius: 5px; + background-color: rgba(var(--color-background-80)); +} + +.hide-vertical-scrollbar::-webkit-scrollbar { + width: 0 !important; +} + +.hide-horizontal-scrollbar::-webkit-scrollbar { + height: 0 !important; +} + +.hide-both-scrollbars::-webkit-scrollbar { + height: 0 !important; + width: 0 !important; +} + +/* By applying below class, the autofilled text in form fields will not have the default autofill background color and styles applied by WebKit browsers */ +.disable-autofill-style:-webkit-autofill, +.disable-autofill-style:-webkit-autofill:hover, +.disable-autofill-style:-webkit-autofill:focus, +.disable-autofill-style:-webkit-autofill:active { + -webkit-background-clip: text; +} + +@-moz-document url-prefix() { + * { + scrollbar-width: none; + } + .vertical-scrollbar, + .horizontal-scrollbar { + scrollbar-width: initial; + scrollbar-color: rgba(96, 100, 108, 0.1) transparent; + } + .vertical-scrollbar:hover, + .horizontal-scrollbar:hover { + scrollbar-color: rgba(96, 100, 108, 0.25) transparent; + } + .vertical-scrollbar:active, + .horizontal-scrollbar:active { + scrollbar-color: rgba(96, 100, 108, 0.7) transparent; + } +} + +.vertical-scrollbar { + overflow-y: auto; +} +.horizontal-scrollbar { + overflow-x: auto; +} +.vertical-scrollbar::-webkit-scrollbar, +.horizontal-scrollbar::-webkit-scrollbar { + display: block; +} +.vertical-scrollbar::-webkit-scrollbar-track, +.horizontal-scrollbar::-webkit-scrollbar-track { + background-color: transparent; + border-radius: 9999px; +} +.vertical-scrollbar::-webkit-scrollbar-thumb, +.horizontal-scrollbar::-webkit-scrollbar-thumb { + background-clip: padding-box; + background-color: rgba(96, 100, 108, 0.1); + border-radius: 9999px; +} +.vertical-scrollbar:hover::-webkit-scrollbar-thumb, +.horizontal-scrollbar:hover::-webkit-scrollbar-thumb { + background-color: rgba(96, 100, 108, 0.25); +} +.vertical-scrollbar::-webkit-scrollbar-thumb:hover, +.horizontal-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(96, 100, 108, 0.5); +} +.vertical-scrollbar::-webkit-scrollbar-thumb:active, +.horizontal-scrollbar::-webkit-scrollbar-thumb:active { + background-color: rgba(96, 100, 108, 0.7); +} +.vertical-scrollbar::-webkit-scrollbar-corner, +.horizontal-scrollbar::-webkit-scrollbar-corner { + background-color: transparent; +} +.vertical-scrollbar-margin-top-md::-webkit-scrollbar-track { + margin-top: 44px; +} + +/* scrollbar sm size */ +.scrollbar-sm::-webkit-scrollbar { + height: 12px; + width: 12px; +} +.scrollbar-sm::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); +} +/* scrollbar md size */ +.scrollbar-md::-webkit-scrollbar { + height: 14px; + width: 14px; +} +.scrollbar-md::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); +} +/* scrollbar lg size */ + +.scrollbar-lg::-webkit-scrollbar { + height: 16px; + width: 16px; +} +.scrollbar-lg::-webkit-scrollbar-thumb { + border: 4px solid rgba(0, 0, 0, 0); +} diff --git a/apps/space/tailwind.config.js b/apps/space/tailwind.config.js new file mode 100644 index 00000000..2232bb29 --- /dev/null +++ b/apps/space/tailwind.config.js @@ -0,0 +1,2 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +module.exports = require("@plane/tailwind-config/tailwind.config.js"); diff --git a/apps/space/tsconfig.json b/apps/space/tsconfig.json new file mode 100644 index 00000000..a4f7c1da --- /dev/null +++ b/apps/space/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "@plane/typescript-config/nextjs.json", + "plugins": [ + { + "name": "next" + } + ], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "additional.d.ts", ".next/types/**/*.ts"], + "exclude": ["node_modules"], + "compilerOptions": { + "baseUrl": ".", + "jsx": "preserve", + "paths": { + "@/*": ["core/*"], + "@/helpers/*": ["helpers/*"], + "@/public/*": ["public/*"], + "@/styles/*": ["styles/*"], + "@/plane-web/*": ["ce/*"] + }, + "plugins": [ + { + "name": "next" + } + ], + + "strictNullChecks": true + } +} diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 00000000..15d7a36a --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,12 @@ +NEXT_PUBLIC_API_BASE_URL="http://localhost:8000" + +NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000" + +NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001" +NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" + +NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002" +NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" + +NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100" +NEXT_PUBLIC_LIVE_BASE_PATH="/live" diff --git a/apps/web/.eslintignore b/apps/web/.eslintignore new file mode 100644 index 00000000..e29e17a0 --- /dev/null +++ b/apps/web/.eslintignore @@ -0,0 +1,13 @@ +.next/* +out/* +public/* +core/local-db/worker/wa-sqlite/src/* +dist/* +node_modules/* +.turbo/* +.env* +.env +.env.local +.env.development +.env.production +.env.test \ No newline at end of file diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js new file mode 100644 index 00000000..a0bc76d5 --- /dev/null +++ b/apps/web/.eslintrc.js @@ -0,0 +1,18 @@ +module.exports = { + root: true, + extends: ["@plane/eslint-config/next.js"], + rules: { + "no-duplicate-imports": "off", + "import/no-duplicates": ["error", { "prefer-inline": false }], + "import/consistent-type-specifier-style": ["error", "prefer-top-level"], + "@typescript-eslint/no-import-type-side-effects": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "separate-type-imports", + disallowTypeAnnotations: false, + }, + ], + }, +}; diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 00000000..7d7c7a5f --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,3 @@ + +# Sentry Config File +.env.sentry-build-plugin diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore new file mode 100644 index 00000000..e841c6b3 --- /dev/null +++ b/apps/web/.prettierignore @@ -0,0 +1,5 @@ +.next +.turbo +out/ +dist/ +build/ \ No newline at end of file diff --git a/apps/web/.prettierrc b/apps/web/.prettierrc new file mode 100644 index 00000000..87d988f1 --- /dev/null +++ b/apps/web/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/apps/web/Dockerfile.dev b/apps/web/Dockerfile.dev new file mode 100644 index 00000000..d914fd81 --- /dev/null +++ b/apps/web/Dockerfile.dev @@ -0,0 +1,13 @@ +FROM node:22-alpine + +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +COPY . . +RUN corepack enable pnpm && pnpm add -g turbo +RUN pnpm install + +EXPOSE 3000 +VOLUME [ "/app/node_modules", "/app/web/node_modules" ] +CMD ["pnpm", "dev", "--filter=web"] diff --git a/apps/web/Dockerfile.web b/apps/web/Dockerfile.web new file mode 100644 index 00000000..873edfb1 --- /dev/null +++ b/apps/web/Dockerfile.web @@ -0,0 +1,120 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-alpine AS base + +# Setup pnpm package manager with corepack and configure global bin directory for caching +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +# ***************************************************************************** +# STAGE 1: Build the project +# ***************************************************************************** +FROM base AS builder +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +ARG TURBO_VERSION=2.5.6 +RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION} +COPY . . + +RUN turbo prune --scope=web --docker + +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** +# Add lockfile and package.json's of isolated subworkspace +FROM base AS installer + +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# First install the dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN corepack enable pnpm +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store + +# Build the project +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store + +ARG NEXT_PUBLIC_API_BASE_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_URL="" +ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +ARG NEXT_PUBLIC_LIVE_BASE_URL="" +ENV NEXT_PUBLIC_LIVE_BASE_URL=$NEXT_PUBLIC_LIVE_BASE_URL + +ARG NEXT_PUBLIC_LIVE_BASE_PATH="/live" +ENV NEXT_PUBLIC_LIVE_BASE_PATH=$NEXT_PUBLIC_LIVE_BASE_PATH + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 + +RUN pnpm turbo run build --filter=web + +# ***************************************************************************** +# STAGE 3: Copy the project and start it +# ***************************************************************************** +FROM base AS runner +WORKDIR /app + +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +USER nextjs + + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer /app/apps/web/.next/standalone ./ +COPY --from=installer /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=installer /app/apps/web/public ./apps/web/public + +ARG NEXT_PUBLIC_API_BASE_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_URL="" +ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +ARG NEXT_PUBLIC_LIVE_BASE_URL="" +ENV NEXT_PUBLIC_LIVE_BASE_URL=$NEXT_PUBLIC_LIVE_BASE_URL + +ARG NEXT_PUBLIC_LIVE_BASE_PATH="/live" +ENV NEXT_PUBLIC_LIVE_BASE_PATH=$NEXT_PUBLIC_LIVE_BASE_PATH + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 + +EXPOSE 3000 + +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx new file mode 100644 index 00000000..d81d2c14 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx @@ -0,0 +1,65 @@ +"use client"; +import type { FC } from "react"; +import { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { SIDEBAR_WIDTH } from "@plane/constants"; +import { useLocalStorage } from "@plane/hooks"; +// components +import { ResizableSidebar } from "@/components/sidebar/resizable-sidebar"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useAppRail } from "@/hooks/use-app-rail"; +// local imports +import { ExtendedAppSidebar } from "./extended-sidebar"; +import { AppSidebar } from "./sidebar"; + +export const ProjectAppSidebar: FC = observer(() => { + // store hooks + const { + sidebarCollapsed, + toggleSidebar, + sidebarPeek, + toggleSidebarPeek, + isExtendedSidebarOpened, + isAnySidebarDropdownOpen, + } = useAppTheme(); + const { storedValue, setValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH); + // states + const [sidebarWidth, setSidebarWidth] = useState(storedValue ?? SIDEBAR_WIDTH); + // hooks + const { shouldRenderAppRail } = useAppRail(); + // derived values + const isAnyExtendedSidebarOpen = isExtendedSidebarOpened; + + // handlers + const handleWidthChange = (width: number) => setValue(width); + + return ( + <> + + + + } + isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen} + isAnySidebarDropdownOpen={isAnySidebarDropdownOpen} + disablePeekTrigger={shouldRenderAppRail} + > + + + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx new file mode 100644 index 00000000..8408a970 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -0,0 +1,31 @@ +"use client"; +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// ui +import { CycleIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +// plane web components +import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge"; + +export const WorkspaceActiveCycleHeader = observer(() => { + const { t } = useTranslation(); + return ( +
    + + + } + /> + } + /> + + + +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx new file mode 100644 index 00000000..1ee1b3c3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local imports +import { WorkspaceActiveCycleHeader } from "./header"; + +export default function WorkspaceActiveCycleLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx new file mode 100644 index 00000000..3b3e82c8 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { PageHead } from "@/components/core/page-title"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +// plane web components +import { WorkspaceActiveCyclesRoot } from "@/plane-web/components/active-cycles"; + +const WorkspaceActiveCyclesPage = observer(() => { + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Active Cycles` : undefined; + + return ( + <> + + + + ); +}); + +export default WorkspaceActiveCyclesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx new file mode 100644 index 00000000..8171d55a --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +import { AnalyticsIcon } from "@plane/propel/icons"; +// plane imports +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; + +export const WorkspaceAnalyticsHeader = observer(() => { + const { t } = useTranslation(); + return ( +
    + + + } + /> + } + /> + + +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx new file mode 100644 index 00000000..29d4a54e --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx @@ -0,0 +1,14 @@ +"use client"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { WorkspaceAnalyticsHeader } from "./header"; + +export default function WorkspaceAnalyticsTabLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx new file mode 100644 index 00000000..5c3f94e3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useMemo } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +// plane package imports +import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Tabs } from "@plane/ui"; +import type { TabItem } from "@plane/ui"; +// components +import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions"; +import { PageHead } from "@/components/core/page-title"; +import { ComicBoxButton } from "@/components/empty-state/comic-box-button"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +// hooks +import { captureClick } from "@/helpers/event-tracker.helper"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs"; + +type Props = { + params: { + tabId: string; + workspaceSlug: string; + }; +}; + +const AnalyticsPage = observer((props: Props) => { + // props + const { params } = props; + const { tabId } = params; + + // hooks + const router = useRouter(); + + // plane imports + const { t } = useTranslation(); + + // store hooks + const { toggleCreateProjectModal } = useCommandPalette(); + const { workspaceProjectIds, loader } = useProject(); + const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); + + // helper hooks + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" }); + + // permissions + const canPerformEmptyStateActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + // derived values + const pageTitle = currentWorkspace?.name + ? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name }) + : undefined; + const ANALYTICS_TABS = useMemo(() => getAnalyticsTabs(t), [t]); + const tabs: TabItem[] = useMemo( + () => + ANALYTICS_TABS.map((tab) => ({ + key: tab.key, + label: tab.label, + content: , + onClick: () => { + router.push(`/${currentWorkspace?.slug}/analytics/${tab.key}`); + }, + disabled: tab.isDisabled, + })), + [ANALYTICS_TABS, router, currentWorkspace?.slug] + ); + const defaultTab = tabId || ANALYTICS_TABS[0].key; + + return ( + <> + + {workspaceProjectIds && ( + <> + {workspaceProjectIds.length > 0 || loader === "init-loader" ? ( +
    + } + /> +
    + ) : ( + { + toggleCreateProjectModal(true); + captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON }); + }} + disabled={!canPerformEmptyStateActions} + /> + } + /> + )} + + )} + + ); +}); + +export default AnalyticsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx new file mode 100644 index 00000000..7b854c58 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EProjectFeatureKey } from "@plane/constants"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const ProjectIssueDetailsHeader = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, workItem } = useParams(); + // store hooks + const { getProjectById, loader } = useProject(); + const { + issue: { getIssueById, getIssueIdByIdentifier }, + } = useIssueDetail(); + // derived values + const issueId = getIssueIdByIdentifier(workItem?.toString()); + const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined; + const projectId = issueDetails ? issueDetails?.project_id : undefined; + const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined; + + if (!workspaceSlug || !projectId || !issueId) return null; + + return ( +
    + + + + + } + /> + + + + {projectId && issueId && ( + + )} + +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx new file mode 100644 index 00000000..f4cac617 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectIssueDetailsHeader } from "./header"; + +export default function ProjectIssueDetailsLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx new file mode 100644 index 00000000..dabb43eb --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import React, { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { EIssueServiceType } from "@plane/types"; +import { Loader } from "@plane/ui"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { PageHead } from "@/components/core/page-title"; +import { IssueDetailRoot } from "@/components/issues/issue-detail"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useProject } from "@/hooks/store/use-project"; +// assets +import { useAppRouter } from "@/hooks/use-app-router"; +import { useWorkItemProperties } from "@/plane-web/hooks/use-issue-properties"; +import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; +import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp"; +import emptyIssueLight from "@/public/empty-state/search/issues-light.webp"; + +const IssueDetailsPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, workItem } = useParams(); + // hooks + const { resolvedTheme } = useTheme(); + // store hooks + const { t } = useTranslation(); + const { + fetchIssueWithIdentifier, + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme(); + + const projectIdentifier = workItem?.toString().split("-")[0]; + const sequence_id = workItem?.toString().split("-")[1]; + + // fetching issue details + const { data, isLoading, error } = useSWR( + workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, + workspaceSlug && workItem + ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) + : null + ); + const issueId = data?.id; + const projectId = data?.project_id; + // derived values + const issue = getIssueById(issueId?.toString() || "") || undefined; + const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined; + const issueLoader = !issue || isLoading; + const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; + + useWorkItemProperties( + projectId, + workspaceSlug.toString(), + issueId, + issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES + ); + + useEffect(() => { + const handleToggleIssueDetailSidebar = () => { + if (window && window.innerWidth < 768) { + toggleIssueDetailSidebar(true); + } + if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) { + toggleIssueDetailSidebar(false); + } + }; + window.addEventListener("resize", handleToggleIssueDetailSidebar); + handleToggleIssueDetailSidebar(); + return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar); + }, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]); + + useEffect(() => { + if (data?.is_intake) { + router.push(`/${workspaceSlug}/projects/${data.project_id}/intake/?currentTab=open&inboxIssueId=${data?.id}`); + } + }, [workspaceSlug, data]); + + return ( + <> + + {error ? ( + router.push(`/${workspaceSlug}/workspace-views/all-issues/`), + }} + /> + ) : issueLoader ? ( + +
    + + + + +
    +
    + + + + +
    +
    + ) : ( + workspaceSlug && + projectId && + issueId && ( + + + + ) + )} + + ); +}); + +export default IssueDetailsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx new file mode 100644 index 00000000..9e847282 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button } from "@plane/propel/button"; +import { DraftIcon } from "@plane/propel/icons"; +import { EIssuesStoreType } from "@plane/types"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { CountChip } from "@/components/common/count-chip"; +import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal"; + +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft"; + +export const WorkspaceDraftHeader = observer(() => { + // state + const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { paginationInfo } = useWorkspaceDraftIssues(); + const { joinedProjectIds } = useProject(); + + const { t } = useTranslation(); + // check if user is authorized to create draft work item + 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/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx new file mode 100644 index 00000000..7629f6ed --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local imports +import { WorkspaceDraftHeader } from "./header"; + +export default function WorkspaceDraftLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx new file mode 100644 index 00000000..93c9b79c --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core/page-title"; +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/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx new file mode 100644 index 00000000..b193dbe2 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx @@ -0,0 +1,154 @@ +"use client"; + +import React, { useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { Plus, Search } from "lucide-react"; +import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Tooltip } from "@plane/propel/tooltip"; +import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils"; +// components +import { CreateProjectModal } from "@/components/project/create-project-modal"; +import { SidebarProjectsListItem } from "@/components/workspace/sidebar/projects-list-item"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import type { TProject } from "@/plane-web/types"; +import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper"; + +export const ExtendedProjectSidebar = observer(() => { + // refs + const extendedProjectSidebarRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(""); + // states + const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); + // routers + const { workspaceSlug } = useParams(); + // store hooks + const { t } = useTranslation(); + const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar } = useAppTheme(); + const { getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject(); + const { allowPermissions } = useUserPermissions(); + + const handleOnProjectDrop = ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => { + if (!sourceId || !destinationId || !workspaceSlug) return; + if (sourceId === destinationId) return; + + const joinedProjectsList: TProject[] = []; + joinedProjects.map((projectId) => { + const projectDetails = getPartialProjectById(projectId); + if (projectDetails) joinedProjectsList.push(projectDetails); + }); + + const sourceIndex = joinedProjects.indexOf(sourceId); + const destinationIndex = shouldDropAtEnd ? joinedProjects.length : joinedProjects.indexOf(destinationId); + + if (joinedProjectsList.length <= 0) return; + + const updatedSortOrder = orderJoinedProjects(sourceIndex, destinationIndex, sourceId, joinedProjectsList); + if (updatedSortOrder != undefined) + updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("something_went_wrong"), + }); + }); + }; + + // filter projects based on search query + const filteredProjects = joinedProjects.filter((projectId) => { + const project = getPartialProjectById(projectId); + if (!project) return false; + return project.name.toLowerCase().includes(searchQuery.toLowerCase()) || project.identifier.includes(searchQuery); + }); + + // auth + const isAuthorizedUser = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const handleClose = () => toggleExtendedProjectSidebar(false); + + const handleCopyText = (projectId: string) => { + copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("link_copied"), + message: t("project_link_copied_to_clipboard"), + }); + }); + }; + return ( + <> + {workspaceSlug && ( + setIsProjectModalOpen(false)} + setToFavorite={false} + workspaceSlug={workspaceSlug.toString()} + /> + )} + +
    +
    + Projects + {isAuthorizedUser && ( + + + + )} +
    +
    + + setSearchQuery(e.target.value)} + /> +
    +
    +
    + {filteredProjects.map((projectId, index) => ( + handleCopyText(projectId)} + projectListType={"JOINED"} + disableDrag={false} + disableDrop={false} + isLastChild={index === joinedProjects.length - 1} + handleOnProjectDrop={handleOnProjectDrop} + renderInExtendedSidebar + /> + ))} +
    +
    + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx new file mode 100644 index 00000000..878f9ee3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { FC } from "react"; +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants"; +import { useLocalStorage } from "@plane/hooks"; +import { cn } from "@plane/utils"; +// hooks +import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click"; + +type Props = { + children: React.ReactNode; + extendedSidebarRef: React.RefObject; + isExtendedSidebarOpened: boolean; + handleClose: () => void; + excludedElementId: string; +}; + +export const ExtendedSidebarWrapper: FC = observer((props) => { + const { children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props; + // store hooks + const { storedValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH); + + useExtendedSidebarOutsideClickDetector(extendedSidebarRef, handleClose, excludedElementId); + + return ( +
    + {children} +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx new file mode 100644 index 00000000..108de05f --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx @@ -0,0 +1,117 @@ +"use client"; + +import React, { useMemo, useRef } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants"; +import type { EUserWorkspaceRoles } from "@plane/types"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +// plane-web imports +import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar/extended-sidebar-item"; +import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper"; + +export const ExtendedAppSidebar = observer(() => { + // refs + const extendedSidebarRef = useRef(null); + // routers + const { workspaceSlug } = useParams(); + // store hooks + const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme(); + const { updateSidebarPreference, getNavigationPreferences } = useWorkspace(); + + // derived values + const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString()); + + const sortedNavigationItems = useMemo( + () => + WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => { + const preference = currentWorkspaceNavigationPreferences?.[item.key]; + return { + ...item, + sort_order: preference ? preference.sort_order : 0, + }; + }).sort((a, b) => a.sort_order - b.sort_order), + [currentWorkspaceNavigationPreferences] + ); + + const sortedNavigationItemsKeys = sortedNavigationItems.map((item) => item.key); + + const orderNavigationItem = ( + sourceIndex: number, + destinationIndex: number, + navigationList: { + sort_order: number; + key: string; + labelTranslationKey: string; + href: string; + access: EUserWorkspaceRoles[]; + }[] + ): number | undefined => { + if (sourceIndex < 0 || destinationIndex < 0 || navigationList.length <= 0) return undefined; + + let updatedSortOrder: number | undefined = undefined; + const sortOrderDefaultValue = 10000; + + if (destinationIndex === 0) { + // updating project at the top of the project + const currentSortOrder = navigationList[destinationIndex].sort_order || 0; + updatedSortOrder = currentSortOrder - sortOrderDefaultValue; + } else if (destinationIndex === navigationList.length) { + // updating project at the bottom of the project + const currentSortOrder = navigationList[destinationIndex - 1].sort_order || 0; + updatedSortOrder = currentSortOrder + sortOrderDefaultValue; + } else { + // updating project in the middle of the project + const destinationTopProjectSortOrder = navigationList[destinationIndex - 1].sort_order || 0; + const destinationBottomProjectSortOrder = navigationList[destinationIndex].sort_order || 0; + const updatedValue = (destinationTopProjectSortOrder + destinationBottomProjectSortOrder) / 2; + updatedSortOrder = updatedValue; + } + + return updatedSortOrder; + }; + + const handleOnNavigationItemDrop = ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => { + if (!sourceId || !destinationId || !workspaceSlug) return; + if (sourceId === destinationId) return; + + const sourceIndex = sortedNavigationItemsKeys.indexOf(sourceId); + const destinationIndex = shouldDropAtEnd + ? sortedNavigationItemsKeys.length + : sortedNavigationItemsKeys.indexOf(destinationId); + + const updatedSortOrder = orderNavigationItem(sourceIndex, destinationIndex, sortedNavigationItems); + + if (updatedSortOrder != undefined) + updateSidebarPreference(workspaceSlug.toString(), sourceId, { + sort_order: updatedSortOrder, + }); + }; + + const handleClose = () => toggleExtendedSidebar(false); + + return ( + + {sortedNavigationItems.map((item, index) => ( + + ))} + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx new file mode 100644 index 00000000..18629c17 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { observer } from "mobx-react"; +import { Shapes } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { HomeIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +// hooks +import { useHome } from "@/hooks/store/use-home"; +// local imports +import { StarUsOnGitHubLink } from "./star-us-link"; + +export const WorkspaceDashboardHeader = observer(() => { + // plane hooks + const { t } = useTranslation(); + // hooks + const { toggleWidgetSettings } = useHome(); + + return ( + <> +
    + +
    + + } + /> + } + /> + +
    +
    + + + + +
    + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx new file mode 100644 index 00000000..3b486684 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { CommandPalette } from "@/components/command-palette"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +// plane web components +import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; +import { ProjectAppSidebar } from "./_sidebar"; + +export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
    +
    +
    + +
    + {children} +
    +
    +
    + + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx new file mode 100644 index 00000000..f8fd3a0f --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +// components +import { NotificationsSidebarRoot } from "@/components/workspace-notifications/sidebar"; + +export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) { + return ( +
    + +
    {children}
    +
    + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx new file mode 100644 index 00000000..4521cf43 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { PageHead } from "@/components/core/page-title"; +import { NotificationsRoot } from "@/components/workspace-notifications"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; + +const WorkspaceDashboardPage = observer(() => { + const { workspaceSlug } = useParams(); + // plane hooks + const { t } = useTranslation(); + // hooks + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name + ? t("notification.page_label", { workspace: currentWorkspace?.name }) + : undefined; + + return ( + <> + + + + ); +}); + +export default WorkspaceDashboardPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx new file mode 100644 index 00000000..446a965a --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { useTranslation } from "@plane/i18n"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { PageHead } from "@/components/core/page-title"; +import { WorkspaceHomeView } from "@/components/home"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +// local components +import { WorkspaceDashboardHeader } from "./header"; + +const WorkspaceDashboardPage = observer(() => { + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - ${t("home.title")}` : undefined; + + return ( + <> + } /> + + + + + + ); +}); + +export default WorkspaceDashboardPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx new file mode 100644 index 00000000..aac7ed45 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React from "react"; +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ProfileIssuesPage } from "@/components/profile/profile-issues"; + +const ProfilePageHeader = { + assigned: "Profile - Assigned", + created: "Profile - Created", + subscribed: "Profile - Subscribed", +}; + +const ProfileIssuesTypePage = () => { + const { profileViewId } = useParams() as { profileViewId: "assigned" | "subscribed" | "created" | undefined }; + + if (!profileViewId) return null; + + const header = ProfilePageHeader[profileViewId]; + + return ( + <> + + + + ); +}; + +export default ProfileIssuesTypePage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx new file mode 100644 index 00000000..cc9a789e --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DownloadActivityButton } from "@/components/profile/activity/download-button"; +import { WorkspaceActivityListPage } from "@/components/profile/activity/workspace-activity-list"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; + +const PER_PAGE = 100; + +const ProfileActivityPage = observer(() => { + // states + const [pageCount, setPageCount] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [resultsCount, setResultsCount] = useState(0); + // router + const { allowPermissions } = useUserPermissions(); + //hooks + const { t } = useTranslation(); + + const updateTotalPages = (count: number) => setTotalPages(count); + + const updateResultsCount = (count: number) => setResultsCount(count); + + const handleLoadMore = () => setPageCount((prev) => prev + 1); + + const activityPages: React.ReactNode[] = []; + for (let i = 0; i < pageCount; i++) + activityPages.push( + + ); + + const canDownloadActivity = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + return ( + <> + +
    +
    +

    {t("profile.stats.recent_activity.title")}

    + {canDownloadActivity && } +
    +
    + {activityPages} + {pageCount < totalPages && resultsCount !== 0 && ( +
    + +
    + )} +
    +
    + + ); +}); + +export default ProfileActivityPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx new file mode 100644 index 00000000..ef49c8ff --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx @@ -0,0 +1,112 @@ +"use client"; + +// ui +import type { FC } from "react"; +import { observer } from "mobx-react"; +import { useParams, useRouter } from "next/navigation"; +import { ChevronDown, PanelRight } from "lucide-react"; +import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { YourWorkIcon } from "@plane/propel/icons"; +import type { IUserProfileProjectSegregation } from "@plane/types"; +import { Breadcrumbs, Header, CustomMenu } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { ProfileIssuesFilter } from "@/components/profile/profile-issues-filter"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useUser, useUserPermissions } from "@/hooks/store/user"; + +type TUserProfileHeader = { + userProjectsData: IUserProfileProjectSegregation | undefined; + type?: string | undefined; + showProfileIssuesFilter?: boolean; +}; + +export const UserProfileHeader: FC = observer((props) => { + const { userProjectsData, type = undefined, showProfileIssuesFilter } = props; + // router + const { workspaceSlug, userId } = useParams(); + const router = useRouter(); + // store hooks + const { toggleProfileSidebar, profileSidebarCollapsed } = useAppTheme(); + const { data: currentUser } = useUser(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); + // derived values + const isAuthorized = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + if (!workspaceUserInfo) return null; + + const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; + + const userName = `${userProjectsData?.user_data?.first_name} ${userProjectsData?.user_data?.last_name}`; + + const isCurrentUser = currentUser?.id === userId; + + const breadcrumbLabel = isCurrentUser ? t("profile.page_label") : `${userName} ${t("profile.work")}`; + + return ( +
    + + + } + /> + } + /> + + + +
    {showProfileIssuesFilter && }
    +
    + + {type} + +
    + } + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + <> + {tabsList.map((tab) => ( + router.push(`/${workspaceSlug}/profile/${userId}/${tab.route}`)} + > + {t(tab.i18n_label)} + + ))} + + +
    + + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx new file mode 100644 index 00000000..f8931405 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +import useSWR from "swr"; +// components +import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProfileSidebar } from "@/components/profile/sidebar"; +// constants +import { USER_PROFILE_PROJECT_SEGREGATION } from "@/constants/fetch-keys"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; +import useSize from "@/hooks/use-window-size"; +// local components +import { UserService } from "@/services/user.service"; +import { UserProfileHeader } from "./header"; +import { ProfileIssuesMobileHeader } from "./mobile-header"; +import { ProfileNavbar } from "./navbar"; + +const userService = new UserService(); + +type Props = { + children: React.ReactNode; +}; + +const UseProfileLayout: React.FC = observer((props) => { + const { children } = props; + // router + const { workspaceSlug, userId } = useParams(); + const pathname = usePathname(); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); + // derived values + const isAuthorized = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const windowSize = useSize(); + const isSmallerScreen = windowSize[0] >= 768; + + const { data: userProjectsData } = useSWR( + workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null, + workspaceSlug && userId + ? () => userService.getUserProfileProjectsSegregation(workspaceSlug.toString(), userId.toString()) + : null + ); + // derived values + const isAuthorizedPath = + pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed"); + const isIssuesTab = pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed"); + + const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; + const currentTab = tabsList.find((tab) => pathname === `/${workspaceSlug}/profile/${userId}${tab.selected}`); + + return ( + <> + {/* Passing the type prop from the current route value as we need the header as top most component. + TODO: We are depending on the route path to handle the mobile header type. If the path changes, this logic will break. */} +
    +
    + + } + mobileHeader={isIssuesTab && } + /> + +
    +
    + + {isAuthorized || !isAuthorizedPath ? ( +
    {children}
    + ) : ( +
    + {t("you_do_not_have_the_permission_to_access_this_page")} +
    + )} +
    + {!isSmallerScreen && } +
    +
    +
    + {isSmallerScreen && } +
    + + ); +}); + +export default UseProfileLayout; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx new file mode 100644 index 00000000..5b59b39d --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { ChevronDown } from "lucide-react"; +// plane constants +import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +// plane i18n +import { useTranslation } from "@plane/i18n"; +// types +import type { + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + TIssueLayouts, + EIssueLayoutTypes, +} from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; +// ui +import { CustomMenu } from "@plane/ui"; +// components +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon"; +// hooks +import { useIssues } from "@/hooks/store/use-issues"; + +export const ProfileIssuesMobileHeader = observer(() => { + // plane i18n + const { t } = useTranslation(); + // router + const { workspaceSlug, userId } = useParams(); + // store hook + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROFILE); + // derived values + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: TIssueLayouts) => { + if (!workspaceSlug || !userId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout as EIssueLayoutTypes | undefined }, + userId.toString() + ); + }, + [workspaceSlug, updateFilters, userId] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !userId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + userId.toString() + ); + }, + [workspaceSlug, updateFilters, userId] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !userId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_PROPERTIES, + property, + userId.toString() + ); + }, + [workspaceSlug, updateFilters, userId] + ); + + return ( +
    + + {t("common.layout")} + +
    + } + customButtonClassName="flex flex-center text-custom-text-200 text-sm" + closeOnSelect + > + {ISSUE_LAYOUTS.map((layout, index) => { + if (layout.key === "spreadsheet" || layout.key === "gantt_chart" || layout.key === "calendar") return; + return ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
    {t(layout.i18n_title)}
    +
    + ); + })} + +
    + + {t("common.display")} + +
    + } + > + + + + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx new file mode 100644 index 00000000..a9183651 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx @@ -0,0 +1,43 @@ +import React from "react"; + +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; + +// components +// constants +import { Header, EHeaderVariant } from "@plane/ui"; + +type Props = { + isAuthorized: boolean; +}; + +export const ProfileNavbar: React.FC = (props) => { + const { isAuthorized } = props; + const { t } = useTranslation(); + const { workspaceSlug, userId } = useParams(); + const pathname = usePathname(); + + const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; + + return ( +
    +
    + {tabsList.map((tab) => ( + + + {t(tab.i18n_label)} + + + ))} +
    +
    + ); +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx new file mode 100644 index 00000000..a7b8aad7 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane imports +import { GROUP_CHOICES } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { IUserStateDistribution, TStateGroups } from "@plane/types"; +import { ContentWrapper } from "@plane/ui"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ProfileActivity } from "@/components/profile/overview/activity"; +import { ProfilePriorityDistribution } from "@/components/profile/overview/priority-distribution"; +import { ProfileStateDistribution } from "@/components/profile/overview/state-distribution"; +import { ProfileStats } from "@/components/profile/overview/stats"; +import { ProfileWorkload } from "@/components/profile/overview/workload"; +// constants +import { USER_PROFILE_DATA } from "@/constants/fetch-keys"; +// services +import { UserService } from "@/services/user.service"; +const userService = new UserService(); + +export default function ProfileOverviewPage() { + const { workspaceSlug, userId } = useParams(); + + const { t } = useTranslation(); + const { data: userProfile } = useSWR( + workspaceSlug && userId ? USER_PROFILE_DATA(workspaceSlug.toString(), userId.toString()) : null, + workspaceSlug && userId ? () => userService.getUserProfileData(workspaceSlug.toString(), userId.toString()) : null + ); + + const stateDistribution: IUserStateDistribution[] = Object.keys(GROUP_CHOICES).map((key) => { + const group = userProfile?.state_distribution.find((g) => g.state_group === key); + + if (group) return group; + else return { state_group: key as TStateGroups, state_count: 0 }; + }); + + return ( + <> + + + + +
    + + +
    + +
    + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx new file mode 100644 index 00000000..c46c5317 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectArchivesHeader } from "../header"; + +export default function ProjectArchiveCyclesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx new file mode 100644 index 00000000..9894b9ca --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ArchivedCycleLayoutRoot } from "@/components/cycles/archived-cycles"; +import { ArchivedCyclesHeader } from "@/components/cycles/archived-cycles/header"; +// hooks +import { useProject } from "@/hooks/store/use-project"; + +const ProjectArchivedCyclesPage = observer(() => { + // router + const { projectId } = useParams(); + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name && `${project?.name} - Archived cycles`; + + return ( + <> + +
    + + +
    + + ); +}); + +export default ProjectArchivedCyclesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx new file mode 100644 index 00000000..fcb28bd3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -0,0 +1,108 @@ +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { ArchiveIcon, CycleIcon, ModuleIcon, WorkItemsIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import { EIssuesStoreType } from "@plane/types"; +// ui +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +// hooks +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs/project"; + +type TProps = { + activeTab: "issues" | "cycles" | "modules"; +}; + +const PROJECT_ARCHIVES_BREADCRUMB_LIST: { + [key: string]: { + label: string; + href: string; + icon: React.FC & { className?: string }>; + }; +} = { + issues: { + label: "Work items", + href: "/issues", + icon: WorkItemsIcon, + }, + cycles: { + label: "Cycles", + href: "/cycles", + icon: CycleIcon, + }, + modules: { + label: "Modules", + href: "/modules", + icon: ModuleIcon, + }, +}; + +export const ProjectArchivesHeader: FC = observer((props: TProps) => { + const { activeTab } = props; + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + // store hooks + const { + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.ARCHIVED); + const { loader } = useProject(); + // hooks + const { isMobile } = usePlatformOS(); + + const issueCount = getGroupIssueCount(undefined, undefined, false); + + const activeTabBreadcrumbDetail = + PROJECT_ARCHIVES_BREADCRUMB_LIST[activeTab as keyof typeof PROJECT_ARCHIVES_BREADCRUMB_LIST]; + + return ( +
    + +
    + + + } + /> + } + /> + {activeTabBreadcrumbDetail && ( + } + /> + } + /> + )} + + {activeTab === "issues" && issueCount && issueCount > 0 ? ( + 1 ? "work items" : "work item"} in project's archived`} + position="bottom" + > + + {issueCount} + + + ) : null} +
    +
    +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx new file mode 100644 index 00000000..550bfa7b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// ui +import { Loader } from "@plane/ui"; +// components +import { PageHead } from "@/components/core/page-title"; +import { IssueDetailRoot } from "@/components/issues/issue-detail"; +// constants +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useProject } from "@/hooks/store/use-project"; + +const ArchivedIssueDetailsPage = observer(() => { + // router + const { workspaceSlug, projectId, archivedIssueId } = useParams(); + // states + // hooks + const { + fetchIssue, + issue: { getIssueById }, + } = useIssueDetail(); + + const { getProjectById } = useProject(); + + const { isLoading } = useSWR( + workspaceSlug && projectId && archivedIssueId + ? `ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}` + : null, + workspaceSlug && projectId && archivedIssueId + ? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString()) + : null + ); + + // derived values + const issue = archivedIssueId ? getIssueById(archivedIssueId.toString()) : undefined; + const project = issue ? getProjectById(issue?.project_id ?? "") : undefined; + const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; + + if (!issue) return <>; + + const issueLoader = !issue || isLoading; + + return ( + <> + + {issueLoader ? ( + +
    + + + + +
    +
    + + + + +
    +
    + ) : ( +
    +
    + {workspaceSlug && projectId && archivedIssueId && ( + + )} +
    +
    + )} + + ); +}); + +export default ArchivedIssueDetailsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx new file mode 100644 index 00000000..3cfbdb81 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// ui +import { ArchiveIcon, WorkItemsIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions"; +// constants +import { ISSUE_DETAILS } from "@/constants/fetch-keys"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs/project"; +// services +import { IssueService } from "@/services/issue"; + +const issueService = new IssueService(); + +export const ProjectArchivedIssueDetailsHeader = observer(() => { + // router + const { workspaceSlug, projectId, archivedIssueId } = useParams(); + // store hooks + const { currentProjectDetails, loader } = useProject(); + + const { data: issueDetails } = useSWR( + workspaceSlug && projectId && archivedIssueId ? ISSUE_DETAILS(archivedIssueId.toString()) : null, + workspaceSlug && projectId && archivedIssueId + ? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString()) + : null + ); + + return ( +
    + + + + } + /> + } + /> + } + /> + } + /> + + } + /> + + + + + +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx new file mode 100644 index 00000000..74eb8949 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectArchivedIssueDetailsHeader } from "./header"; + +export default function ProjectArchivedIssueDetailLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx new file mode 100644 index 00000000..321ab8a6 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectArchivesHeader } from "../../header"; + +export default function ProjectArchiveIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx new file mode 100644 index 00000000..ceb24bf3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ArchivedIssuesHeader } from "@/components/issues/archived-issues-header"; +import { ArchivedIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/archived-issue-layout-root"; +// hooks +import { useProject } from "@/hooks/store/use-project"; + +const ProjectArchivedIssuesPage = observer(() => { + // router + const { projectId } = useParams(); + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name && `${project?.name} - Archived work items`; + + return ( + <> + +
    + + +
    + + ); +}); + +export default ProjectArchivedIssuesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx new file mode 100644 index 00000000..ee72018a --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectArchivesHeader } from "../header"; + +export default function ProjectArchiveModulesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx new file mode 100644 index 00000000..1edb13e2 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules"; +// hooks +import { useProject } from "@/hooks/store/use-project"; + +const ProjectArchivedModulesPage = observer(() => { + // router + const { projectId } = useParams(); + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name && `${project?.name} - Archived modules`; + + return ( + <> + +
    + + +
    + + ); +}); + +export default ProjectArchivedModulesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx new file mode 100644 index 00000000..9f630214 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { cn } from "@plane/utils"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { PageHead } from "@/components/core/page-title"; +import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details"; +import { CycleDetailsSidebar } from "@/components/cycles/analytics-sidebar"; +import { CycleLayoutRoot } from "@/components/issues/issue-layouts/roots/cycle-layout-root"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import useLocalStorage from "@/hooks/use-local-storage"; +// assets +import emptyCycle from "@/public/empty-state/cycle.svg"; + +const CycleDetailPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, cycleId } = useParams(); + // store hooks + const { getCycleById, loader } = useCycle(); + const { getProjectById } = useProject(); + // const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE); + // hooks + const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false); + + useCyclesDetails({ + workspaceSlug: workspaceSlug?.toString(), + projectId: projectId.toString(), + cycleId: cycleId.toString(), + }); + // derived values + const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false; + const cycle = cycleId ? getCycleById(cycleId.toString()) : undefined; + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name && cycle?.name ? `${project?.name} - ${cycle?.name}` : undefined; + + /** + * Toggles the sidebar + */ + const toggleSidebar = () => setValue(!isSidebarCollapsed); + + // const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + + return ( + <> + + {!cycle && !loader ? ( + router.push(`/${workspaceSlug}/projects/${projectId}/cycles`), + }} + /> + ) : ( + <> +
    +
    + +
    + {cycleId && !isSidebarCollapsed && ( +
    + +
    + )} +
    + + )} + + ); +}); + +export default CycleDetailPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx new file mode 100644 index 00000000..885d6bc6 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { ChartNoAxesColumn, PanelRight, SlidersHorizontal } from "lucide-react"; +// plane imports +import { + EIssueFilterType, + EUserPermissions, + EUserPermissionsLevel, + EProjectFeatureKey, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; +import { usePlatformOS } from "@plane/hooks"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { CycleIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; +import { Breadcrumbs, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { SwitcherLabel } from "@/components/common/switcher-label"; +import { CycleQuickActions } from "@/components/cycles/quick-actions"; +import { + DisplayFiltersSelection, + FiltersDropdown, + LayoutSelection, + MobileLayoutSelection, +} from "@/components/issues/issue-layouts/filters"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useCycle } from "@/hooks/store/use-cycle"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import useLocalStorage from "@/hooks/use-local-storage"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const CycleIssuesHeader: React.FC = observer(() => { + // refs + const parentRef = useRef(null); + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, cycleId } = useParams() as { + workspaceSlug: string; + projectId: string; + cycleId: string; + }; + // i18n + const { t } = useTranslation(); + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.CYCLE); + const { currentProjectCycleIds, getCycleById } = useCycle(); + const { toggleCreateIssueModal } = useCommandPalette(); + const { currentProjectDetails, loader } = useProject(); + const { isMobile } = usePlatformOS(); + const { allowPermissions } = useUserPermissions(); + + const activeLayout = issueFilters?.displayFilters?.layout; + + const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false); + + const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false; + const toggleSidebar = () => { + setValue(!isSidebarCollapsed); + }; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + // derived values + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + const isCompletedCycle = cycleDetails?.status?.toLocaleLowerCase() === "completed"; + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + const switcherOptions = currentProjectCycleIds + ?.map((id) => { + const _cycle = id === cycleId ? cycleDetails : getCycleById(id); + if (!_cycle) return; + return { + value: _cycle.id, + query: _cycle.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + const workItemsCount = getGroupIssueCount(undefined, undefined, false); + + return ( + <> + setAnalyticsModal(false)} + cycleDetails={cycleDetails ?? undefined} + /> +
    + +
    + + + { + router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${value}`); + }} + title={cycleDetails?.name} + icon={ + + + + } + isLast + /> + } + isLast + /> + + {workItemsCount && workItemsCount > 0 ? ( + 1 ? "work items" : "work item" + } in this cycle`} + position="bottom" + > + + {workItemsCount} + + + ) : null} +
    +
    + +
    +
    + handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> +
    +
    + handleLayoutChange(layout)} + activeLayout={activeLayout} + /> +
    + + } + > + + + + {canUserCreateIssue && ( + <> + + {!isCompletedCycle && ( + + )} + + )} + + +
    +
    +
    + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx new file mode 100644 index 00000000..40872f0b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { CycleIssuesHeader } from "./header"; +import { CycleIssuesMobileHeader } from "./mobile-header"; + +export default function ProjectCycleIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx new file mode 100644 index 00000000..e97104c5 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useParams } from "next/navigation"; +// icons +import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +// plane imports +import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; + +const SUPPORTED_LAYOUTS = [ + { key: "list", titleTranslationKey: "issue.layouts.list", icon: List }, + { key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban }, + { key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar }, +]; + +export const CycleIssuesMobileHeader = () => { + // router + const { workspaceSlug, projectId, cycleId } = useParams(); + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentProjectDetails } = useProject(); + const { getCycleById } = useCycle(); + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.CYCLE); + // derived values + const activeLayout = issueFilters?.displayFilters?.layout; + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId || !cycleId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout }, + cycleId.toString() + ); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId || !cycleId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + cycleId.toString() + ); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId || !cycleId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_PROPERTIES, + property, + cycleId.toString() + ); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + cycleDetails={cycleDetails ?? undefined} + /> +
    + {t("common.layout")} + } + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {SUPPORTED_LAYOUTS.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
    {t(layout.titleTranslationKey)}
    +
    + ))} +
    +
    + + {t("common.display")} + + + } + > + + +
    + + setAnalyticsModal(true)} + className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200" + > + {t("common.analytics")} + +
    + + ); +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx new file mode 100644 index 00000000..c2ac8e45 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// ui +import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { CyclesViewHeader } from "@/components/cycles/cycles-view-header"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +// constants + +export const CyclesListHeader: FC = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug } = useParams(); + + // store hooks + const { toggleCreateCycleModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails, loader } = useProject(); + const { t } = useTranslation(); + + const canUserCreateCycle = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + return ( +
    + + + + + + {canUserCreateCycle && currentProjectDetails ? ( + + + + + ) : ( + <> + )} +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx new file mode 100644 index 00000000..a3caddf3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { CyclesListHeader } from "./header"; +import { CyclesListMobileHeader } from "./mobile-header"; + +export default function ProjectCyclesListLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx new file mode 100644 index 00000000..97838d34 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { observer } from "mobx-react"; +// ui +import { GanttChartSquare, LayoutGrid, List } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +// plane package imports +import type { TCycleLayoutOptions } from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +// hooks +import { useCycleFilter } from "@/hooks/store/use-cycle-filter"; +import { useProject } from "@/hooks/store/use-project"; + +const CYCLE_VIEW_LAYOUTS: { + key: TCycleLayoutOptions; + icon: LucideIcon; + title: string; +}[] = [ + { + key: "list", + icon: List, + title: "List layout", + }, + { + key: "board", + icon: LayoutGrid, + title: "Gallery layout", + }, + { + key: "gantt", + icon: GanttChartSquare, + title: "Timeline layout", + }, +]; + +export const CyclesListMobileHeader = observer(() => { + const { currentProjectDetails } = useProject(); + // hooks + const { updateDisplayFilters } = useCycleFilter(); + return ( +
    + + + Layout + + } + customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm" + closeOnSelect + > + {CYCLE_VIEW_LAYOUTS.map((layout) => { + if (layout.key == "gantt") return; + return ( + { + updateDisplayFilters(currentProjectDetails!.id, { + layout: layout.key, + }); + }} + className="flex items-center gap-2" + > + +
    {layout.title}
    +
    + ); + })} +
    +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx new file mode 100644 index 00000000..681ec22e --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { TCycleFilters } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; +// components +import { Header, EHeaderVariant } from "@plane/ui"; +import { calculateTotalFilters } from "@plane/utils"; +import { PageHead } from "@/components/core/page-title"; +import { CycleAppliedFiltersList } from "@/components/cycles/applied-filters"; +import { CyclesView } from "@/components/cycles/cycles-view"; +import { CycleCreateUpdateModal } from "@/components/cycles/modal"; +import { ComicBoxButton } from "@/components/empty-state/comic-box-button"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useCycleFilter } from "@/hooks/store/use-cycle-filter"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const ProjectCyclesPage = observer(() => { + // states + const [createModal, setCreateModal] = useState(false); + // store hooks + const { currentProjectCycleIds, loader } = useCycle(); + const { getProjectById, currentProjectDetails } = useProject(); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // cycle filters hook + const { clearAllFilters, currentProjectFilters, updateFilters } = useCycleFilter(); + const { allowPermissions } = useUserPermissions(); + // derived values + const totalCycles = currentProjectCycleIds?.length ?? 0; + const project = projectId ? getProjectById(projectId?.toString()) : undefined; + const pageTitle = project?.name ? `${project?.name} - ${t("common.cycles", { count: 2 })}` : undefined; + const hasAdminLevelPermission = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const hasMemberLevelPermission = allowPermissions( + [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER], + EUserPermissionsLevel.PROJECT + ); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/cycles" }); + + const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => { + if (!projectId) return; + let newValues = currentProjectFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId.toString(), { [key]: newValues }); + }; + + if (!workspaceSlug || !projectId) return <>; + + // No access to cycle + if (currentProjectDetails?.cycle_view === false) + return ( +
    + { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !hasAdminLevelPermission, + }} + /> +
    + ); + + if (loader) return ; + + return ( + <> + +
    + setCreateModal(false)} + /> + {totalCycles === 0 ? ( +
    + { + setCreateModal(true); + }} + disabled={!hasMemberLevelPermission} + /> + } + /> +
    + ) : ( + <> + {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( +
    + clearAllFilters(projectId.toString())} + handleRemoveFilter={handleRemoveFilter} + /> +
    + )} + + + + )} +
    + + ); +}); + +export default ProjectCyclesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx new file mode 100644 index 00000000..72573c44 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectInboxHeader } from "@/plane-web/components/projects/settings/intake/header"; + +export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx new file mode 100644 index 00000000..1190cdae --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx @@ -0,0 +1,86 @@ +"use client"; +import { observer } from "mobx-react"; +import { useParams, useSearchParams } from "next/navigation"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EUserProjectRoles, EInboxIssueCurrentTab } from "@plane/types"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { InboxIssueRoot } from "@/components/inbox"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const ProjectInboxPage = observer(() => { + /// router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + const searchParams = useSearchParams(); + const navigationTab = searchParams.get("currentTab"); + const inboxIssueId = searchParams.get("inboxIssueId"); + // plane hooks + const { t } = useTranslation(); + // hooks + const { currentProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); + // derived values + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/intake" }); + + // No access to inbox + if (currentProjectDetails?.inbox_view === false) + return ( +
    + { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
    + ); + + // derived values + const pageTitle = currentProjectDetails?.name + ? t("inbox_issue.page_label", { + workspace: currentProjectDetails?.name, + }) + : t("inbox_issue.page_label", { + workspace: "Plane", + }); + + const currentNavigationTab = navigationTab + ? navigationTab === "open" + ? EInboxIssueCurrentTab.OPEN + : EInboxIssueCurrentTab.CLOSED + : undefined; + + if (!workspaceSlug || !projectId) return <>; + + return ( +
    + +
    + +
    +
    + ); +}); + +export default ProjectInboxPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx new file mode 100644 index 00000000..e63f7282 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +import { useTranslation } from "@plane/i18n"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { LogoSpinner } from "@/components/common/logo-spinner"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; +// assets +import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp"; +import emptyIssueLight from "@/public/empty-state/search/issues-light.webp"; +// services +import { IssueService } from "@/services/issue/issue.service"; + +const issueService = new IssueService(); + +const IssueDetailsPage = observer(() => { + const router = useAppRouter(); + const { t } = useTranslation(); + const { workspaceSlug, projectId, issueId } = useParams(); + const { resolvedTheme } = useTheme(); + + const { data, isLoading, error } = useSWR( + workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_META_${workspaceSlug}_${projectId}_${issueId}` : null, + workspaceSlug && projectId && issueId + ? () => issueService.getIssueMetaFromURL(workspaceSlug.toString(), projectId.toString(), issueId.toString()) + : null + ); + + useEffect(() => { + if (data) { + router.push(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`); + } + }, [workspaceSlug, data]); + + return ( +
    + {error ? ( + router.push(`/${workspaceSlug}/workspace-views/all-issues/`), + }} + /> + ) : isLoading ? ( + <> + + + ) : ( + <> + )} +
    + ); +}); + +export default IssueDetailsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx new file mode 100644 index 00000000..8a2e9a71 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx @@ -0,0 +1,3 @@ +import { IssuesHeader } from "@/plane-web/components/issues/header"; + +export const ProjectIssuesHeader = () => ; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx new file mode 100644 index 00000000..c2df9c4e --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectIssuesHeader } from "./header"; +import { ProjectIssuesMobileHeader } from "./mobile-header"; + +export default function ProjectIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx new file mode 100644 index 00000000..ed827135 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { ChevronDown } from "lucide-react"; +// plane imports +import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { + DisplayFiltersSelection, + FiltersDropdown, + MobileLayoutSelection, +} from "@/components/issues/issue-layouts/filters"; +// hooks +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; + +export const ProjectIssuesMobileHeader = observer(() => { + // i18n + const { t } = useTranslation(); + const [analyticsModal, setAnalyticsModal] = useState(false); + const { workspaceSlug, projectId } = useParams() as { + workspaceSlug: string; + projectId: string; + }; + const { currentProjectDetails } = useProject(); + + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT); + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); + }, + [workspaceSlug, projectId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + projectDetails={currentProjectDetails ?? undefined} + /> +
    + +
    + + {t("common.display")} + + + } + > + + +
    + + +
    + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx new file mode 100644 index 00000000..63162e10 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { observer } from "mobx-react"; +import Head from "next/head"; +import { useParams } from "next/navigation"; +// i18n +import { useTranslation } from "@plane/i18n"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ProjectLayoutRoot } from "@/components/issues/issue-layouts/roots/project-layout-root"; +// hooks +import { useProject } from "@/hooks/store/use-project"; + +const ProjectIssuesPage = observer(() => { + const { projectId } = useParams(); + // i18n + const { t } = useTranslation(); + // store + const { getProjectById } = useProject(); + + if (!projectId) { + return <>; + } + + // derived values + const project = getProjectById(projectId.toString()); + const pageTitle = project?.name ? `${project?.name} - ${t("issue.label", { count: 2 })}` : undefined; // Count is for pluralization + + return ( + <> + + + + {project?.name} - {t("issue.label", { count: 2 })} + + +
    + +
    + + ); +}); + +export default ProjectIssuesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx new file mode 100644 index 00000000..5b1fa1b1 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// components +import { cn } from "@plane/utils"; +import { EmptyState } from "@/components/common/empty-state"; +import { PageHead } from "@/components/core/page-title"; +import { ModuleLayoutRoot } from "@/components/issues/issue-layouts/roots/module-layout-root"; +import { ModuleAnalyticsSidebar } from "@/components/modules"; +// helpers +// hooks +import { useModule } from "@/hooks/store/use-module"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import useLocalStorage from "@/hooks/use-local-storage"; +// assets +import emptyModule from "@/public/empty-state/module.svg"; + +const ModuleIssuesPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, moduleId } = useParams(); + // store hooks + const { fetchModuleDetails, getModuleById } = useModule(); + const { getProjectById } = useProject(); + // const { issuesFilter } = useIssues(EIssuesStoreType.MODULE); + // local storage + const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); + const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; + // fetching module details + const { error } = useSWR( + workspaceSlug && projectId && moduleId ? `CURRENT_MODULE_DETAILS_${moduleId.toString()}` : null, + workspaceSlug && projectId && moduleId + ? () => fetchModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString()) + : null + ); + // derived values + const projectModule = moduleId ? getModuleById(moduleId.toString()) : undefined; + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name && projectModule?.name ? `${project?.name} - ${projectModule?.name}` : undefined; + + const toggleSidebar = () => { + setValue(`${!isSidebarCollapsed}`); + }; + + if (!workspaceSlug || !projectId || !moduleId) return <>; + + // const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + + return ( + <> + + {error ? ( + router.push(`/${workspaceSlug}/projects/${projectId}/modules`), + }} + /> + ) : ( +
    +
    + +
    + {moduleId && !isSidebarCollapsed && ( +
    + +
    + )} +
    + )} + + ); +}); + +export default ModuleIssuesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx new file mode 100644 index 00000000..2dec9fa9 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { ChartNoAxesColumn, PanelRight, SlidersHorizontal } from "lucide-react"; +// plane imports +import { + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + EUserPermissions, + EUserPermissionsLevel, + EProjectFeatureKey, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { ModuleIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; +import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { SwitcherLabel } from "@/components/common/switcher-label"; +import { + DisplayFiltersSelection, + FiltersDropdown, + LayoutSelection, + MobileLayoutSelection, +} from "@/components/issues/issue-layouts/filters"; +import { ModuleQuickActions } from "@/components/modules"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useModule } from "@/hooks/store/use-module"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useIssuesActions } from "@/hooks/use-issues-actions"; +import useLocalStorage from "@/hooks/use-local-storage"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const ModuleIssuesHeader: React.FC = observer(() => { + // refs + const parentRef = useRef(null); + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, moduleId: routerModuleId } = useParams(); + const moduleId = routerModuleId ? routerModuleId.toString() : undefined; + // hooks + const { isMobile } = usePlatformOS(); + // store hooks + const { + issuesFilter: { issueFilters }, + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.MODULE); + const { updateFilters } = useIssuesActions(EIssuesStoreType.MODULE); + const { projectModuleIds, getModuleById } = useModule(); + const { toggleCreateIssueModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails, loader } = useProject(); + // local storage + const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); + // derived values + const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; + const activeLayout = issueFilters?.displayFilters?.layout; + const moduleDetails = moduleId ? getModuleById(moduleId) : undefined; + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + const workItemsCount = getGroupIssueCount(undefined, undefined, false); + + const toggleSidebar = () => { + setValue(`${!isSidebarCollapsed}`); + }; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!projectId) return; + updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); + }, + [projectId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!projectId) return; + updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + }, + [projectId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!projectId) return; + updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property); + }, + [projectId, updateFilters] + ); + + const switcherOptions = projectModuleIds + ?.map((id) => { + const _module = id === moduleId ? moduleDetails : getModuleById(id); + if (!_module) return; + return { + value: _module.id, + query: _module.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + return ( + <> + setAnalyticsModal(false)} + moduleDetails={moduleDetails ?? undefined} + projectDetails={currentProjectDetails} + /> +
    + +
    + + + { + router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`); + }} + title={moduleDetails?.name} + icon={} + isLast + /> + } + /> + + {workItemsCount && workItemsCount > 0 ? ( + 1 ? "work items" : "work item" + } in this module`} + position="bottom" + > + + {workItemsCount} + + + ) : null} +
    +
    + +
    +
    + handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> +
    +
    + handleLayoutChange(layout)} + activeLayout={activeLayout} + /> +
    + {moduleId && } + } + > + + +
    + + {canUserCreateIssue ? ( + <> + + + + ) : ( + <> + )} + + {moduleId && ( + + )} +
    +
    + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx new file mode 100644 index 00000000..e976c735 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ModuleIssuesHeader } from "./header"; +import { ModuleIssuesMobileHeader } from "./mobile-header"; + +export default function ProjectModuleIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx new file mode 100644 index 00000000..77b0e2b2 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +// plane imports +import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon"; +// hooks +import { useIssues } from "@/hooks/store/use-issues"; +import { useModule } from "@/hooks/store/use-module"; +import { useProject } from "@/hooks/store/use-project"; + +const SUPPORTED_LAYOUTS = [ + { key: "list", i18n_title: "issue.layouts.list", icon: List }, + { key: "kanban", i18n_title: "issue.layouts.kanban", icon: Kanban }, + { key: "calendar", i18n_title: "issue.layouts.calendar", icon: Calendar }, +]; + +export const ModuleIssuesMobileHeader = observer(() => { + // router + const { workspaceSlug, projectId, moduleId } = useParams() as { + workspaceSlug: string; + projectId: string; + moduleId: string; + }; + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentProjectDetails } = useProject(); + const { getModuleById } = useModule(); + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.MODULE); + // derived values + const activeLayout = issueFilters?.displayFilters?.layout; + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + return ( +
    + setAnalyticsModal(false)} + moduleDetails={moduleDetails ?? undefined} + projectDetails={currentProjectDetails} + /> +
    + Layout} + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {SUPPORTED_LAYOUTS.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
    {t(layout.i18n_title)}
    +
    + ))} +
    +
    + + Display + + + } + > + + +
    + + +
    +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx new file mode 100644 index 00000000..fbb40e3b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, MODULE_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button } from "@plane/propel/button"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { ModuleViewHeader } from "@/components/modules"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +// constants + +export const ModulesListHeader: React.FC = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; + // store hooks + const { toggleCreateModuleModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + + const { loader } = useProject(); + + const { t } = useTranslation(); + + // auth + const canUserCreateModule = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + return ( +
    + +
    + + + +
    +
    + + + {canUserCreateModule ? ( + + ) : ( + <> + )} + +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx new file mode 100644 index 00000000..269cf945 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ModulesListHeader } from "./header"; +import { ModulesListMobileHeader } from "./mobile-header"; + +export default function ProjectModulesListLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx new file mode 100644 index 00000000..138867e4 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { observer } from "mobx-react"; +import { ChevronDown } from "lucide-react"; +import { MODULE_VIEW_LAYOUTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { CustomMenu, Row } from "@plane/ui"; +import { ModuleLayoutIcon } from "@/components/modules"; +import { useModuleFilter } from "@/hooks/store/use-module-filter"; +import { useProject } from "@/hooks/store/use-project"; + +export const ModulesListMobileHeader = observer(() => { + const { currentProjectDetails } = useProject(); + const { updateDisplayFilters } = useModuleFilter(); + const { t } = useTranslation(); + + return ( +
    + + Layout + + } + customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm" + closeOnSelect + > + {MODULE_VIEW_LAYOUTS.map((layout) => { + if (layout.key == "gantt") return; + return ( + { + updateDisplayFilters(currentProjectDetails!.id.toString(), { layout: layout.key }); + }} + className="flex items-center gap-2" + > + +
    {t(layout.i18n_title)}
    +
    + ); + })} +
    +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx new file mode 100644 index 00000000..daef0c65 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// types +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { TModuleFilters } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; +// components +import { calculateTotalFilters } from "@plane/utils"; +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; +// helpers +// hooks +import { useModuleFilter } from "@/hooks/store/use-module-filter"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const ProjectModulesPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // store + const { getProjectById, currentProjectDetails } = useProject(); + const { currentProjectFilters, currentProjectDisplayFilters, clearAllFilters, updateFilters, updateDisplayFilters } = + useModuleFilter(); + const { allowPermissions } = useUserPermissions(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name ? `${project?.name} - Modules` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/modules" }); + + const handleRemoveFilter = useCallback( + (key: keyof TModuleFilters, value: string | null) => { + if (!projectId) return; + let newValues = currentProjectFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId.toString(), { [key]: newValues }); + }, + [currentProjectFilters, projectId, updateFilters] + ); + + if (!workspaceSlug || !projectId) return <>; + + // No access to + if (currentProjectDetails?.module_view === false) + return ( +
    + { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
    + ); + + return ( + <> + +
    + {(calculateTotalFilters(currentProjectFilters ?? {}) !== 0 || currentProjectDisplayFilters?.favorites) && ( + clearAllFilters(`${projectId}`)} + handleRemoveFilter={handleRemoveFilter} + handleDisplayFiltersUpdate={(val) => { + if (!projectId) return; + updateDisplayFilters(projectId.toString(), val); + }} + alwaysAllowEditing + /> + )} + +
    + + ); +}); + +export default ProjectModulesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx new file mode 100644 index 00000000..4c15f2b2 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useCallback, useEffect, useMemo } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane types +import { getButtonStyling } from "@plane/propel/button"; +import type { TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types"; +import { EFileAssetType } from "@plane/types"; +// plane ui +// plane utils +import { cn } from "@plane/utils"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PageHead } from "@/components/core/page-title"; +import { IssuePeekOverview } from "@/components/issues/peek-overview"; +import type { TPageRootConfig, TPageRootHandlers } from "@/components/pages/editor/page-root"; +import { PageRoot } from "@/components/pages/editor/page-root"; +// hooks +import { useEditorConfig } from "@/hooks/editor"; +import { useEditorAsset } from "@/hooks/store/use-editor-asset"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web hooks +import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; +// services +import { ProjectPageService, ProjectPageVersionService } from "@/services/page"; +const workspaceService = new WorkspaceService(); +const projectPageService = new ProjectPageService(); +const projectPageVersionService = new ProjectPageVersionService(); + +const storeType = EPageStoreType.PROJECT; + +const PageDetailsPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, pageId } = useParams(); + // store hooks + const { createPage, fetchPageDetails } = usePageStore(storeType); + const page = usePage({ + pageId: pageId?.toString() ?? "", + storeType, + }); + const { getWorkspaceBySlug } = useWorkspace(); + const { uploadEditorAsset } = useEditorAsset(); + // derived values + const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : ""; + const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {}; + // entity search handler + const fetchEntityCallback = useCallback( + async (payload: TSearchEntityRequestPayload) => + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + }), + [projectId, workspaceSlug] + ); + // editor config + const { getEditorFileHandlers } = useEditorConfig(); + // fetch page details + const { error: pageDetailsError } = useSWR( + workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null, + workspaceSlug && projectId && pageId + ? () => fetchPageDetails(workspaceSlug?.toString(), projectId?.toString(), pageId.toString()) + : null, + { + revalidateIfStale: true, + revalidateOnFocus: true, + revalidateOnReconnect: true, + } + ); + // page root handlers + const pageRootHandlers: TPageRootHandlers = useMemo( + () => ({ + create: createPage, + fetchAllVersions: async (pageId) => { + if (!workspaceSlug || !projectId) return; + return await projectPageVersionService.fetchAllVersions(workspaceSlug.toString(), projectId.toString(), pageId); + }, + fetchDescriptionBinary: async () => { + if (!workspaceSlug || !projectId || !id) return; + return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), id); + }, + fetchEntity: fetchEntityCallback, + fetchVersionDetails: async (pageId, versionId) => { + if (!workspaceSlug || !projectId) return; + return await projectPageVersionService.fetchVersionById( + workspaceSlug.toString(), + projectId.toString(), + pageId, + versionId + ); + }, + restoreVersion: async (pageId, versionId) => { + if (!workspaceSlug || !projectId) return; + await projectPageVersionService.restoreVersion( + workspaceSlug.toString(), + projectId.toString(), + pageId, + versionId + ); + }, + getRedirectionLink: (pageId) => { + if (pageId) { + return `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`; + } else { + return `/${workspaceSlug}/projects/${projectId}/pages`; + } + }, + updateDescription: updateDescription ?? (async () => {}), + }), + [createPage, fetchEntityCallback, id, updateDescription, workspaceSlug, projectId] + ); + // page root config + const pageRootConfig: TPageRootConfig = useMemo( + () => ({ + fileHandler: getEditorFileHandlers({ + projectId: projectId?.toString() ?? "", + uploadFile: async (blockId, file) => { + const { asset_id } = await uploadEditorAsset({ + blockId, + data: { + entity_identifier: id ?? "", + entity_type: EFileAssetType.PAGE_DESCRIPTION, + }, + file, + projectId: projectId?.toString() ?? "", + workspaceSlug: workspaceSlug?.toString() ?? "", + }); + return asset_id; + }, + workspaceId, + workspaceSlug: workspaceSlug?.toString() ?? "", + }), + }), + [getEditorFileHandlers, id, uploadEditorAsset, projectId, workspaceId, workspaceSlug] + ); + + const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo( + () => ({ + documentType: "project_page", + projectId: projectId?.toString() ?? "", + workspaceSlug: workspaceSlug?.toString() ?? "", + }), + [projectId, workspaceSlug] + ); + + useEffect(() => { + if (page?.deleted_at && page?.id) { + router.push(pageRootHandlers.getRedirectionLink()); + } + }, [page?.deleted_at, page?.id, router, pageRootHandlers]); + + if ((!page || !id) && !pageDetailsError) + return ( +
    + +
    + ); + + if (pageDetailsError || !canCurrentUserAccessPage) + return ( +
    +

    Page not found

    +

    + The page you are trying to access doesn{"'"}t exist or you don{"'"}t have permission to view it. +

    + + View other Pages + +
    + ); + + if (!page || !workspaceSlug || !projectId) return null; + + return ( + <> + +
    +
    + + +
    +
    + + ); +}); + +export default PageDetailsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx new file mode 100644 index 00000000..ad6b9c12 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -0,0 +1,102 @@ +"use client"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { EProjectFeatureKey } from "@plane/constants"; +import { PageIcon } from "@plane/propel/icons"; +// types +import type { ICustomSearchSelectOption } from "@plane/types"; +// ui +import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +// components +import { getPageName } from "@plane/utils"; +import { PageAccessIcon } from "@/components/common/page-access-icon"; +import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label"; +import { PageHeaderActions } from "@/components/pages/header/actions"; +// helpers +// hooks +import { useProject } from "@/hooks/store/use-project"; +// plane web components +import { useAppRouter } from "@/hooks/use-app-router"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages"; +// plane web hooks +import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; + +export interface IPagesHeaderProps { + showButton?: boolean; +} + +const storeType = EPageStoreType.PROJECT; + +export const PageDetailsHeader = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, pageId, projectId } = useParams(); + // store hooks + const { loader } = useProject(); + const { getPageById, getCurrentProjectPageIds } = usePageStore(storeType); + const page = usePage({ + pageId: pageId?.toString() ?? "", + storeType, + }); + // derived values + const projectPageIds = getCurrentProjectPageIds(projectId?.toString()); + + const switcherOptions = projectPageIds + .map((id) => { + const _page = id === pageId ? page : getPageById(id); + if (!_page) return; + return { + value: _page.id, + query: _page.name, + content: ( +
    + + +
    + ), + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + if (!page) return null; + + return ( +
    + +
    + + + + { + router.push(`/${workspaceSlug}/projects/${projectId}/pages/${value}`); + }} + title={getPageName(page?.name)} + icon={ + + + + } + isLast + /> + } + /> + +
    +
    + + + + +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx new file mode 100644 index 00000000..a9147e0f --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx @@ -0,0 +1,27 @@ +"use client"; + +// component +import { useParams } from "next/navigation"; +import useSWR from "swr"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// plane web hooks +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; +// local components +import { PageDetailsHeader } from "./header"; + +export default function ProjectPageDetailsLayout({ children }: { children: React.ReactNode }) { + const { workspaceSlug, projectId } = useParams(); + const { fetchPagesList } = usePageStore(EPageStoreType.PROJECT); + // fetching pages list + useSWR( + workspaceSlug && projectId ? `PROJECT_PAGES_${projectId}` : null, + workspaceSlug && projectId ? () => fetchPagesList(workspaceSlug.toString(), projectId.toString()) : null + ); + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx new file mode 100644 index 00000000..4aa12716 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +// constants +import { + EPageAccess, + EProjectFeatureKey, + PROJECT_PAGE_TRACKER_EVENTS, + PROJECT_TRACKER_ELEMENTS, +} from "@plane/constants"; +// plane types +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TPage } from "@plane/types"; +// plane ui +import { Breadcrumbs, Header } from "@plane/ui"; +// helpers +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +// plane web +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +// plane web hooks +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; + +export const PagesListHeader = observer(() => { + // states + const [isCreatingPage, setIsCreatingPage] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug } = useParams(); + const searchParams = useSearchParams(); + const pageType = searchParams.get("type"); + // store hooks + const { currentProjectDetails, loader } = useProject(); + const { canCurrentUserCreatePage, createPage } = usePageStore(EPageStoreType.PROJECT); + // handle page create + const handleCreatePage = async () => { + setIsCreatingPage(true); + + const payload: Partial = { + access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC, + }; + + await createPage(payload) + .then((res) => { + captureSuccess({ + eventName: PROJECT_PAGE_TRACKER_EVENTS.create, + payload: { + id: res?.id, + state: "SUCCESS", + }, + }); + const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`; + router.push(pageId); + }) + .catch((err) => { + captureError({ + eventName: PROJECT_PAGE_TRACKER_EVENTS.create, + payload: { + state: "ERROR", + }, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.data?.error || "Page could not be created. Please try again.", + }); + }) + .finally(() => setIsCreatingPage(false)); + }; + + return ( +
    + + + + + + {canCurrentUserCreatePage ? ( + + + + ) : ( + <> + )} +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx new file mode 100644 index 00000000..6c62d42c --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +import type { ReactNode } from "react"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local components +import { PagesListHeader } from "./header"; + +export default function ProjectPagesListLayout({ children }: { children: ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx new file mode 100644 index 00000000..f5fb1f4c --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams, useSearchParams } from "next/navigation"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { TPageNavigationTabs } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { PagesListRoot } from "@/components/pages/list/root"; +import { PagesListView } from "@/components/pages/pages-list-view"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +// plane web hooks +import { EPageStoreType } from "@/plane-web/hooks/store"; + +const ProjectPagesPage = observer(() => { + // router + const router = useAppRouter(); + const searchParams = useSearchParams(); + const type = searchParams.get("type"); + const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { getProjectById, currentProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name ? `${project?.name} - Pages` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/pages" }); + + const currentPageType = (): TPageNavigationTabs => { + const pageType = type?.toString(); + if (pageType === "private") return "private"; + if (pageType === "archived") return "archived"; + return "public"; + }; + + if (!workspaceSlug || !projectId) return <>; + + // No access to cycle + if (currentProjectDetails?.page_view === false) + return ( +
    + { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
    + ); + return ( + <> + + + + + + ); +}); + +export default ProjectPagesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx new file mode 100644 index 00000000..33a84241 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Lock } from "lucide-react"; +// plane constants +import { + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + EUserPermissions, + EUserPermissionsLevel, + EProjectFeatureKey, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; +// types +import { Button } from "@plane/propel/button"; +import { ViewsIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EViewAccess, EIssueLayoutTypes } from "@plane/types"; +// ui +import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +// components +import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label"; +import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters"; +// constants +import { ViewQuickActions } from "@/components/views/quick-actions"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; +import { useProjectView } from "@/hooks/store/use-project-view"; +import { useUserPermissions } from "@/hooks/store/user"; +// plane web +import { useAppRouter } from "@/hooks/use-app-router"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const ProjectViewIssuesHeader: React.FC = observer(() => { + // refs + const parentRef = useRef(null); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, viewId: routerViewId } = useParams(); + const viewId = routerViewId ? routerViewId.toString() : undefined; + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT_VIEW); + const { toggleCreateIssueModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + + const { currentProjectDetails, loader } = useProject(); + const { projectViewIds, getViewById } = useProjectView(); + + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId || !viewId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout }, + viewId.toString() + ); + }, + [workspaceSlug, projectId, viewId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId || !viewId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + viewId.toString() + ); + }, + [workspaceSlug, projectId, viewId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId || !viewId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_PROPERTIES, + property, + viewId.toString() + ); + }, + [workspaceSlug, projectId, viewId, updateFilters] + ); + + const viewDetails = viewId ? getViewById(viewId.toString()) : null; + + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + if (!viewDetails) return; + + const switcherOptions = projectViewIds + ?.map((id) => { + const _view = id === viewId ? viewDetails : getViewById(id); + if (!_view) return; + return { + value: _view.id, + query: _view.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + return ( +
    + + + + + { + router.push(`/${workspaceSlug}/projects/${projectId}/views/${value}`); + }} + title={viewDetails?.name} + icon={ + + + + } + isLast + /> + } + /> + + + {viewDetails?.access === EViewAccess.PRIVATE ? ( +
    + + + +
    + ) : ( + <> + )} +
    + + <> + {!viewDetails.is_locked && ( + handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> + )} + {viewId && } + {!viewDetails.is_locked && ( + + + + )} + + {canUserCreateIssue ? ( + + ) : ( + <> + )} +
    + +
    +
    +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx new file mode 100644 index 00000000..a5342d81 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { PageHead } from "@/components/core/page-title"; +import { ProjectViewLayoutRoot } from "@/components/issues/issue-layouts/roots/project-view-layout-root"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useProjectView } from "@/hooks/store/use-project-view"; +// assets +import { useAppRouter } from "@/hooks/use-app-router"; +import emptyView from "@/public/empty-state/view.svg"; + +const ProjectViewIssuesPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, viewId } = useParams(); + // store hooks + const { fetchViewDetails, getViewById } = useProjectView(); + const { getProjectById } = useProject(); + // derived values + const projectView = viewId ? getViewById(viewId.toString()) : undefined; + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name && projectView?.name ? `${project?.name} - ${projectView?.name}` : undefined; + + const { error } = useSWR( + workspaceSlug && projectId && viewId ? `VIEW_DETAILS_${viewId.toString()}` : null, + workspaceSlug && projectId && viewId + ? () => fetchViewDetails(workspaceSlug.toString(), projectId.toString(), viewId.toString()) + : null + ); + + if (error) { + return ( + router.push(`/${workspaceSlug}/projects/${projectId}/views`), + }} + /> + ); + } + + return ( + <> + + + + ); +}); + +export default ProjectViewIssuesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx new file mode 100644 index 00000000..af6e57d4 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local components +import { ProjectViewIssuesHeader } from "./[viewId]/header"; + +export default function ProjectViewIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx new file mode 100644 index 00000000..1ac783eb --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// ui +import { EProjectFeatureKey, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { ViewListHeader } from "@/components/views/view-list-header"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +// plane web +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const ProjectViewsHeader = observer(() => { + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; + // store hooks + const { toggleCreateViewModal } = useCommandPalette(); + const { loader } = useProject(); + + return ( + <> +
    + + + + + + + +
    + +
    +
    +
    + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx new file mode 100644 index 00000000..d96c8256 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local components +import { ProjectViewsHeader } from "./header"; +import { ViewMobileHeader } from "./mobile-header"; + +export default function ProjectViewsListLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx new file mode 100644 index 00000000..e9ef19d1 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { observer } from "mobx-react"; +// icons +import { ChevronDown, ListFilter } from "lucide-react"; +// components +import { Row } from "@plane/ui"; +import { FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { ViewFiltersSelection } from "@/components/views/filters/filter-selection"; +import { ViewOrderByDropdown } from "@/components/views/filters/order-by"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useProjectView } from "@/hooks/store/use-project-view"; + +export const ViewMobileHeader = observer(() => { + // store hooks + const { filters, updateFilters } = useProjectView(); + const { + project: { projectMemberIds }, + } = useMember(); + + return ( + <> +
    + + { + if (val.key) updateFilters("sortKey", val.key); + if (val.order) updateFilters("sortBy", val.order); + }} + isMobile + /> + +
    + } + title="Filters" + placement="bottom-end" + isFiltersApplied={false} + menuButton={ + + Filters + + + } + > + + +
    +
    + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx new file mode 100644 index 00000000..62de1c0d --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { EViewAccess, TViewFilterProps } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; +import { Header, EHeaderVariant } from "@plane/ui"; +import { calculateTotalFilters } from "@plane/utils"; +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { ViewAppliedFiltersList } from "@/components/views/applied-filters"; +import { ProjectViewsList } from "@/components/views/views-list"; +// constants +// helpers +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useProjectView } from "@/hooks/store/use-project-view"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const ProjectViewsPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // store + const { getProjectById, currentProjectDetails } = useProject(); + const { filters, updateFilters, clearAllFilters } = useProjectView(); + const { allowPermissions } = useUserPermissions(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name ? `${project?.name} - Views` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/views" }); + + const handleRemoveFilter = useCallback( + (key: keyof TViewFilterProps, value: string | EViewAccess | null) => { + let newValues = filters.filters?.[key]; + + if (key === "favorites") { + newValues = !!value; + } + if (Array.isArray(newValues)) { + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value) as string[]; + } + + updateFilters("filters", { [key]: newValues }); + }, + [filters.filters, updateFilters] + ); + + const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0; + + if (!workspaceSlug || !projectId) return <>; + + // No access to + if (currentProjectDetails?.issue_views_view === false) + return ( +
    + { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
    + ); + + return ( + <> + + {isFiltersApplied && ( +
    + +
    + )} + + + ); +}); + +export default ProjectViewsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx new file mode 100644 index 00000000..d33616ed --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +import type { ReactNode } from "react"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local components +import { ProjectsListHeader } from "@/plane-web/components/projects/header"; +import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header"; +export default function ProjectListLayout({ children }: { children: ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx new file mode 100644 index 00000000..ac6e5c3c --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx @@ -0,0 +1,4 @@ +import { ProjectPageRoot } from "@/plane-web/components/projects/page"; + +const ProjectsPage = () => ; +export default ProjectsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx new file mode 100644 index 00000000..25cefaf5 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx @@ -0,0 +1,18 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useParams } from "next/navigation"; +// plane web layouts +import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; + +const ProjectDetailLayout = ({ children }: { children: ReactNode }) => { + // router + const { workspaceSlug, projectId } = useParams(); + return ( + + {children} + + ); +}; + +export default ProjectDetailLayout; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx new file mode 100644 index 00000000..d33616ed --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +import type { ReactNode } from "react"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local components +import { ProjectsListHeader } from "@/plane-web/components/projects/header"; +import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header"; +export default function ProjectListLayout({ children }: { children: ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx new file mode 100644 index 00000000..ac6e5c3c --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx @@ -0,0 +1,4 @@ +import { ProjectPageRoot } from "@/plane-web/components/projects/page"; + +const ProjectsPage = () => ; +export default ProjectsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx new file mode 100644 index 00000000..a30707e2 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -0,0 +1,42 @@ +import type { FC } from "react"; +import { isEmpty } from "lodash-es"; +import { observer } from "mobx-react"; +// plane helpers +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +// components +import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper"; +import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu"; +import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list"; +import { SidebarQuickActions } from "@/components/workspace/sidebar/quick-actions"; +import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items"; +// hooks +import { useFavorite } from "@/hooks/store/use-favorite"; +import { useUserPermissions } from "@/hooks/store/user"; +// plane web components +import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list"; + +export const AppSidebar: FC = observer(() => { + // store hooks + const { allowPermissions } = useUserPermissions(); + const { groupedFavorites } = useFavorite(); + + // derived values + const canPerformWorkspaceMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const isFavoriteEmpty = isEmpty(groupedFavorites); + + return ( + }> + + {/* Favorites Menu */} + {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } + {/* Teams List */} + + {/* Projects List */} + + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx new file mode 100644 index 00000000..1573e752 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx @@ -0,0 +1,44 @@ +"use client"; + +import Image from "next/image"; +import { useTheme } from "next-themes"; +// plane imports +import { HEADER_GITHUB_ICON, GITHUB_REDIRECTED_TRACKER_EVENT } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// helpers +import { captureElementAndEvent } from "@/helpers/event-tracker.helper"; +// public imports +import githubBlackImage from "@/public/logos/github-black.png"; +import githubWhiteImage from "@/public/logos/github-white.png"; + +export const StarUsOnGitHubLink = () => { + // plane hooks + const { t } = useTranslation(); + // hooks + const { resolvedTheme } = useTheme(); + const imageSrc = resolvedTheme === "dark" ? githubWhiteImage : githubBlackImage; + + return ( + + captureElementAndEvent({ + element: { + elementName: HEADER_GITHUB_ICON, + }, + event: { + eventName: GITHUB_REDIRECTED_TRACKER_EVENT, + state: "SUCCESS", + }, + }) + } + className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5" + href="https://github.com/makeplane/plane" + target="_blank" + rel="noopener noreferrer" + > + + {t("home.star_us_on_github")} + + ); +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx new file mode 100644 index 00000000..49b76da5 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { Button } from "@plane/propel/button"; +import { RecentStickyIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { StickySearch } from "@/components/stickies/modal/search"; +import { useStickyOperations } from "@/components/stickies/sticky/use-operations"; +// hooks +import { useSticky } from "@/hooks/use-stickies"; + +export const WorkspaceStickyHeader = observer(() => { + const { workspaceSlug } = useParams(); + // hooks + const { creatingSticky, toggleShowNewSticky } = useSticky(); + const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() }); + + return ( + <> +
    + +
    + + } + /> + } + /> + +
    +
    + + + + + +
    + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx new file mode 100644 index 00000000..d2abcd3f --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { WorkspaceStickyHeader } from "./header"; + +export default function WorkspaceStickiesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx new file mode 100644 index 00000000..19898592 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +// components +import { PageHead } from "@/components/core/page-title"; +import { StickiesInfinite } from "@/components/stickies/layout/stickies-infinite"; + +export default function WorkspaceStickiesPage() { + return ( + <> + +
    + +
    + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx new file mode 100644 index 00000000..9419f2b3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants"; +// components +import { PageHead } from "@/components/core/page-title"; +import { AllIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/all-issue-layout-root"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; + +const GlobalViewIssuesPage = observer(() => { + // router + const { globalViewId } = useParams(); + // store hooks + const { currentWorkspace } = useWorkspace(); + // states + const [isLoading, setIsLoading] = useState(false); + + // derived values + const defaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - All Views` : undefined; + + // handlers + const toggleLoading = (value: boolean) => setIsLoading(value); + return ( + <> + + + + ); +}); + +export default GlobalViewIssuesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx new file mode 100644 index 00000000..07f75a30 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + GLOBAL_VIEW_TRACKER_ELEMENTS, + DEFAULT_GLOBAL_VIEWS_LIST, +} from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { ViewsIcon } from "@plane/propel/icons"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, ICustomSearchSelectOption } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; +import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SwitcherLabel } from "@/components/common/switcher-label"; +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; +import { DefaultWorkspaceViewQuickActions } from "@/components/workspace/views/default-view-quick-action"; +import { CreateUpdateWorkspaceViewModal } from "@/components/workspace/views/modal"; +import { WorkspaceViewQuickActions } from "@/components/workspace/views/quick-action"; +// hooks +import { useGlobalView } from "@/hooks/store/use-global-view"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { GlobalViewLayoutSelection } from "@/plane-web/components/views/helper"; + +export const GlobalIssuesHeader = observer(() => { + // states + const [createViewModal, setCreateViewModal] = useState(false); + // router + const router = useAppRouter(); + const { workspaceSlug, globalViewId: routerGlobalViewId } = useParams(); + const globalViewId = routerGlobalViewId ? routerGlobalViewId.toString() : undefined; + // store hooks + const { + issuesFilter: { filters, updateFilters }, + } = useIssues(EIssuesStoreType.GLOBAL); + const { getViewDetailsById, currentWorkspaceViews } = useGlobalView(); + const { t } = useTranslation(); + + const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined; + + const activeLayout = issueFilters?.displayFilters?.layout; + const viewDetails = globalViewId ? getViewDetailsById(globalViewId) : undefined; + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !globalViewId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + globalViewId + ); + }, + [workspaceSlug, updateFilters, globalViewId] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !globalViewId) return; + updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.DISPLAY_PROPERTIES, property, globalViewId); + }, + [workspaceSlug, updateFilters, globalViewId] + ); + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !globalViewId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout }, + globalViewId + ); + }, + [workspaceSlug, updateFilters, globalViewId] + ); + + const isLocked = viewDetails?.is_locked; + + const isDefaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId); + + const defaultViewDetails = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId); + + const defaultOptions = DEFAULT_GLOBAL_VIEWS_LIST.map((view) => ({ + value: view.key, + query: view.key, + content: , + })); + + const workspaceOptions = (currentWorkspaceViews || []).map((view) => { + const _view = getViewDetailsById(view); + if (!_view) return; + return { + value: _view.id, + query: _view.name, + content: , + }; + }); + + const switcherOptions = [...defaultOptions, ...workspaceOptions].filter( + (option) => option !== undefined + ) as ICustomSearchSelectOption[]; + const currentLayoutFilters = useMemo(() => { + const layout = activeLayout ?? EIssueLayoutTypes.SPREADSHEET; + return ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.layoutOptions[layout]; + }, [activeLayout]); + + return ( + <> + setCreateViewModal(false)} /> +
    + + + } /> + } + /> + { + router.push(`/${workspaceSlug}/workspace-views/${value}`); + }} + title={viewDetails?.name ?? t(defaultViewDetails?.i18n_label ?? "")} + icon={ + + + + } + isLast + /> + } + isLast + /> + + + + + {!isLocked && ( + + )} + {globalViewId && } + {!isLocked && ( + + + + )} + + +
    + {viewDetails && } + {isDefaultView && defaultViewDetails && ( + + )} +
    +
    +
    + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx new file mode 100644 index 00000000..6f3dbe1b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { GlobalIssuesHeader } from "./header"; + +export default function GlobalIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx new file mode 100644 index 00000000..5a3969a2 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import { Search } from "lucide-react"; +// plane imports +import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Input } from "@plane/ui"; +// components +import { PageHead } from "@/components/core/page-title"; +import { GlobalDefaultViewListItem } from "@/components/workspace/views/default-view-list-item"; +import { GlobalViewsList } from "@/components/workspace/views/views-list"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; + +const WorkspaceViewsPage = observer(() => { + const [query, setQuery] = useState(""); + // store + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - All Views` : undefined; + + return ( + <> + +
    +
    + + setQuery(e.target.value)} + placeholder="Search" + mode="true-transparent" + /> +
    +
    + {DEFAULT_GLOBAL_VIEWS_LIST.filter((v) => t(v.i18n_label).toLowerCase().includes(query.toLowerCase())).map( + (option) => ( + + ) + )} + +
    +
    + + ); +}); + +export default WorkspaceViewsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx new file mode 100644 index 00000000..a87d4d26 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { CommandPalette } from "@/components/command-palette"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { SettingsHeader } from "@/components/settings/header"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; + +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
    +
    + {/* Header */} + + {/* Content */} + +
    {children}
    +
    +
    +
    +
    +
    + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx new file mode 100644 index 00000000..6c8950b4 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { observer } from "mobx-react"; +// component +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +// hooks +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// plane web components +import { BillingRoot } from "@/plane-web/components/workspace/billing"; + +const BillingSettingsPage = observer(() => { + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentWorkspace } = useWorkspace(); + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined; + + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } + + return ( + + + + + ); +}); + +export default BillingSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx new file mode 100644 index 00000000..b0ba8774 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { cn } from "@plane/utils"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import ExportGuide from "@/components/exporter/guide"; +// helpers +// hooks +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import SettingsHeading from "@/components/settings/heading"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; + +const ExportsPage = observer(() => { + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + + // derived values + const canPerformWorkspaceMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.exports.title")}` + : undefined; + + // if user is not authorized to view this page + if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { + return ; + } + + return ( + + +
    + + +
    +
    + ); +}); + +export default ExportsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx new file mode 100644 index 00000000..2838e222 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import IntegrationGuide from "@/components/integration/guide"; +// hooks +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; + +const ImportsPage = observer(() => { + // router + // store hooks + const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); + // derived values + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined; + + if (!isAdmin) return ; + + return ( + + +
    + + +
    +
    + ); +}); + +export default ImportsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx new file mode 100644 index 00000000..bb9d8f3d --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx @@ -0,0 +1,58 @@ +"use client"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SingleIntegrationCard } from "@/components/integration"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { IntegrationAndImportExportBanner } from "@/components/ui/integration-and-import-export-banner"; +import { IntegrationsSettingsLoader } from "@/components/ui/loader/settings/integration"; +// constants +import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// services +import { IntegrationService } from "@/services/integrations"; + +const integrationService = new IntegrationService(); + +const WorkspaceIntegrationsPage = observer(() => { + // router + const { workspaceSlug } = useParams(); + // store hooks + const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); + + // derived values + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined; + const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () => + workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null + ); + + if (!isAdmin) return ; + + return ( + + +
    + +
    + {appIntegrations ? ( + appIntegrations.map((integration) => ( + + )) + ) : ( + + )} +
    +
    +
    + ); +}); + +export default WorkspaceIntegrationsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx new file mode 100644 index 00000000..385fa654 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx @@ -0,0 +1,57 @@ +"use client"; + +import type { FC, ReactNode } from "react"; +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +// constants +import { WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; +import type { EUserWorkspaceRoles } from "@plane/types"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper"; +import { SettingsMobileNav } from "@/components/settings/mobile"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; +// local components +import { WorkspaceSettingsSidebar } from "./sidebar"; + +export interface IWorkspaceSettingLayout { + children: ReactNode; +} + +const WorkspaceSettingLayout: FC = observer((props) => { + const { children } = props; + // store hooks + const { workspaceUserInfo, getWorkspaceRoleByWorkspaceSlug } = useUserPermissions(); + // next hooks + const pathname = usePathname(); + // derived values + const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname); + const userWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug.toString()); + + let isAuthorized: boolean | string = false; + if (pathname && workspaceSlug && userWorkspaceRole) { + isAuthorized = WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles); + } + + return ( + <> + +
    + {workspaceUserInfo && !isAuthorized ? ( + + ) : ( +
    +
    {}
    +
    {children}
    +
    + )} +
    + + ); +}); + +export default WorkspaceSettingLayout; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx new file mode 100644 index 00000000..80871f19 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Search } from "lucide-react"; +// types +import { + EUserPermissions, + EUserPermissionsLevel, + MEMBER_TRACKER_ELEMENTS, + MEMBER_TRACKER_EVENTS, +} from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IWorkspaceBulkInviteFormData } from "@plane/types"; +import { cn } from "@plane/utils"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { CountChip } from "@/components/common/count-chip"; +import { PageHead } from "@/components/core/page-title"; +import { MemberListFiltersDropdown } from "@/components/project/dropdowns/filters/member-list"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { WorkspaceMembersList } from "@/components/workspace/settings/members-list"; +// helpers +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// plane web components +import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button"; +import { SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members/invite-modal"; + +const WorkspaceMembersSettingsPage = observer(() => { + // states + const [inviteModal, setInviteModal] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + // router + const { workspaceSlug } = useParams(); + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { + workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore }, + } = useMember(); + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const canPerformWorkspaceMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const handleWorkspaceInvite = (data: IWorkspaceBulkInviteFormData) => { + if (!workspaceSlug) return; + + return inviteMembersToWorkspace(workspaceSlug.toString(), data) + .then(() => { + setInviteModal(false); + captureSuccess({ + eventName: MEMBER_TRACKER_EVENTS.invite, + payload: { + emails: [...data.emails.map((email) => email.email)], + }, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: t("workspace_settings.settings.members.invitations_sent_successfully"), + }); + }) + .catch((err) => { + captureError({ + eventName: MEMBER_TRACKER_EVENTS.invite, + payload: { + emails: [...data.emails.map((email) => email.email)], + }, + error: err, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${err.error ?? t("something_went_wrong_please_try_again")}`, + }); + throw err; + }); + }; + + // Handler for role filter updates + const handleRoleFilterUpdate = (role: string) => { + const currentFilters = filtersStore.filters; + const currentRoles = currentFilters?.roles || []; + const updatedRoles = currentRoles.includes(role) ? currentRoles.filter((r) => r !== role) : [...currentRoles, role]; + + filtersStore.updateFilters({ + roles: updatedRoles.length > 0 ? updatedRoles : undefined, + }); + }; + + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined; + const appliedRoleFilters = filtersStore.filters?.roles || []; + + // if user is not authorized to view this page + if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { + return ; + } + + return ( + + + setInviteModal(false)} + onSubmit={handleWorkspaceInvite} + /> +
    +
    +

    + {t("workspace_settings.settings.members.title")} + {workspaceMemberIds && workspaceMemberIds.length > 0 && ( + + )} +

    +
    +
    + + setSearchQuery(e.target.value)} + /> +
    + + {canPerformWorkspaceAdminActions && ( + + )} + +
    +
    + +
    +
    + ); +}); + +export default WorkspaceMembersSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx new file mode 100644 index 00000000..9e7c2498 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx @@ -0,0 +1,40 @@ +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web helpers +import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; + +export const MobileWorkspaceSettingsTabs = observer(() => { + const router = useAppRouter(); + const { workspaceSlug } = useParams(); + const pathname = usePathname(); + const { t } = useTranslation(); + // mobx store + const { allowPermissions } = useUserPermissions(); + + return ( +
    + {WORKSPACE_SETTINGS_LINKS.map( + (item, index) => + shouldRenderSettingLink(workspaceSlug.toString(), item.key) && + allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && ( +
    router.push(`/${workspaceSlug}${item.href}`)} + > + {t(item.i18n_label)} +
    + ) + )} +
    + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx new file mode 100644 index 00000000..12fcdca8 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; + +const WorkspaceSettingsPage = observer(() => { + // store hooks + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + // derived values + const pageTitle = currentWorkspace?.name + ? t("workspace_settings.page_label", { workspace: currentWorkspace.name }) + : undefined; + + return ( + + + + + ); +}); + +export default WorkspaceSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx new file mode 100644 index 00000000..bda42ccc --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -0,0 +1,73 @@ +import { useParams, usePathname } from "next/navigation"; +import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react"; +import { + EUserPermissionsLevel, + GROUPED_WORKSPACE_SETTINGS, + WORKSPACE_SETTINGS_CATEGORIES, + EUserPermissions, + WORKSPACE_SETTINGS_CATEGORY, +} from "@plane/constants"; +import type { EUserWorkspaceRoles } from "@plane/types"; +import { SettingsSidebar } from "@/components/settings/sidebar"; +import { useUserPermissions } from "@/hooks/store/user"; +import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; + +const ICONS = { + general: Building, + members: Users, + export: ArrowUpToLine, + "billing-and-plans": CreditCard, + webhooks: Webhook, +}; + +export const WorkspaceActionIcons = ({ + type, + size, + className, +}: { + type: string; + size?: number; + className?: string; +}) => { + if (type === undefined) return null; + const Icon = ICONS[type as keyof typeof ICONS]; + if (!Icon) return null; + return ; +}; + +type TWorkspaceSettingsSidebarProps = { + isMobile?: boolean; +}; + +export const WorkspaceSettingsSidebar = (props: TWorkspaceSettingsSidebarProps) => { + const { isMobile = false } = props; + // router + const pathname = usePathname(); + const { workspaceSlug } = useParams(); // store hooks + const { allowPermissions } = useUserPermissions(); + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + return ( + + isAdmin || ![WORKSPACE_SETTINGS_CATEGORY.FEATURES, WORKSPACE_SETTINGS_CATEGORY.DEVELOPER].includes(category) + )} + groupedSettings={GROUPED_WORKSPACE_SETTINGS} + workspaceSlug={workspaceSlug.toString()} + isActive={(data: { href: string }) => + data.href === "/settings" + ? pathname === `/${workspaceSlug}${data.href}/` + : new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname) + } + shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) => + data.access + ? shouldRenderSettingLink(workspaceSlug.toString(), data.key) && + allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) + : false + } + actionIcons={WorkspaceActionIcons} + /> + ); +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx new file mode 100644 index 00000000..7605f227 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IWebhook } from "@plane/types"; +// ui +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks"; +// hooks +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +import { useWebhook } from "@/hooks/store/use-webhook"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; + +const WebhookDetailsPage = observer(() => { + // states + const [deleteWebhookModal, setDeleteWebhookModal] = useState(false); + // router + const { workspaceSlug, webhookId } = useParams(); + // mobx store + const { currentWebhook, fetchWebhookById, updateWebhook } = useWebhook(); + const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); + + // TODO: fix this error + // useEffect(() => { + // if (isCreated !== "true") clearSecretKey(); + // }, [clearSecretKey, isCreated]); + + // derived values + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Webhook` : undefined; + + useSWR( + workspaceSlug && webhookId && isAdmin ? `WEBHOOK_DETAILS_${workspaceSlug}_${webhookId}` : null, + workspaceSlug && webhookId && isAdmin + ? () => fetchWebhookById(workspaceSlug.toString(), webhookId.toString()) + : null + ); + + const handleUpdateWebhook = async (formData: IWebhook) => { + if (!workspaceSlug || !formData || !formData.id) return; + const payload = { + url: formData?.url, + is_active: formData?.is_active, + project: formData?.project, + cycle: formData?.cycle, + module: formData?.module, + issue: formData?.issue, + issue_comment: formData?.issue_comment, + }; + await updateWebhook(workspaceSlug.toString(), formData.id, payload) + .then(() => { + captureSuccess({ + eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated, + payload: { + webhook: formData.id, + }, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Webhook updated successfully.", + }); + }) + .catch((error) => { + captureError({ + eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated, + payload: { + webhook: formData.id, + }, + error: error as Error, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: error?.error ?? "Something went wrong. Please try again.", + }); + }); + }; + + if (!isAdmin) + return ( + <> + +
    +

    You are not authorized to access this page.

    +
    + + ); + + if (!currentWebhook) + return ( +
    + +
    + ); + + return ( + + + setDeleteWebhookModal(false)} /> +
    +
    + await handleUpdateWebhook(data)} data={currentWebhook} /> +
    + {currentWebhook && setDeleteWebhookModal(true)} />} +
    +
    + ); +}); + +export default WebhookDetailsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx new file mode 100644 index 00000000..06be0fd8 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { WebhookSettingsLoader } from "@/components/ui/loader/settings/web-hook"; +import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; +// hooks +import { captureClick } from "@/helpers/event-tracker.helper"; +import { useWebhook } from "@/hooks/store/use-webhook"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const WebhooksListPage = observer(() => { + // states + const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false); + // router + const { workspaceSlug } = useParams(); + // plane hooks + const { t } = useTranslation(); + // mobx store + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { fetchWebhooks, webhooks, clearSecretKey, webhookSecretKey, createWebhook } = useWebhook(); + const { currentWorkspace } = useWorkspace(); + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/webhooks" }); + + useSWR( + workspaceSlug && canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null, + workspaceSlug && canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug.toString()) : null + ); + + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.webhooks.title")}` + : undefined; + + // clear secret key when modal is closed. + useEffect(() => { + if (!showCreateWebhookModal && webhookSecretKey) clearSecretKey(); + }, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]); + + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } + + if (!webhooks) return ; + + return ( + + +
    + { + setShowCreateWebhookModal(false); + }} + /> + { + captureClick({ + elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_WEBHOOK_BUTTON, + }); + setShowCreateWebhookModal(true); + }, + }} + /> + {Object.keys(webhooks).length > 0 ? ( +
    + +
    + ) : ( +
    +
    + { + captureClick({ + elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_WEBHOOK_BUTTON, + }); + setShowCreateWebhookModal(true); + }, + }} + /> +
    +
    + )} +
    +
    + ); +}); + +export default WebhooksListPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx new file mode 100644 index 00000000..799c29e1 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button } from "@plane/propel/button"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list"; +// hooks +import { SettingsHeading } from "@/components/settings/heading"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const PER_PAGE = 100; + +const ProfileActivityPage = observer(() => { + // states + const [pageCount, setPageCount] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [resultsCount, setResultsCount] = useState(0); + const [isEmpty, setIsEmpty] = useState(false); + // plane hooks + const { t } = useTranslation(); + // derived values + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/profile/activity" }); + + const updateTotalPages = (count: number) => setTotalPages(count); + + const updateResultsCount = (count: number) => setResultsCount(count); + + const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty); + + const handleLoadMore = () => setPageCount((prev) => prev + 1); + + const activityPages: React.ReactNode[] = []; + for (let i = 0; i < pageCount; i++) + activityPages.push( + + ); + + const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; + + if (isEmpty) { + return ( +
    + + +
    + ); + } + + return ( + <> + + +
    {activityPages}
    + {isLoadMoreVisible && ( +
    + +
    + )} + + ); +}); + +export default ProfileActivityPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx new file mode 100644 index 00000000..d37711e3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane imports +import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// component +import { APITokenService } from "@plane/services"; +import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal"; +import { ApiTokenListItem } from "@/components/api-token/token-list-item"; +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { SettingsHeading } from "@/components/settings/heading"; +import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token"; +import { API_TOKENS_LIST } from "@/constants/fetch-keys"; +// store hooks +import { captureClick } from "@/helpers/event-tracker.helper"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const apiTokenService = new APITokenService(); + +const ApiTokensPage = observer(() => { + // states + const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); + // router + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentWorkspace } = useWorkspace(); + // derived values + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/api-tokens" }); + + const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list()); + + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}` + : undefined; + + if (!tokens) { + return ; + } + + return ( +
    + + setIsCreateTokenModalOpen(false)} /> +
    + {tokens.length > 0 ? ( + <> + { + captureClick({ + elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON, + }); + setIsCreateTokenModalOpen(true); + }, + }} + /> +
    + {tokens.map((token) => ( + + ))} +
    + + ) : ( +
    + { + captureClick({ + elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON, + }); + setIsCreateTokenModalOpen(true); + }, + }} + /> +
    + { + captureClick({ + elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_PAT_BUTTON, + }); + setIsCreateTokenModalOpen(true); + }, + }} + /> +
    +
    + )} +
    +
    + ); +}); + +export default ApiTokensPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx new file mode 100644 index 00000000..61f227f4 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { ReactNode } from "react"; +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +// components +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { getProfileActivePath } from "@/components/settings/helper"; +import { SettingsMobileNav } from "@/components/settings/mobile"; +// local imports +import { ProfileSidebar } from "./sidebar"; + +type Props = { + children: ReactNode; +}; + +const ProfileSettingsLayout = observer((props: Props) => { + const { children } = props; + // router + const pathname = usePathname(); + + return ( + <> + +
    +
    + +
    +
    + {children} +
    +
    + + ); +}); + +export default ProfileSettingsLayout; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx new file mode 100644 index 00000000..5b0d43ab --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import useSWR from "swr"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { PageHead } from "@/components/core/page-title"; +import { EmailNotificationForm } from "@/components/profile/notification/email-notification-form"; +import { SettingsHeading } from "@/components/settings/heading"; +import { EmailSettingsLoader } from "@/components/ui/loader/settings/email"; +// services +import { UserService } from "@/services/user.service"; + +const userService = new UserService(); + +export default function ProfileNotificationPage() { + const { t } = useTranslation(); + // fetching user email notification settings + const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => + userService.currentUserEmailNotificationSettings() + ); + + if (!data || isLoading) { + return ; + } + + return ( + <> + + + + + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx new file mode 100644 index 00000000..23811933 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PageHead } from "@/components/core/page-title"; +import { ProfileForm } from "@/components/profile/form"; +// hooks +import { useUser } from "@/hooks/store/user"; + +const ProfileSettingsPage = observer(() => { + const { t } = useTranslation(); + // store hooks + const { data: currentUser, userProfile } = useUser(); + + if (!currentUser) + return ( +
    + +
    + ); + + return ( + <> + + + + ); +}); + +export default ProfileSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx new file mode 100644 index 00000000..5032ae1b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PageHead } from "@/components/core/page-title"; +import { PreferencesList } from "@/components/preferences/list"; +import { LanguageTimezone } from "@/components/profile/preferences/language-timezone"; +import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; +import { SettingsHeading } from "@/components/settings/heading"; +// hooks +import { useUserProfile } from "@/hooks/store/user"; + +const ProfileAppearancePage = observer(() => { + const { t } = useTranslation(); + // hooks + const { data: userProfile } = useUserProfile(); + + return ( + <> + + {userProfile ? ( + <> +
    +
    + + +
    +
    + + +
    +
    + + ) : ( +
    + +
    + )} + + ); +}); + +export default ProfileAppearancePage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx new file mode 100644 index 00000000..b4815a69 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { Eye, EyeOff } from "lucide-react"; +// plane imports +import { E_PASSWORD_STRENGTH } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Input, PasswordStrengthIndicator } from "@plane/ui"; +import { getPasswordStrength } from "@plane/utils"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; +// helpers +import { authErrorHandler } from "@/helpers/authentication.helper"; +import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; +// hooks +import { useUser } from "@/hooks/store/user"; +// services +import { AuthService } from "@/services/auth.service"; + +export interface FormValues { + old_password: string; + new_password: string; + confirm_password: string; +} + +const defaultValues: FormValues = { + old_password: "", + new_password: "", + confirm_password: "", +}; + +const authService = new AuthService(); + +const defaultShowPassword = { + oldPassword: false, + password: false, + confirmPassword: false, +}; + +const SecurityPage = observer(() => { + // store + const { data: currentUser, changePassword } = useUser(); + // states + const [showPassword, setShowPassword] = useState(defaultShowPassword); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + + // use form + const { + control, + handleSubmit, + watch, + formState: { errors, isSubmitting }, + reset, + } = useForm({ defaultValues }); + // derived values + const oldPassword = watch("old_password"); + const password = watch("new_password"); + const confirmPassword = watch("confirm_password"); + const oldPasswordRequired = !currentUser?.is_password_autoset; + // i18n + const { t } = useTranslation(); + + const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword; + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleChangePassword = async (formData: FormValues) => { + const { old_password, new_password } = formData; + try { + const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token); + if (!csrfToken) throw new Error("csrf token not found"); + + await changePassword(csrfToken, { + ...(oldPasswordRequired && { old_password }), + new_password, + }); + + reset(defaultValues); + setShowPassword(defaultShowPassword); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("auth.common.password.toast.change_password.success.title"), + message: t("auth.common.password.toast.change_password.success.message"), + }); + } catch (error: unknown) { + let errorInfo = undefined; + if (error instanceof Error) { + const err = error as Error & { error_code?: string }; + const code = err.error_code?.toString(); + errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined; + } + + setToast({ + type: TOAST_TYPE.ERROR, + title: errorInfo?.title ?? t("auth.common.password.toast.error.title"), + message: + typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"), + }); + } + }; + + const isButtonDisabled = + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID || + (oldPasswordRequired && oldPassword.trim() === "") || + password.trim() === "" || + confirmPassword.trim() === "" || + password !== confirmPassword || + password === oldPassword; + + const passwordSupport = password.length > 0 && + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( + + ); + + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + return ( + <> + + +
    +
    + {oldPasswordRequired && ( +
    +

    {t("auth.common.password.current_password.label")}

    +
    + ( + + )} + /> + {showPassword?.oldPassword ? ( + handleShowPassword("oldPassword")} + /> + ) : ( + handleShowPassword("oldPassword")} + /> + )} +
    + {errors.old_password && {errors.old_password.message}} +
    + )} +
    +

    {t("auth.common.password.new_password.label")}

    +
    + ( + setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + /> + )} + /> + {showPassword?.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
    + {passwordSupport} + {isNewPasswordSameAsOldPassword && !isPasswordInputFocused && ( + {t("new_password_must_be_different_from_old_password")} + )} +
    +
    +

    {t("auth.common.password.confirm_password.label")}

    +
    + ( + setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + )} + /> + {showPassword?.confirmPassword ? ( + handleShowPassword("confirmPassword")} + /> + ) : ( + handleShowPassword("confirmPassword")} + /> + )} +
    + {!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )} +
    +
    + +
    + +
    +
    + + ); +}); + +export default SecurityPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx new file mode 100644 index 00000000..0dd30509 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx @@ -0,0 +1,75 @@ +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks, Lock } from "lucide-react"; +// plane imports +import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants"; +import { getFileURL } from "@plane/utils"; +// components +import { SettingsSidebar } from "@/components/settings/sidebar"; +// hooks +import { useUser } from "@/hooks/store/user"; + +const ICONS = { + profile: CircleUser, + security: Lock, + activity: Activity, + preferences: Settings2, + notifications: Bell, + "api-tokens": KeyRound, + connections: Blocks, +}; + +export const ProjectActionIcons = ({ type, size, className }: { type: string; size?: number; className?: string }) => { + if (type === undefined) return null; + const Icon = ICONS[type as keyof typeof ICONS]; + if (!Icon) return null; + return ; +}; + +type TProfileSidebarProps = { + isMobile?: boolean; +}; + +export const ProfileSidebar = observer((props: TProfileSidebarProps) => { + const { isMobile = false } = props; + // router + const pathname = usePathname(); + const { workspaceSlug } = useParams(); + // store hooks + const { data: currentUser } = useUser(); + + return ( + pathname === `/${workspaceSlug}${data.href}/`} + customHeader={ +
    +
    + {!currentUser?.avatar_url || currentUser?.avatar_url === "" ? ( +
    + +
    + ) : ( +
    + {currentUser?.display_name} +
    + )} +
    +
    +
    {currentUser?.display_name}
    +
    {currentUser?.email}
    +
    +
    + } + actionIcons={ProjectActionIcons} + shouldRender + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx new file mode 100644 index 00000000..0d9de2f2 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IProject } from "@plane/types"; +// ui +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; +import { PageHead } from "@/components/core/page-title"; +// hooks +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// plane web imports +import { CustomAutomationsRoot } from "@/plane-web/components/automations/root"; + +const AutomationSettingsPage = observer(() => { + // router + const { workspaceSlug: workspaceSlugParam, projectId: projectIdParam } = useParams(); + const workspaceSlug = workspaceSlugParam?.toString(); + const projectId = projectIdParam?.toString(); + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails: projectDetails, updateProject } = useProject(); + + const { t } = useTranslation(); + + // derived values + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + const handleChange = async (formData: Partial) => { + if (!workspaceSlug || !projectId || !projectDetails) return; + + await updateProject(workspaceSlug.toString(), projectId.toString(), formData).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Something went wrong. Please try again.", + }); + }); + }; + + // derived values + const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined; + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + + +
    + + + +
    + +
    + ); +}); + +export default AutomationSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx new file mode 100644 index 00000000..bc448aed --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { EstimateRoot } from "@/components/estimates"; +// hooks +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; + +const EstimatesSettingsPage = observer(() => { + const { workspaceSlug, projectId } = useParams(); + // store + const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + + // derived values + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (!workspaceSlug || !projectId) return <>; + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + + +
    + +
    +
    + ); +}); + +export default EstimatesSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx new file mode 100644 index 00000000..730177e1 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { ProjectFeaturesList } from "@/plane-web/components/projects/settings/features-list"; + +const FeaturesSettingsPage = observer(() => { + const { workspaceSlug, projectId } = useParams(); + // store + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + + const { currentProjectDetails } = useProject(); + // derived values + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (!workspaceSlug || !projectId) return null; + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + + +
    + +
    +
    + ); +}); + +export default FeaturesSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx new file mode 100644 index 00000000..7579d6b5 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; +import { observer } from "mobx-react"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { ProjectSettingsLabelList } from "@/components/labels"; +// hooks +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; + +const LabelsSettingsPage = observer(() => { + // store hooks + const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined; + + const scrollableContainerRef = useRef(null); + + // derived values + const canPerformProjectMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + // Enable Auto Scroll for Labels list + useEffect(() => { + const element = scrollableContainerRef.current; + + if (!element) return; + + return combine( + autoScrollForElements({ + element, + }) + ); + }, []); + + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } + + return ( + + +
    + +
    +
    + ); +}); + +export default LabelsSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx new file mode 100644 index 00000000..545903ea --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +// hooks +import { ProjectMemberList } from "@/components/project/member-list"; +import { ProjectSettingsMemberDefaults } from "@/components/project/project-settings-member-defaults"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// plane web imports +import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list"; +import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings"; + +const MembersSettingsPage = observer(() => { + // router + const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + // derived values + const projectId = routerProjectId?.toString(); + const workspaceSlug = routerWorkspaceSlug?.toString(); + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; + const isProjectMemberOrAdmin = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin; + + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } + + return ( + + + + + + + + ); +}); + +export default MembersSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx new file mode 100644 index 00000000..00899909 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DeleteProjectModal } from "@/components/project/delete-project-modal"; +import { ProjectDetailsForm } from "@/components/project/form"; +import { ProjectDetailsFormLoader } from "@/components/project/form-loader"; +import { ArchiveRestoreProjectModal } from "@/components/project/settings/archive-project/archive-restore-modal"; +import { ArchiveProjectSelection } from "@/components/project/settings/archive-project/selection"; +import { DeleteProjectSection } from "@/components/project/settings/delete-project-section"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; + +const ProjectSettingsPage = observer(() => { + // states + const [selectProject, setSelectedProject] = useState(null); + const [archiveProject, setArchiveProject] = useState(false); + // router + const { workspaceSlug, projectId } = useParams(); + // store hooks + const { currentProjectDetails, fetchProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); + + // api call to fetch project details + // TODO: removed this API if not necessary + const { isLoading } = useSWR( + workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null, + workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()) : null + ); + // derived values + const isAdmin = allowPermissions( + [EUserPermissions.ADMIN], + EUserPermissionsLevel.PROJECT, + workspaceSlug.toString(), + projectId.toString() + ); + + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined; + + return ( + + + {currentProjectDetails && workspaceSlug && projectId && ( + <> + setArchiveProject(false)} + archive + /> + setSelectedProject(null)} + /> + + )} + +
    + {currentProjectDetails && workspaceSlug && projectId && !isLoading ? ( + + ) : ( + + )} + + {isAdmin && currentProjectDetails && ( + <> + setArchiveProject(true)} + /> + setSelectedProject(currentProjectDetails.id ?? null)} + /> + + )} +
    +
    + ); +}); + +export default ProjectSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx new file mode 100644 index 00000000..338319e4 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { ProjectStateRoot } from "@/components/project-states"; +// hook +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; + +const StatesSettingsPage = observer(() => { + const { workspaceSlug, projectId } = useParams(); + // store + const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + + const { t } = useTranslation(); + + // derived values + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined; + // derived values + const canPerformProjectMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } + + return ( + + +
    + + {workspaceSlug && projectId && ( + + )} +
    +
    + ); +}); + +export default StatesSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx new file mode 100644 index 00000000..e82348c6 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +// components +import { getProjectActivePath } from "@/components/settings/helper"; +import { SettingsMobileNav } from "@/components/settings/mobile"; +import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; + +type Props = { + children: ReactNode; +}; + +const ProjectSettingsLayout = observer((props: Props) => { + const { children } = props; + // router + const router = useAppRouter(); + const pathname = usePathname(); + const { workspaceSlug, projectId } = useParams(); + const { joinedProjectIds } = useProject(); + + useEffect(() => { + if (projectId) return; + if (joinedProjectIds.length > 0) { + router.push(`/${workspaceSlug}/settings/projects/${joinedProjectIds[0]}`); + } + }, [joinedProjectIds, router, workspaceSlug, projectId]); + + return ( + <> + + +
    +
    {projectId && }
    +
    {children}
    +
    +
    + + ); +}); + +export default ProjectSettingsLayout; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx new file mode 100644 index 00000000..2812d278 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx @@ -0,0 +1,43 @@ +"use client"; +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { cn } from "@plane/utils"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; + +const ProjectSettingsPage = () => { + // store hooks + const { resolvedTheme } = useTheme(); + const { toggleCreateProjectModal } = useCommandPalette(); + // derived values + const resolvedPath = + resolvedTheme === "dark" + ? "/empty-state/project-settings/no-projects-dark.png" + : "/empty-state/project-settings/no-projects-light.png"; + return ( +
    + No projects yet +
    No projects yet
    +
    + Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you + need to get things done. +
    +
    + + Learn more about projects + + +
    +
    + ); +}; + +export default ProjectSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/layout.tsx new file mode 100644 index 00000000..ed16556a --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/layout.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { AppRailProvider } from "@/hooks/context/app-rail-context"; +import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper"; + +export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/(all)/accounts/forgot-password/layout.tsx b/apps/web/app/(all)/accounts/forgot-password/layout.tsx new file mode 100644 index 00000000..eb743954 --- /dev/null +++ b/apps/web/app/(all)/accounts/forgot-password/layout.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Forgot Password - Plane", +}; + +export default function ForgotPasswordLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(all)/accounts/forgot-password/page.tsx b/apps/web/app/(all)/accounts/forgot-password/page.tsx new file mode 100644 index 00000000..c49e2174 --- /dev/null +++ b/apps/web/app/(all)/accounts/forgot-password/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { ForgotPasswordForm } from "@/components/account/auth-forms/forgot-password"; +import { AuthHeader } from "@/components/auth-screens/header"; +// helpers +import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +const ForgotPasswordPage = observer(() => ( + + +
    + + +
    +
    +
    +)); + +export default ForgotPasswordPage; diff --git a/apps/web/app/(all)/accounts/reset-password/layout.tsx b/apps/web/app/(all)/accounts/reset-password/layout.tsx new file mode 100644 index 00000000..54488aa3 --- /dev/null +++ b/apps/web/app/(all)/accounts/reset-password/layout.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Reset Password - Plane", +}; + +export default function ResetPasswordLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(all)/accounts/reset-password/page.tsx b/apps/web/app/(all)/accounts/reset-password/page.tsx new file mode 100644 index 00000000..93cc0f83 --- /dev/null +++ b/apps/web/app/(all)/accounts/reset-password/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +// plane imports +import { EAuthModes } from "@plane/constants"; +// components +import { ResetPasswordForm } from "@/components/account/auth-forms/reset-password"; +import { AuthHeader } from "@/components/auth-screens/header"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +const ResetPasswordPage = () => ( + + +
    + + +
    +
    +
    +); + +export default ResetPasswordPage; diff --git a/apps/web/app/(all)/accounts/set-password/layout.tsx b/apps/web/app/(all)/accounts/set-password/layout.tsx new file mode 100644 index 00000000..89bf9748 --- /dev/null +++ b/apps/web/app/(all)/accounts/set-password/layout.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Set Password - Plane", +}; + +export default function SetPasswordLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(all)/accounts/set-password/page.tsx b/apps/web/app/(all)/accounts/set-password/page.tsx new file mode 100644 index 00000000..3f9a07bf --- /dev/null +++ b/apps/web/app/(all)/accounts/set-password/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +// plane imports +import { EAuthModes } from "@plane/constants"; +// components +import { ResetPasswordForm } from "@/components/account/auth-forms/reset-password"; +import { AuthHeader } from "@/components/auth-screens/header"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +const SetPasswordPage = () => ( + + +
    + + +
    +
    +
    +); + +export default SetPasswordPage; diff --git a/apps/web/app/(all)/create-workspace/layout.tsx b/apps/web/app/(all)/create-workspace/layout.tsx new file mode 100644 index 00000000..991c9c75 --- /dev/null +++ b/apps/web/app/(all)/create-workspace/layout.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Create Workspace", +}; + +export default function CreateWorkspaceLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(all)/create-workspace/page.tsx b/apps/web/app/(all)/create-workspace/page.tsx new file mode 100644 index 00000000..db497729 --- /dev/null +++ b/apps/web/app/(all)/create-workspace/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import Link from "next/link"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { PlaneLogo } from "@plane/propel/icons"; +import type { IWorkspace } from "@plane/types"; +// components +import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form"; +// hooks +import { useUser, useUserProfile } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +// plane web helpers +import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; +// images +import WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png"; + +const CreateWorkspacePage = observer(() => { + const { t } = useTranslation(); + // router + const router = useAppRouter(); + // store hooks + const { data: currentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); + // states + const [defaultValues, setDefaultValues] = useState>({ + name: "", + slug: "", + organization_size: "", + }); + // derived values + const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled(); + + // methods + const getMailtoHref = () => { + const subject = t("workspace_creation.request_email.subject"); + const body = t("workspace_creation.request_email.body", { + firstName: currentUser?.first_name || "", + lastName: currentUser?.last_name || "", + email: currentUser?.email || "", + }); + + return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + }; + + const onSubmit = async (workspace: IWorkspace) => { + await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); + }; + + return ( + +
    +
    +
    + + + +
    + {currentUser?.email} +
    +
    +
    + {isWorkspaceCreationDisabled ? ( +
    + Workspace creation disabled +
    + {t("workspace_creation.errors.creation_disabled.title")} +
    +

    + {t("workspace_creation.errors.creation_disabled.description")} +

    +
    + + + {t("workspace_creation.errors.creation_disabled.request_button")} + +
    +
    + ) : ( +
    +

    {t("workspace_creation.heading")}

    +
    + +
    +
    + )} +
    +
    + + ); +}); + +export default CreateWorkspacePage; diff --git a/apps/web/app/(all)/installations/[provider]/layout.tsx b/apps/web/app/(all)/installations/[provider]/layout.tsx new file mode 100644 index 00000000..51978de9 --- /dev/null +++ b/apps/web/app/(all)/installations/[provider]/layout.tsx @@ -0,0 +1,3 @@ +export default function InstallationProviderLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(all)/installations/[provider]/page.tsx b/apps/web/app/(all)/installations/[provider]/page.tsx new file mode 100644 index 00000000..03d224f5 --- /dev/null +++ b/apps/web/app/(all)/installations/[provider]/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React, { useEffect } from "react"; +import { useParams, useSearchParams } from "next/navigation"; +// ui +import { LogoSpinner } from "@/components/common/logo-spinner"; +// services +import { AppInstallationService } from "@/services/app_installation.service"; + +// services +const appInstallationService = new AppInstallationService(); + +export default function AppPostInstallation() { + // params + const { provider } = useParams(); + // query params + const searchParams = useSearchParams(); + const installation_id = searchParams.get("installation_id"); + const state = searchParams.get("state"); + const code = searchParams.get("code"); + + useEffect(() => { + if (provider === "github" && state && installation_id) { + appInstallationService + .addInstallationApp(state.toString(), provider, { installation_id }) + .then(() => { + window.opener = null; + window.open("", "_self"); + window.close(); + }) + .catch((err) => { + console.log(err); + }); + } else if (provider === "slack" && state && code) { + const [workspaceSlug, projectId, integrationId] = state.toString().split(","); + + if (!projectId) { + const payload = { + code, + }; + appInstallationService + .addInstallationApp(state.toString(), provider, payload) + .then(() => { + window.opener = null; + window.open("", "_self"); + window.close(); + }) + .catch((err) => { + throw err?.response; + }); + } else { + const payload = { + code, + }; + appInstallationService + .addSlackChannel(workspaceSlug, projectId, integrationId, payload) + .then(() => { + window.opener = null; + window.open("", "_self"); + window.close(); + }) + .catch((err) => { + throw err?.response; + }); + } + } + }, [state, installation_id, provider, code]); + + return ( +
    +

    Installing. Please wait...

    + +
    + ); +} diff --git a/apps/web/app/(all)/invitations/layout.tsx b/apps/web/app/(all)/invitations/layout.tsx new file mode 100644 index 00000000..0f05c344 --- /dev/null +++ b/apps/web/app/(all)/invitations/layout.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Invitations", +}; + +export default function InvitationsLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(all)/invitations/page.tsx b/apps/web/app/(all)/invitations/page.tsx new file mode 100644 index 00000000..080322ba --- /dev/null +++ b/apps/web/app/(all)/invitations/page.tsx @@ -0,0 +1,225 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; + +import useSWR, { mutate } from "swr"; +import { CheckCircle2 } from "lucide-react"; +// plane imports +import { ROLE, MEMBER_TRACKER_EVENTS, MEMBER_TRACKER_ELEMENTS, GROUP_WORKSPACE_TRACKER_EVENT } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// types +import { Button } from "@plane/propel/button"; +import { PlaneLogo } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IWorkspaceMemberInvitation } from "@plane/types"; +import { truncateText } from "@plane/utils"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { WorkspaceLogo } from "@/components/workspace/logo"; +import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; +// helpers +// hooks +import { captureError, captureSuccess, joinEventGroup } from "@/helpers/event-tracker.helper"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUser, useUserProfile } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// services +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; +// images +import emptyInvitation from "@/public/empty-state/invitation.svg"; + +const workspaceService = new WorkspaceService(); + +const UserInvitationsPage = observer(() => { + // states + const [invitationsRespond, setInvitationsRespond] = useState([]); + const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); + // router + const router = useAppRouter(); + // store hooks + const { t } = useTranslation(); + const { data: currentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); + + const { fetchWorkspaces } = useWorkspace(); + + const { data: invitations } = useSWR("USER_WORKSPACE_INVITATIONS", () => workspaceService.userWorkspaceInvitations()); + + const redirectWorkspaceSlug = + // currentUserSettings?.workspace?.last_workspace_slug || + // currentUserSettings?.workspace?.fallback_workspace_slug || + ""; + + const handleInvitation = (workspace_invitation: IWorkspaceMemberInvitation, action: "accepted" | "withdraw") => { + if (action === "accepted") { + setInvitationsRespond((prevData) => [...prevData, workspace_invitation.id]); + } else if (action === "withdraw") { + setInvitationsRespond((prevData) => prevData.filter((item: string) => item !== workspace_invitation.id)); + } + }; + + const submitInvitations = () => { + if (invitationsRespond.length === 0) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("please_select_at_least_one_invitation"), + }); + return; + } + + setIsJoiningWorkspaces(true); + + workspaceService + .joinWorkspaces({ invitations: invitationsRespond }) + .then(() => { + mutate(USER_WORKSPACES_LIST); + const firstInviteId = invitationsRespond[0]; + const invitation = invitations?.find((i) => i.id === firstInviteId); + const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace; + if (redirectWorkspace?.id) { + joinEventGroup(GROUP_WORKSPACE_TRACKER_EVENT, redirectWorkspace?.id, { + date: new Date().toDateString(), + workspace_id: redirectWorkspace?.id, + }); + } + captureSuccess({ + eventName: MEMBER_TRACKER_EVENTS.accept, + payload: { + member_id: invitation?.id, + }, + }); + updateUserProfile({ last_workspace_id: redirectWorkspace?.id }) + .then(() => { + setIsJoiningWorkspaces(false); + fetchWorkspaces().then(() => { + router.push(`/${redirectWorkspace?.slug}`); + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("something_went_wrong_please_try_again"), + }); + setIsJoiningWorkspaces(false); + }); + }) + .catch((err) => { + captureError({ + eventName: MEMBER_TRACKER_EVENTS.accept, + payload: { + member_id: invitationsRespond?.[0], + }, + error: err, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("something_went_wrong_please_try_again"), + }); + setIsJoiningWorkspaces(false); + }); + }; + + return ( + +
    +
    +
    + + + +
    + {currentUser?.email} +
    +
    + {invitations ? ( + invitations.length > 0 ? ( +
    +
    +
    {t("we_see_that_someone_has_invited_you_to_join_a_workspace")}
    +

    {t("join_a_workspace")}

    +
    + {invitations.map((invitation) => { + const isSelected = invitationsRespond.includes(invitation.id); + + return ( +
    handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} + > +
    + +
    +
    +
    {truncateText(invitation.workspace.name, 30)}
    +

    {ROLE[invitation.role]}

    +
    + + + +
    + ); + })} +
    +
    + + + + + + +
    +
    +
    + ) : ( +
    + router.push("/"), + }} + /> +
    + ) + ) : null} +
    + + ); +}); + +export default UserInvitationsPage; diff --git a/apps/web/app/(all)/layout.preload.tsx b/apps/web/app/(all)/layout.preload.tsx new file mode 100644 index 00000000..fb72b72a --- /dev/null +++ b/apps/web/app/(all)/layout.preload.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import ReactDOM from "react-dom"; + +// https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload +export const usePreloadResources = () => { + useEffect(() => { + const preloadItem = (url: string) => { + ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" }); + }; + + const urls = [ + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/instances/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/profile/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/settings/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`, + ]; + + urls.forEach((url) => preloadItem(url)); + }, []); +}; + +export const PreloadResources = () => { + usePreloadResources(); + return null; +}; diff --git a/apps/web/app/(all)/layout.tsx b/apps/web/app/(all)/layout.tsx new file mode 100644 index 00000000..2775b1b3 --- /dev/null +++ b/apps/web/app/(all)/layout.tsx @@ -0,0 +1,31 @@ +import type { Metadata, Viewport } from "next"; + +import { PreloadResources } from "./layout.preload"; + +// styles +import "@/styles/command-pallette.css"; +import "@/styles/emoji.css"; +import "@plane/propel/styles/react-day-picker"; + +export const metadata: Metadata = { + robots: { + index: false, + follow: false, + }, +}; + +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + width: "device-width", + viewportFit: "cover", +}; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/apps/web/app/(all)/onboarding/layout.tsx b/apps/web/app/(all)/onboarding/layout.tsx new file mode 100644 index 00000000..cad1f92c --- /dev/null +++ b/apps/web/app/(all)/onboarding/layout.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Onboarding", +}; + +export default function OnboardingLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(all)/onboarding/page.tsx b/apps/web/app/(all)/onboarding/page.tsx new file mode 100644 index 00000000..14ef5881 --- /dev/null +++ b/apps/web/app/(all)/onboarding/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { observer } from "mobx-react"; +import useSWR from "swr"; + +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { OnboardingRoot } from "@/components/onboarding"; +// constants +import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUser } from "@/hooks/store/user"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +// services +import { WorkspaceService } from "@/plane-web/services"; + +const workspaceService = new WorkspaceService(); + +const OnboardingPage = observer(() => { + // store hooks + const { data: user } = useUser(); + const { fetchWorkspaces } = useWorkspace(); + + // fetching workspaces list + useSWR(USER_WORKSPACES_LIST, () => { + if (user?.id) { + fetchWorkspaces(); + } + }); + + // fetching user workspace invitations + const { isLoading: invitationsLoader, data: invitations } = useSWR( + `USER_WORKSPACE_INVITATIONS_LIST_${user?.id}`, + () => { + if (user?.id) return workspaceService.userWorkspaceInvitations(); + } + ); + + return ( + +
    +
    +
    + {user && !invitationsLoader ? ( + + ) : ( +
    + +
    + )} +
    +
    +
    +
    + ); +}); + +export default OnboardingPage; diff --git a/apps/web/app/(all)/profile/activity/page.tsx b/apps/web/app/(all)/profile/activity/page.tsx new file mode 100644 index 00000000..d978b9a0 --- /dev/null +++ b/apps/web/app/(all)/profile/activity/page.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list"; +import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; +import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; +// hooks +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const PER_PAGE = 100; + +const ProfileActivityPage = observer(() => { + // states + const [pageCount, setPageCount] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [resultsCount, setResultsCount] = useState(0); + const [isEmpty, setIsEmpty] = useState(false); + // plane hooks + const { t } = useTranslation(); + // derived values + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/profile/activity" }); + + const updateTotalPages = (count: number) => setTotalPages(count); + + const updateResultsCount = (count: number) => setResultsCount(count); + + const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty); + + const handleLoadMore = () => setPageCount((prev) => prev + 1); + + const activityPages: React.ReactNode[] = []; + for (let i = 0; i < pageCount; i++) + activityPages.push( + + ); + + const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; + + if (isEmpty) { + return ( + + ); + } + + return ( + <> + + + + {activityPages} + {isLoadMoreVisible && ( +
    + +
    + )} +
    + + ); +}); + +export default ProfileActivityPage; diff --git a/apps/web/app/(all)/profile/appearance/page.tsx b/apps/web/app/(all)/profile/appearance/page.tsx new file mode 100644 index 00000000..bbcbe243 --- /dev/null +++ b/apps/web/app/(all)/profile/appearance/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +// plane imports +import type { I_THEME_OPTION } from "@plane/constants"; +import { THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { setPromiseToast } from "@plane/propel/toast"; +import type { IUserTheme } from "@plane/types"; +// components +import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PageHead } from "@/components/core/page-title"; +import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; +import { ThemeSwitch } from "@/components/core/theme/theme-switch"; +import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; +import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; +// hooks +import { useUserProfile } from "@/hooks/store/user"; + +const ProfileAppearancePage = observer(() => { + const { t } = useTranslation(); + const { setTheme } = useTheme(); + // states + const [currentTheme, setCurrentTheme] = useState(null); + // hooks + const { data: userProfile, updateUserTheme } = useUserProfile(); + + useEffect(() => { + if (userProfile?.theme?.theme) { + const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme); + if (userThemeOption) { + setCurrentTheme(userThemeOption); + } + } + }, [userProfile?.theme?.theme]); + + const handleThemeChange = (themeOption: I_THEME_OPTION) => { + applyThemeChange({ theme: themeOption.value }); + + const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value }); + setPromiseToast(updateCurrentUserThemePromise, { + loading: "Updating theme...", + success: { + title: "Success!", + message: () => "Theme updated successfully!", + }, + error: { + title: "Error!", + message: () => "Failed to Update the theme", + }, + }); + }; + + const applyThemeChange = (theme: Partial) => { + setTheme(theme?.theme || "system"); + + if (theme?.theme === "custom" && theme?.palette) { + applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false); + } else unsetCustomCssVariables(); + }; + + return ( + <> + + {userProfile ? ( + + +
    +
    +

    {t("theme")}

    +

    {t("select_or_customize_your_interface_color_scheme")}

    +
    +
    + +
    +
    + {userProfile?.theme?.theme === "custom" && } +
    + ) : ( +
    + +
    + )} + + ); +}); + +export default ProfileAppearancePage; diff --git a/apps/web/app/(all)/profile/layout.tsx b/apps/web/app/(all)/profile/layout.tsx new file mode 100644 index 00000000..fdc08676 --- /dev/null +++ b/apps/web/app/(all)/profile/layout.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { ReactNode } from "react"; +// components +import { CommandPalette } from "@/components/command-palette"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +// layout +import { ProfileLayoutSidebar } from "./sidebar"; + +type Props = { + children: ReactNode; +}; + +export default function ProfileSettingsLayout(props: Props) { + const { children } = props; + + return ( + <> + + +
    + +
    +
    {children}
    +
    +
    +
    + + ); +} diff --git a/apps/web/app/(all)/profile/notifications/page.tsx b/apps/web/app/(all)/profile/notifications/page.tsx new file mode 100644 index 00000000..9f7bb220 --- /dev/null +++ b/apps/web/app/(all)/profile/notifications/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import useSWR from "swr"; +// components +import { useTranslation } from "@plane/i18n"; +import { PageHead } from "@/components/core/page-title"; +import { EmailNotificationForm } from "@/components/profile/notification/email-notification-form"; +import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; +import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; +import { EmailSettingsLoader } from "@/components/ui/loader/settings/email"; +// services +import { UserService } from "@/services/user.service"; + +const userService = new UserService(); + +export default function ProfileNotificationPage() { + const { t } = useTranslation(); + // fetching user email notification settings + const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => + userService.currentUserEmailNotificationSettings() + ); + + if (!data || isLoading) { + return ; + } + + return ( + <> + + + + + + + ); +} diff --git a/apps/web/app/(all)/profile/page.tsx b/apps/web/app/(all)/profile/page.tsx new file mode 100644 index 00000000..01ff1114 --- /dev/null +++ b/apps/web/app/(all)/profile/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PageHead } from "@/components/core/page-title"; +import { ProfileForm } from "@/components/profile/form"; +import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; +// hooks +import { useUser } from "@/hooks/store/user"; + +const ProfileSettingsPage = observer(() => { + const { t } = useTranslation(); + // store hooks + const { data: currentUser, userProfile } = useUser(); + + if (!currentUser) + return ( +
    + +
    + ); + + return ( + <> + + + + + + ); +}); + +export default ProfileSettingsPage; diff --git a/apps/web/app/(all)/profile/security/page.tsx b/apps/web/app/(all)/profile/security/page.tsx new file mode 100644 index 00000000..4f0ad13c --- /dev/null +++ b/apps/web/app/(all)/profile/security/page.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { Eye, EyeOff } from "lucide-react"; +// plane imports +import { E_PASSWORD_STRENGTH } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Input, PasswordStrengthIndicator } from "@plane/ui"; +// components +import { getPasswordStrength } from "@plane/utils"; +import { PageHead } from "@/components/core/page-title"; +import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; +import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; +// helpers +import { authErrorHandler } from "@/helpers/authentication.helper"; +import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; +// hooks +import { useUser } from "@/hooks/store/user"; +// services +import { AuthService } from "@/services/auth.service"; + +export interface FormValues { + old_password: string; + new_password: string; + confirm_password: string; +} + +const defaultValues: FormValues = { + old_password: "", + new_password: "", + confirm_password: "", +}; + +const authService = new AuthService(); + +const defaultShowPassword = { + oldPassword: false, + password: false, + confirmPassword: false, +}; + +const SecurityPage = observer(() => { + // store + const { data: currentUser, changePassword } = useUser(); + // states + const [showPassword, setShowPassword] = useState(defaultShowPassword); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + + // use form + const { + control, + handleSubmit, + watch, + formState: { errors, isSubmitting }, + reset, + } = useForm({ defaultValues }); + // derived values + const oldPassword = watch("old_password"); + const password = watch("new_password"); + const confirmPassword = watch("confirm_password"); + const oldPasswordRequired = !currentUser?.is_password_autoset; + // i18n + const { t } = useTranslation(); + + const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword; + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleChangePassword = async (formData: FormValues) => { + const { old_password, new_password } = formData; + try { + const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token); + if (!csrfToken) throw new Error("csrf token not found"); + + await changePassword(csrfToken, { + ...(oldPasswordRequired && { old_password }), + new_password, + }); + + reset(defaultValues); + setShowPassword(defaultShowPassword); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("auth.common.password.toast.change_password.success.title"), + message: t("auth.common.password.toast.change_password.success.message"), + }); + } catch (error: unknown) { + const err = error as Error & { error_code?: string }; + const code = err.error_code?.toString(); + const errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined; + setToast({ + type: TOAST_TYPE.ERROR, + title: errorInfo?.title ?? t("auth.common.password.toast.error.title"), + message: + typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"), + }); + } + }; + + const isButtonDisabled = + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID || + (oldPasswordRequired && oldPassword.trim() === "") || + password.trim() === "" || + confirmPassword.trim() === "" || + password !== confirmPassword || + password === oldPassword; + + const passwordSupport = password.length > 0 && + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( + + ); + + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + return ( + <> + + + +
    +
    + {oldPasswordRequired && ( +
    +

    {t("auth.common.password.current_password.label")}

    +
    + ( + + )} + /> + {showPassword?.oldPassword ? ( + handleShowPassword("oldPassword")} + /> + ) : ( + handleShowPassword("oldPassword")} + /> + )} +
    + {errors.old_password && {errors.old_password.message}} +
    + )} +
    +

    {t("auth.common.password.new_password.label")}

    +
    + ( + setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + /> + )} + /> + {showPassword?.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
    + {passwordSupport} + {isNewPasswordSameAsOldPassword && !isPasswordInputFocused && ( + {t("new_password_must_be_different_from_old_password")} + )} +
    +
    +

    {t("auth.common.password.confirm_password.label")}

    +
    + ( + setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + )} + /> + {showPassword?.confirmPassword ? ( + handleShowPassword("confirmPassword")} + /> + ) : ( + handleShowPassword("confirmPassword")} + /> + )} +
    + {!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )} +
    +
    + +
    + +
    +
    +
    + + ); +}); + +export default SecurityPage; diff --git a/apps/web/app/(all)/profile/sidebar.tsx b/apps/web/app/(all)/profile/sidebar.tsx new file mode 100644 index 00000000..acf25bb1 --- /dev/null +++ b/apps/web/app/(all)/profile/sidebar.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +// icons +import { + ChevronLeft, + LogOut, + MoveLeft, + Activity, + Bell, + CircleUser, + KeyRound, + Settings2, + CirclePlus, + Mails, +} from "lucide-react"; +// plane imports +import { PROFILE_ACTION_LINKS } from "@plane/constants"; +import { useOutsideClickDetector } from "@plane/hooks"; +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Tooltip } from "@plane/propel/tooltip"; +import { cn, getFileURL } from "@plane/utils"; +// components +import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUser, useUserSettings } from "@/hooks/store/user"; +import { usePlatformOS } from "@/hooks/use-platform-os"; + +const WORKSPACE_ACTION_LINKS = [ + { + key: "create_workspace", + Icon: CirclePlus, + i18n_label: "create_workspace", + href: "/create-workspace", + }, + { + key: "invitations", + Icon: Mails, + i18n_label: "workspace_invites", + href: "/invitations", + }, +]; + +const ProjectActionIcons = ({ type, size, className = "" }: { type: string; size?: number; className?: string }) => { + const icons = { + profile: CircleUser, + security: KeyRound, + activity: Activity, + preferences: Settings2, + notifications: Bell, + "api-tokens": KeyRound, + }; + + if (type === undefined) return null; + const Icon = icons[type as keyof typeof icons]; + if (!Icon) return null; + return ; +}; +export const ProfileLayoutSidebar = observer(() => { + // states + const [isSigningOut, setIsSigningOut] = useState(false); + // router + const pathname = usePathname(); + // store hooks + const { sidebarCollapsed, toggleSidebar } = useAppTheme(); + const { data: currentUser, signOut } = useUser(); + const { data: currentUserSettings } = useUserSettings(); + const { workspaces } = useWorkspace(); + const { isMobile } = usePlatformOS(); + const { t } = useTranslation(); + + const workspacesList = Object.values(workspaces ?? {}); + + // redirect url for normal mode + const redirectWorkspaceSlug = + currentUserSettings?.workspace?.last_workspace_slug || + currentUserSettings?.workspace?.fallback_workspace_slug || + ""; + + const ref = useRef(null); + + useOutsideClickDetector(ref, () => { + if (sidebarCollapsed === false) { + if (window.innerWidth < 768) { + toggleSidebar(); + } + } + }); + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth <= 768) { + toggleSidebar(true); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [toggleSidebar]); + + const handleItemClick = () => { + if (window.innerWidth < 768) { + toggleSidebar(); + } + }; + + const handleSignOut = async () => { + setIsSigningOut(true); + await signOut() + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: t("sign_out.toast.error.title"), + message: t("sign_out.toast.error.message"), + }) + ) + .finally(() => setIsSigningOut(false)); + }; + + return ( +
    +
    + +
    + + + + {!sidebarCollapsed && ( +

    {t("profile_settings")}

    + )} +
    + +
    + {!sidebarCollapsed && ( +
    {t("your_account")}
    + )} +
    + {PROFILE_ACTION_LINKS.map((link) => { + if (link.key === "change-password" && currentUser?.is_password_autoset) return null; + + return ( + + + +
    + + + {!sidebarCollapsed &&

    {t(link.i18n_label)}

    } +
    +
    +
    + + ); + })} +
    +
    +
    + {!sidebarCollapsed && ( +
    {t("workspaces")}
    + )} + {workspacesList && workspacesList.length > 0 && ( +
    + {workspacesList.map((workspace) => ( + + + + {workspace?.logo_url && workspace.logo_url !== "" ? ( + Workspace Logo + ) : ( + (workspace?.name?.charAt(0) ?? "...") + )} + + {!sidebarCollapsed && ( +

    {workspace.name}

    + )} +
    + + ))} +
    + )} +
    + {WORKSPACE_ACTION_LINKS.map((link) => ( + + +
    + {} + {!sidebarCollapsed && t(link.i18n_label)} +
    +
    + + ))} +
    +
    +
    +
    + + + +
    +
    +
    +
    + ); +}); diff --git a/apps/web/app/(all)/sign-up/layout.tsx b/apps/web/app/(all)/sign-up/layout.tsx new file mode 100644 index 00000000..815fe08f --- /dev/null +++ b/apps/web/app/(all)/sign-up/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Sign up - Plane", + robots: { + index: true, + follow: false, + }, +}; + +export default function SignUpLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(all)/sign-up/page.tsx b/apps/web/app/(all)/sign-up/page.tsx new file mode 100644 index 00000000..18deab2d --- /dev/null +++ b/apps/web/app/(all)/sign-up/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +// components +import { AuthBase } from "@/components/auth-screens/auth-base"; +// helpers +import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +// assets +import DefaultLayout from "@/layouts/default-layout"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +const SignUpPage = () => ( + + + + + +); + +export default SignUpPage; diff --git a/apps/web/app/(all)/workspace-invitations/layout.tsx b/apps/web/app/(all)/workspace-invitations/layout.tsx new file mode 100644 index 00000000..535b2f62 --- /dev/null +++ b/apps/web/app/(all)/workspace-invitations/layout.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Workspace Invitations", +}; + +export default function WorkspaceInvitationsLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(all)/workspace-invitations/page.tsx b/apps/web/app/(all)/workspace-invitations/page.tsx new file mode 100644 index 00000000..6f9d78d5 --- /dev/null +++ b/apps/web/app/(all)/workspace-invitations/page.tsx @@ -0,0 +1,128 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import useSWR from "swr"; +import { Boxes, Check, Share2, Star, User2, X } from "lucide-react"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { EmptySpace, EmptySpaceItem } from "@/components/ui/empty-space"; +// constants +import { WORKSPACE_INVITATION } from "@/constants/fetch-keys"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +import { WorkspaceService } from "@/plane-web/services"; +// services + +// service initialization +const workspaceService = new WorkspaceService(); + +const WorkspaceInvitationPage = observer(() => { + // router + const router = useAppRouter(); + // query params + const searchParams = useSearchParams(); + const invitation_id = searchParams.get("invitation_id"); + const email = searchParams.get("email"); + const slug = searchParams.get("slug"); + // store hooks + const { data: currentUser } = useUser(); + + const { data: invitationDetail, error } = useSWR( + invitation_id && slug && WORKSPACE_INVITATION(invitation_id.toString()), + invitation_id && slug + ? () => workspaceService.getWorkspaceInvitation(slug.toString(), invitation_id.toString()) + : null + ); + + const handleAccept = () => { + if (!invitationDetail) return; + workspaceService + .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { + accepted: true, + email: invitationDetail.email, + }) + .then(() => { + if (email === currentUser?.email) { + router.push(`/${invitationDetail.workspace.slug}`); + } else { + router.push(`/?${searchParams.toString()}`); + } + }) + .catch((err) => console.error(err)); + }; + + const handleReject = () => { + if (!invitationDetail) return; + workspaceService + .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { + accepted: false, + email: invitationDetail.email, + }) + .then(() => { + router.push("/"); + }) + .catch((err) => console.error(err)); + }; + + return ( + +
    + {invitationDetail && !invitationDetail.responded_at ? ( + error ? ( +
    +

    INVITATION NOT FOUND

    +
    + ) : ( + + + + + ) + ) : error || invitationDetail?.responded_at ? ( + invitationDetail?.accepted ? ( + + + + ) : ( + + {!currentUser ? ( + + ) : ( + + )} + + + + ) + ) : ( +
    + +
    + )} +
    +
    + ); +}); + +export default WorkspaceInvitationPage; diff --git a/apps/web/app/(home)/layout.tsx b/apps/web/app/(home)/layout.tsx new file mode 100644 index 00000000..d50131fc --- /dev/null +++ b/apps/web/app/(home)/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata, Viewport } from "next"; + +export const metadata: Metadata = { + robots: { + index: true, + follow: false, + }, +}; + +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + width: "device-width", + viewportFit: "cover", +}; + +export default function HomeLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/web/app/(home)/page.tsx b/apps/web/app/(home)/page.tsx new file mode 100644 index 00000000..20a184ff --- /dev/null +++ b/apps/web/app/(home)/page.tsx @@ -0,0 +1,20 @@ +"use client"; +import React from "react"; +// components +import { AuthBase } from "@/components/auth-screens/auth-base"; +// helpers +import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +const HomePage = () => ( + + + + + +); + +export default HomePage; diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx new file mode 100644 index 00000000..a6fa660a --- /dev/null +++ b/apps/web/app/error.tsx @@ -0,0 +1,86 @@ +"use client"; + +import Image from "next/image"; +import { useTheme } from "next-themes"; +// layouts +import { Button } from "@plane/propel/button"; +import { useAppRouter } from "@/hooks/use-app-router"; +import DefaultLayout from "@/layouts/default-layout"; +// images +import maintenanceModeDarkModeImage from "@/public/instance/maintenance-mode-dark.svg"; +import maintenanceModeLightModeImage from "@/public/instance/maintenance-mode-light.svg"; + +const linkMap = [ + { + key: "mail_to", + label: "Contact Support", + value: "mailto:support@plane.so", + }, + { + key: "status", + label: "Status Page", + value: "https://status.plane.so/", + }, + { + key: "twitter_handle", + label: "@planepowers", + value: "https://x.com/planepowers", + }, +]; + +export default function CustomErrorComponent() { + // hooks + const { resolvedTheme } = useTheme(); + const router = useAppRouter(); + + // derived values + const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage; + + return ( + +
    +
    + ProjectSettingImg +
    +
    +
    +

    + 🚧 Looks like something went wrong! +

    + + We track these errors automatically and working on getting things back up and running. If the problem + persists feel free to contact us. In the meantime, try refreshing. + +
    + +
    + {linkMap.map((link) => ( + + ))} +
    + +
    + +
    +
    +
    +
    + ); +} diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx new file mode 100644 index 00000000..b0b191e4 --- /dev/null +++ b/apps/web/app/global-error.tsx @@ -0,0 +1,17 @@ +"use client"; + +import NextError from "next/error"; + +export default function GlobalError() { + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 00000000..b2b274c7 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,109 @@ +import type { Metadata, Viewport } from "next"; +import Script from "next/script"; + +// styles +import "@/styles/globals.css"; + +import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; + +// helpers +import { cn } from "@plane/utils"; + +// local +import { AppProvider } from "./provider"; + +export const metadata: Metadata = { + title: "Plane | Simple, extensible, open-source project management tool.", + description: SITE_DESCRIPTION, + metadataBase: new URL("https://app.plane.so"), + openGraph: { + title: "Plane | Simple, extensible, open-source project management tool.", + description: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", + url: "https://app.plane.so/", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "Plane - Modern project management", + }, + ], + }, + keywords: + "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + card: "summary_large_image", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "Plane - Modern project management", + }, + ], + }, +}; + +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + maximumScale: 1, + userScalable: false, + width: "device-width", + viewportFit: "cover", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0"); + + return ( + + + + + + + + {/* Meta info for PWA */} + + + + + + + + + + + + + +
    +
    + +
    +
    {children}
    +
    +
    + + {process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && ( + + )} + + ); +} diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx new file mode 100644 index 00000000..3d58991b --- /dev/null +++ b/apps/web/app/not-found.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React from "react"; +import type { Metadata } from "next"; +import Image from "next/image"; +import Link from "next/link"; +// ui +import { Button } from "@plane/propel/button"; +// images +import Image404 from "@/public/404.svg"; + +export const metadata: Metadata = { + title: "404 - Page Not Found", + robots: { + index: false, + follow: false, + }, +}; + +const PageNotFound = () => ( +
    +
    +
    +
    + 404- Page not found +
    +
    +

    Oops! Something went wrong.

    +

    + Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is + temporarily unavailable. +

    +
    + + + + + +
    +
    +
    +); + +export default PageNotFound; diff --git a/apps/web/app/provider.tsx b/apps/web/app/provider.tsx new file mode 100644 index 00000000..a83c75f9 --- /dev/null +++ b/apps/web/app/provider.tsx @@ -0,0 +1,64 @@ +"use client"; + +import type { FC, ReactNode } from "react"; +import { AppProgressProvider as ProgressProvider } from "@bprogress/next"; +import dynamic from "next/dynamic"; +import { useTheme, ThemeProvider } from "next-themes"; +import { SWRConfig } from "swr"; +// Plane Imports +import { WEB_SWR_CONFIG } from "@plane/constants"; +import { TranslationProvider } from "@plane/i18n"; +import { Toast } from "@plane/propel/toast"; +//helpers +import { resolveGeneralTheme } from "@plane/utils"; +// polyfills +import "@/lib/polyfills"; +// mobx store provider +import { StoreProvider } from "@/lib/store-context"; +// wrappers +import { InstanceWrapper } from "@/lib/wrappers/instance-wrapper"; +// dynamic imports +const StoreWrapper = dynamic(() => import("@/lib/wrappers/store-wrapper"), { ssr: false }); +const PostHogProvider = dynamic(() => import("@/lib/posthog-provider"), { ssr: false }); +const IntercomProvider = dynamic(() => import("@/lib/intercom-provider"), { ssr: false }); + +export interface IAppProvider { + children: ReactNode; +} + +const ToastWithTheme = () => { + const { resolvedTheme } = useTheme(); + return ; +}; + +export const AppProvider: FC = (props) => { + const { children } = props; + // themes + return ( + <> + + + + + + + + + + {children} + + + + + + + + + + ); +}; diff --git a/apps/web/ce/components/active-cycles/index.ts b/apps/web/ce/components/active-cycles/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/active-cycles/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/active-cycles/root.tsx b/apps/web/ce/components/active-cycles/root.tsx new file mode 100644 index 00000000..caad61a0 --- /dev/null +++ b/apps/web/ce/components/active-cycles/root.tsx @@ -0,0 +1,4 @@ +// local imports +import { WorkspaceActiveCyclesUpgrade } from "./workspace-active-cycles-upgrade"; + +export const WorkspaceActiveCyclesRoot = () => ; diff --git a/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx b/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx new file mode 100644 index 00000000..1ce060b0 --- /dev/null +++ b/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx @@ -0,0 +1,136 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { AlertOctagon, BarChart4, CircleDashed, Folder, Microscope, Search } from "lucide-react"; +// plane imports +import { MARKETING_PRICING_PAGE_LINK } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { getButtonStyling } from "@plane/propel/button"; +import { ContentWrapper } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { ProIcon } from "@/components/common/pro-icon"; +// hooks +import { useUser } from "@/hooks/store/user"; + +export const WORKSPACE_ACTIVE_CYCLES_DETAILS = [ + { + key: "10000_feet_view", + title: "10,000-feet view of all active cycles.", + description: + "Zoom out to see running cycles across all your projects at once instead of going from Cycle to Cycle in each project.", + icon: Folder, + }, + { + key: "get_snapshot_of_each_active_cycle", + title: "Get a snapshot of each active cycle.", + description: + "Track high-level metrics for all active cycles, see their state of progress, and get a sense of scope against deadlines.", + icon: CircleDashed, + }, + { + key: "compare_burndowns", + title: "Compare burndowns.", + description: "Monitor how each of your teams are performing with a peek into each cycle’s burndown report.", + icon: BarChart4, + }, + { + key: "quickly_see_make_or_break_issues", + title: "Quickly see make-or-break work items. ", + description: + "Preview high-priority work items for each cycle against due dates. See all of them per cycle in one click.", + icon: AlertOctagon, + }, + { + key: "zoom_into_cycles_that_need_attention", + title: "Zoom into cycles that need attention. ", + description: "Investigate the state of any cycle that doesn’t conform to expectations in one click.", + icon: Search, + }, + { + key: "stay_ahead_of_blockers", + title: "Stay ahead of blockers.", + description: + "Spot challenges from one project to another and see inter-cycle dependencies that aren’t obvious from any other view.", + icon: Microscope, + }, +]; + +export const WorkspaceActiveCyclesUpgrade = observer(() => { + const { t } = useTranslation(); + // store hooks + const { + userProfile: { data: userProfile }, + } = useUser(); + + const isDarkMode = userProfile?.theme.theme === "dark"; + + return ( + +
    +
    +
    +

    {t("on_demand_snapshots_of_all_your_cycles")}

    +

    {t("active_cycles_description")}

    +
    + + + l-1 + +
    +
    + + r-1 + + + r-2 + +
    +
    +
    + {WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => ( +
    +
    +

    {t(item.key)}

    + +
    + {t(`${item.key}_description`)} +
    + ))} +
    +
    + ); +}); diff --git a/apps/web/ce/components/analytics/tabs.tsx b/apps/web/ce/components/analytics/tabs.tsx new file mode 100644 index 00000000..3cca9739 --- /dev/null +++ b/apps/web/ce/components/analytics/tabs.tsx @@ -0,0 +1,8 @@ +import type { AnalyticsTab } from "@plane/types"; +import { Overview } from "@/components/analytics/overview"; +import { WorkItems } from "@/components/analytics/work-items"; + +export const getAnalyticsTabs = (t: (key: string, params?: Record) => string): AnalyticsTab[] => [ + { key: "overview", label: t("common.overview"), content: Overview, isDisabled: false }, + { key: "work-items", label: t("sidebar.work_items"), content: WorkItems, isDisabled: false }, +]; diff --git a/apps/web/ce/components/app-rail/index.ts b/apps/web/ce/components/app-rail/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/app-rail/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/app-rail/root.tsx b/apps/web/ce/components/app-rail/root.tsx new file mode 100644 index 00000000..259764b2 --- /dev/null +++ b/apps/web/ce/components/app-rail/root.tsx @@ -0,0 +1,4 @@ +"use client"; +import React from "react"; + +export const AppRailRoot = () => <>; diff --git a/apps/web/ce/components/automations/root.tsx b/apps/web/ce/components/automations/root.tsx new file mode 100644 index 00000000..e7f15288 --- /dev/null +++ b/apps/web/ce/components/automations/root.tsx @@ -0,0 +1,11 @@ +"use client"; + +import type { FC } from "react"; +import React from "react"; + +export type TCustomAutomationsRootProps = { + projectId: string; + workspaceSlug: string; +}; + +export const CustomAutomationsRoot: FC = () => <>; diff --git a/apps/web/ce/components/breadcrumbs/common.tsx b/apps/web/ce/components/breadcrumbs/common.tsx new file mode 100644 index 00000000..86a12391 --- /dev/null +++ b/apps/web/ce/components/breadcrumbs/common.tsx @@ -0,0 +1,32 @@ +"use client"; + +import type { FC } from "react"; +// plane imports +import type { EProjectFeatureKey } from "@plane/constants"; +// local components +import { ProjectBreadcrumb } from "./project"; +import { ProjectFeatureBreadcrumb } from "./project-feature"; + +type TCommonProjectBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + featureKey?: EProjectFeatureKey; + isLast?: boolean; +}; + +export const CommonProjectBreadcrumbs: FC = (props) => { + const { workspaceSlug, projectId, featureKey, isLast = false } = props; + return ( + <> + + {featureKey && ( + + )} + + ); +}; diff --git a/apps/web/ce/components/breadcrumbs/project-feature.tsx b/apps/web/ce/components/breadcrumbs/project-feature.tsx new file mode 100644 index 00000000..cad4338d --- /dev/null +++ b/apps/web/ce/components/breadcrumbs/project-feature.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { EProjectFeatureKey } from "@plane/constants"; +import type { ISvgIcons } from "@plane/propel/icons"; +import { BreadcrumbNavigationDropdown, Breadcrumbs } from "@plane/ui"; +// components +import { SwitcherLabel } from "@/components/common/switcher-label"; +import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +// local imports +import { getProjectFeatureNavigation } from "../projects/navigation/helper"; + +type TProjectFeatureBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + featureKey: EProjectFeatureKey; + isLast?: boolean; + additionalNavigationItems?: TNavigationItem[]; +}; + +export const ProjectFeatureBreadcrumb = observer((props: TProjectFeatureBreadcrumbProps) => { + const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props; + // router + const router = useAppRouter(); + // store hooks + const { getPartialProjectById } = useProject(); + // derived values + const project = getPartialProjectById(projectId); + + if (!project) return null; + + const navigationItems = getProjectFeatureNavigation(workspaceSlug, projectId, project); + + // if additional navigation items are provided, add them to the navigation items + const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems]; + + return ( + <> + item.shouldRender) + .map((item) => ({ + key: item.key, + title: item.name, + customContent: } />, + action: () => router.push(item.href), + icon: item.icon as FC, + }))} + handleOnClick={() => { + router.push( + `/${workspaceSlug}/projects/${projectId}/${featureKey === EProjectFeatureKey.WORK_ITEMS ? "issues" : featureKey}/` + ); + }} + isLast={isLast} + /> + } + showSeparator={false} + isLast={isLast} + /> + + ); +}); diff --git a/apps/web/ce/components/breadcrumbs/project.tsx b/apps/web/ce/components/breadcrumbs/project.tsx new file mode 100644 index 00000000..2f6c67bd --- /dev/null +++ b/apps/web/ce/components/breadcrumbs/project.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { observer } from "mobx-react"; +import { ProjectIcon } from "@plane/propel/icons"; +// plane imports +import type { ICustomSearchSelectOption } from "@plane/types"; +import { BreadcrumbNavigationSearchDropdown, Breadcrumbs } from "@plane/ui"; +// components +import { Logo } from "@/components/common/logo"; +import { SwitcherLabel } from "@/components/common/switcher-label"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import type { TProject } from "@/plane-web/types"; + +type TProjectBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + handleOnClick?: () => void; +}; + +export const ProjectBreadcrumb = observer((props: TProjectBreadcrumbProps) => { + const { workspaceSlug, projectId, handleOnClick } = props; + // router + const router = useAppRouter(); + // store hooks + const { joinedProjectIds, getPartialProjectById } = useProject(); + const currentProjectDetails = getPartialProjectById(projectId); + + // store hooks + + if (!currentProjectDetails) return null; + + // derived values + const switcherOptions = joinedProjectIds + .map((projectId) => { + const project = getPartialProjectById(projectId); + return { + value: projectId, + query: project?.name, + content: ( + + ), + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + // helpers + const renderIcon = (projectDetails: TProject) => ( + + + + ); + + return ( + <> + { + router.push(`/${workspaceSlug}/projects/${value}/issues`); + }} + title={currentProjectDetails?.name} + icon={renderIcon(currentProjectDetails)} + handleOnClick={() => { + if (handleOnClick) handleOnClick(); + else router.push(`/${workspaceSlug}/projects/${currentProjectDetails.id}/issues/`); + }} + shouldTruncate + /> + } + showSeparator={false} + /> + + ); +}); diff --git a/apps/web/ce/components/command-palette/actions/index.ts b/apps/web/ce/components/command-palette/actions/index.ts new file mode 100644 index 00000000..c7f1e122 --- /dev/null +++ b/apps/web/ce/components/command-palette/actions/index.ts @@ -0,0 +1 @@ +export * from "./work-item-actions"; diff --git a/apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx b/apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx new file mode 100644 index 00000000..fb3595d5 --- /dev/null +++ b/apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx @@ -0,0 +1,50 @@ +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Check } from "lucide-react"; +// plane imports +import { EIconSize } from "@plane/constants"; +import { StateGroupIcon } from "@plane/propel/icons"; +import { Spinner } from "@plane/ui"; +// store hooks +import { useProjectState } from "@/hooks/store/use-project-state"; + +export type TChangeWorkItemStateListProps = { + projectId: string | null; + currentStateId: string | null; + handleStateChange: (stateId: string) => void; +}; + +export const ChangeWorkItemStateList = observer((props: TChangeWorkItemStateListProps) => { + const { projectId, currentStateId, handleStateChange } = props; + // store hooks + const { getProjectStates } = useProjectState(); + // derived values + const projectStates = getProjectStates(projectId); + + return ( + <> + {projectStates ? ( + projectStates.length > 0 ? ( + projectStates.map((state) => ( + handleStateChange(state.id)} className="focus:outline-none"> +
    + +

    {state.name}

    +
    +
    {state.id === currentStateId && }
    +
    + )) + ) : ( +
    No states found
    + ) + ) : ( + + )} + + ); +}); diff --git a/apps/web/ce/components/command-palette/actions/work-item-actions/index.ts b/apps/web/ce/components/command-palette/actions/work-item-actions/index.ts new file mode 100644 index 00000000..ac7f8aa8 --- /dev/null +++ b/apps/web/ce/components/command-palette/actions/work-item-actions/index.ts @@ -0,0 +1 @@ +export * from "./change-state-list"; diff --git a/apps/web/ce/components/command-palette/helpers.tsx b/apps/web/ce/components/command-palette/helpers.tsx new file mode 100644 index 00000000..865aa9e5 --- /dev/null +++ b/apps/web/ce/components/command-palette/helpers.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { LayoutGrid } from "lucide-react"; +// plane imports +import { CycleIcon, ModuleIcon, PageIcon, ProjectIcon, ViewsIcon } from "@plane/propel/icons"; +import type { + IWorkspaceDefaultSearchResult, + IWorkspaceIssueSearchResult, + IWorkspacePageSearchResult, + IWorkspaceProjectSearchResult, + IWorkspaceSearchResult, +} from "@plane/types"; +import { generateWorkItemLink } from "@plane/utils"; +// plane web components +import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; + +export type TCommandGroups = { + [key: string]: { + icon: React.ReactNode | null; + itemName: (item: any) => React.ReactNode; + path: (item: any, projectId: string | undefined) => string; + title: string; + }; +}; + +export const commandGroups: TCommandGroups = { + cycle: { + icon: , + itemName: (cycle: IWorkspaceDefaultSearchResult) => ( +
    + {cycle.project__identifier} {cycle.name} +
    + ), + path: (cycle: IWorkspaceDefaultSearchResult) => + `/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`, + title: "Cycles", + }, + issue: { + icon: null, + itemName: (issue: IWorkspaceIssueSearchResult) => ( +
    + {" "} + {issue.name} +
    + ), + path: (issue: IWorkspaceIssueSearchResult) => + generateWorkItemLink({ + workspaceSlug: issue?.workspace__slug, + projectId: issue?.project_id, + issueId: issue?.id, + projectIdentifier: issue.project__identifier, + sequenceId: issue?.sequence_id, + }), + title: "Work items", + }, + issue_view: { + icon: , + itemName: (view: IWorkspaceDefaultSearchResult) => ( +
    + {view.project__identifier} {view.name} +
    + ), + path: (view: IWorkspaceDefaultSearchResult) => + `/${view?.workspace__slug}/projects/${view?.project_id}/views/${view?.id}`, + title: "Views", + }, + module: { + icon: , + itemName: (module: IWorkspaceDefaultSearchResult) => ( +
    + {module.project__identifier} {module.name} +
    + ), + path: (module: IWorkspaceDefaultSearchResult) => + `/${module?.workspace__slug}/projects/${module?.project_id}/modules/${module?.id}`, + title: "Modules", + }, + page: { + icon: , + itemName: (page: IWorkspacePageSearchResult) => ( +
    + {page.project__identifiers?.[0]} {page.name} +
    + ), + path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => { + let redirectProjectId = page?.project_ids?.[0]; + if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId; + return redirectProjectId + ? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}` + : `/${page?.workspace__slug}/pages/${page?.id}`; + }, + title: "Pages", + }, + project: { + icon: , + itemName: (project: IWorkspaceProjectSearchResult) => project?.name, + path: (project: IWorkspaceProjectSearchResult) => `/${project?.workspace__slug}/projects/${project?.id}/issues/`, + title: "Projects", + }, + workspace: { + icon: , + itemName: (workspace: IWorkspaceSearchResult) => workspace?.name, + path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`, + title: "Workspaces", + }, +}; diff --git a/apps/web/ce/components/command-palette/index.ts b/apps/web/ce/components/command-palette/index.ts new file mode 100644 index 00000000..62404249 --- /dev/null +++ b/apps/web/ce/components/command-palette/index.ts @@ -0,0 +1,3 @@ +export * from "./actions"; +export * from "./modals"; +export * from "./helpers"; diff --git a/apps/web/ce/components/command-palette/modals/index.ts b/apps/web/ce/components/command-palette/modals/index.ts new file mode 100644 index 00000000..a4fac4b9 --- /dev/null +++ b/apps/web/ce/components/command-palette/modals/index.ts @@ -0,0 +1,3 @@ +export * from "./workspace-level"; +export * from "./project-level"; +export * from "./issue-level"; diff --git a/apps/web/ce/components/command-palette/modals/issue-level.tsx b/apps/web/ce/components/command-palette/modals/issue-level.tsx new file mode 100644 index 00000000..f720e38e --- /dev/null +++ b/apps/web/ce/components/command-palette/modals/issue-level.tsx @@ -0,0 +1,102 @@ +import type { FC } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import type { TIssue } from "@plane/types"; +import { EIssueServiceType, EIssuesStoreType } from "@plane/types"; +// components +import { BulkDeleteIssuesModal } from "@/components/core/modals/bulk-delete-issues-modal"; +import { DeleteIssueModal } from "@/components/issues/delete-issue-modal"; +import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useIssuesActions } from "@/hooks/use-issues-actions"; + +export type TIssueLevelModalsProps = { + projectId: string | undefined; + issueId: string | undefined; +}; + +export const IssueLevelModals: FC = observer((props) => { + const { projectId, issueId } = props; + // router + const { workspaceSlug, cycleId, moduleId } = useParams(); + const router = useAppRouter(); + // store hooks + const { data: currentUser } = useUser(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + + const { removeIssue: removeEpic } = useIssuesActions(EIssuesStoreType.EPIC); + const { removeIssue: removeWorkItem } = useIssuesActions(EIssuesStoreType.PROJECT); + + const { + isCreateIssueModalOpen, + toggleCreateIssueModal, + isDeleteIssueModalOpen, + toggleDeleteIssueModal, + isBulkDeleteIssueModalOpen, + toggleBulkDeleteIssueModal, + createWorkItemAllowedProjectIds, + } = useCommandPalette(); + // derived values + const issueDetails = issueId ? getIssueById(issueId) : undefined; + const { fetchSubIssues: fetchSubWorkItems } = useIssueDetail(); + const { fetchSubIssues: fetchEpicSubWorkItems } = useIssueDetail(EIssueServiceType.EPICS); + + const handleDeleteIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + const isEpic = issueDetails?.is_epic; + const deleteAction = isEpic ? removeEpic : removeWorkItem; + const redirectPath = `/${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}`; + + await deleteAction(projectId, issueId); + router.push(redirectPath); + } catch (error) { + console.error("Failed to delete issue:", error); + } + }; + + const handleCreateIssueSubmit = async (newIssue: TIssue) => { + if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== issueDetails?.id) return; + + const fetchAction = issueDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems; + await fetchAction(workspaceSlug?.toString(), newIssue.project_id, issueDetails.id); + }; + + const getCreateIssueModalData = () => { + if (cycleId) return { cycle_id: cycleId.toString() }; + if (moduleId) return { module_ids: [moduleId.toString()] }; + return undefined; + }; + + return ( + <> + toggleCreateIssueModal(false)} + data={getCreateIssueModalData()} + onSubmit={handleCreateIssueSubmit} + allowedProjectIds={createWorkItemAllowedProjectIds} + /> + {workspaceSlug && projectId && issueId && issueDetails && ( + toggleDeleteIssueModal(false)} + isOpen={isDeleteIssueModalOpen} + data={issueDetails} + onSubmit={() => handleDeleteIssue(workspaceSlug.toString(), projectId?.toString(), issueId?.toString())} + isEpic={issueDetails?.is_epic} + /> + )} + toggleBulkDeleteIssueModal(false)} + user={currentUser} + /> + + ); +}); diff --git a/apps/web/ce/components/command-palette/modals/project-level.tsx b/apps/web/ce/components/command-palette/modals/project-level.tsx new file mode 100644 index 00000000..6b9e8000 --- /dev/null +++ b/apps/web/ce/components/command-palette/modals/project-level.tsx @@ -0,0 +1,62 @@ +import { observer } from "mobx-react"; +// components +import { CycleCreateUpdateModal } from "@/components/cycles/modal"; +import { CreateUpdateModuleModal } from "@/components/modules"; +import { CreatePageModal } from "@/components/pages/modals/create-page-modal"; +import { CreateUpdateProjectViewModal } from "@/components/views/modal"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +// plane web hooks +import { EPageStoreType } from "@/plane-web/hooks/store"; + +export type TProjectLevelModalsProps = { + workspaceSlug: string; + projectId: string; +}; + +export const ProjectLevelModals = observer((props: TProjectLevelModalsProps) => { + const { workspaceSlug, projectId } = props; + // store hooks + const { + isCreateCycleModalOpen, + toggleCreateCycleModal, + isCreateModuleModalOpen, + toggleCreateModuleModal, + isCreateViewModalOpen, + toggleCreateViewModal, + createPageModal, + toggleCreatePageModal, + } = useCommandPalette(); + + return ( + <> + toggleCreateCycleModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + /> + toggleCreateModuleModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + /> + toggleCreateViewModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + /> + toggleCreatePageModal({ isOpen: false })} + redirectionEnabled + storeType={EPageStoreType.PROJECT} + /> + + ); +}); diff --git a/apps/web/ce/components/command-palette/modals/workspace-level.tsx b/apps/web/ce/components/command-palette/modals/workspace-level.tsx new file mode 100644 index 00000000..a6c89776 --- /dev/null +++ b/apps/web/ce/components/command-palette/modals/workspace-level.tsx @@ -0,0 +1,25 @@ +import { observer } from "mobx-react"; +// components +import { CreateProjectModal } from "@/components/project/create-project-modal"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; + +export type TWorkspaceLevelModalsProps = { + workspaceSlug: string; +}; + +export const WorkspaceLevelModals = observer((props: TWorkspaceLevelModalsProps) => { + const { workspaceSlug } = props; + // store hooks + const { isCreateProjectModalOpen, toggleCreateProjectModal } = useCommandPalette(); + + return ( + <> + toggleCreateProjectModal(false)} + workspaceSlug={workspaceSlug.toString()} + /> + + ); +}); diff --git a/apps/web/ce/components/comments/comment-block.tsx b/apps/web/ce/components/comments/comment-block.tsx new file mode 100644 index 00000000..c5c9b442 --- /dev/null +++ b/apps/web/ce/components/comments/comment-block.tsx @@ -0,0 +1,82 @@ +import type { FC, ReactNode } from "react"; +import { useRef } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import type { TIssueComment } from "@plane/types"; +import { EIssueCommentAccessSpecifier } from "@plane/types"; +import { Avatar, Tooltip } from "@plane/ui"; +import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils"; +// hooks +import { useMember } from "@/hooks/store/use-member"; + +type TCommentBlock = { + comment: TIssueComment; + ends: "top" | "bottom" | undefined; + quickActions: ReactNode; + children: ReactNode; +}; + +export const CommentBlock: FC = observer((props) => { + const { comment, ends, quickActions, children } = props; + // refs + const commentBlockRef = useRef(null); + // store hooks + const { getUserDetails } = useMember(); + // derived values + const userDetails = getUserDetails(comment?.actor); + // translation + const { t } = useTranslation(); + + const displayName = comment?.actor_detail?.is_bot + ? comment?.actor_detail?.first_name + ` ${t("bot")}` + : (userDetails?.display_name ?? comment?.actor_detail?.display_name); + + const avatarUrl = userDetails?.avatar_url ?? comment?.actor_detail?.avatar_url; + + if (!comment) return null; + + return ( +
    +
    +
    + +
    +
    +
    +
    +
    + + {`${displayName}${comment.access === EIssueCommentAccessSpecifier.EXTERNAL ? " (External User)" : ""}`} + +
    +
    + commented{" "} + + + {calculateTimeAgo(comment.updated_at)} + {comment.edited_at && ` (${t("edited")})`} + + +
    +
    +
    {quickActions}
    +
    +
    {children}
    +
    +
    + ); +}); diff --git a/apps/web/ce/components/comments/index.ts b/apps/web/ce/components/comments/index.ts new file mode 100644 index 00000000..f0ef4e2b --- /dev/null +++ b/apps/web/ce/components/comments/index.ts @@ -0,0 +1 @@ +export * from "./comment-block"; diff --git a/apps/web/ce/components/common/extended-app-header.tsx b/apps/web/ce/components/common/extended-app-header.tsx new file mode 100644 index 00000000..59dbf339 --- /dev/null +++ b/apps/web/ce/components/common/extended-app-header.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import { observer } from "mobx-react"; +import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button"; +import { useAppTheme } from "@/hooks/store/use-app-theme"; + +export const ExtendedAppHeader = observer((props: { header: ReactNode }) => { + const { header } = props; + // store hooks + const { sidebarCollapsed } = useAppTheme(); + + return ( + <> + {sidebarCollapsed && } +
    {header}
    + + ); +}); diff --git a/apps/web/ce/components/common/subscription/subscription-pill.tsx b/apps/web/ce/components/common/subscription/subscription-pill.tsx new file mode 100644 index 00000000..ba30d3ad --- /dev/null +++ b/apps/web/ce/components/common/subscription/subscription-pill.tsx @@ -0,0 +1,7 @@ +import type { IWorkspace } from "@plane/types"; + +type TProps = { + workspace?: IWorkspace; +}; + +export const SubscriptionPill = (props: TProps) => <>; diff --git a/apps/web/ce/components/cycles/active-cycle/index.ts b/apps/web/ce/components/cycles/active-cycle/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/cycles/active-cycle/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/cycles/active-cycle/root.tsx b/apps/web/ce/components/cycles/active-cycle/root.tsx new file mode 100644 index 00000000..8ac33198 --- /dev/null +++ b/apps/web/ce/components/cycles/active-cycle/root.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useMemo } from "react"; +import { observer } from "mobx-react"; +import { Disclosure } from "@headlessui/react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Row } from "@plane/ui"; +// components +import { ActiveCycleStats } from "@/components/cycles/active-cycle/cycle-stats"; +import { ActiveCycleProductivity } from "@/components/cycles/active-cycle/productivity"; +import { ActiveCycleProgress } from "@/components/cycles/active-cycle/progress"; +import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details"; +import { CycleListGroupHeader } from "@/components/cycles/list/cycle-list-group-header"; +import { CyclesListItem } from "@/components/cycles/list/cycles-list-item"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import type { ActiveCycleIssueDetails } from "@/store/issue/cycle"; + +interface IActiveCycleDetails { + workspaceSlug: string; + projectId: string; + cycleId?: string; + showHeader?: boolean; +} + +export const ActiveCycleRoot: React.FC = observer((props) => { + const { workspaceSlug, projectId, cycleId: propsCycleId, showHeader = true } = props; + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentProjectActiveCycleId } = useCycle(); + // derived values + const cycleId = propsCycleId ?? currentProjectActiveCycleId; + const activeCycleResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/cycle/active" }); + // fetch cycle details + const { + handleFiltersUpdate, + cycle: activeCycle, + cycleIssueDetails, + } = useCyclesDetails({ workspaceSlug, projectId, cycleId }); + + const ActiveCyclesComponent = useMemo( + () => ( + <> + {!cycleId || !activeCycle ? ( + + ) : ( +
    + {cycleId && ( + + )} + +
    + + + +
    +
    +
    + )} + + ), + [cycleId, activeCycle, workspaceSlug, projectId, handleFiltersUpdate, cycleIssueDetails] + ); + + return ( + <> + {showHeader ? ( + + {({ open }) => ( + <> + + + + {ActiveCyclesComponent} + + )} + + ) : ( + <>{ActiveCyclesComponent} + )} + + ); +}); diff --git a/apps/web/ce/components/cycles/additional-actions.tsx b/apps/web/ce/components/cycles/additional-actions.tsx new file mode 100644 index 00000000..0fd9efb3 --- /dev/null +++ b/apps/web/ce/components/cycles/additional-actions.tsx @@ -0,0 +1,7 @@ +import type { FC } from "react"; +import { observer } from "mobx-react"; +type Props = { + cycleId: string; + projectId: string; +}; +export const CycleAdditionalActions: FC = observer(() => <>); diff --git a/apps/web/ce/components/cycles/analytics-sidebar/base.tsx b/apps/web/ce/components/cycles/analytics-sidebar/base.tsx new file mode 100644 index 00000000..37a07077 --- /dev/null +++ b/apps/web/ce/components/cycles/analytics-sidebar/base.tsx @@ -0,0 +1,86 @@ +"use client"; +import type { FC } from "react"; +import { Fragment } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import type { TCycleEstimateType } from "@plane/types"; +import { Loader } from "@plane/ui"; +import { getDate } from "@plane/utils"; +// components +import ProgressChart from "@/components/core/sidebar/progress-chart"; +import { validateCycleSnapshot } from "@/components/cycles/analytics-sidebar/issue-progress"; +import { EstimateTypeDropdown } from "@/components/cycles/dropdowns"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; + +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(); + const { t } = useTranslation(); + + // 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 ( +
    +
    + +
    +
    +
    + {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( + + + + ) : ( + + + + )} +
    +
    +
    + ); +}); diff --git a/apps/web/ce/components/cycles/analytics-sidebar/index.ts b/apps/web/ce/components/cycles/analytics-sidebar/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/cycles/analytics-sidebar/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/cycles/analytics-sidebar/root.tsx b/apps/web/ce/components/cycles/analytics-sidebar/root.tsx new file mode 100644 index 00000000..6be4361e --- /dev/null +++ b/apps/web/ce/components/cycles/analytics-sidebar/root.tsx @@ -0,0 +1,13 @@ +"use client"; +import type { FC } from "react"; +import React from "react"; +// components +import { SidebarChart } from "./base"; + +type Props = { + workspaceSlug: string; + projectId: string; + cycleId: string; +}; + +export const SidebarChartRoot: FC = (props) => ; diff --git a/apps/web/ce/components/cycles/end-cycle/index.ts b/apps/web/ce/components/cycles/end-cycle/index.ts new file mode 100644 index 00000000..2e60c456 --- /dev/null +++ b/apps/web/ce/components/cycles/end-cycle/index.ts @@ -0,0 +1,2 @@ +export * from "./modal"; +export * from "./use-end-cycle"; diff --git a/apps/web/ce/components/cycles/end-cycle/modal.tsx b/apps/web/ce/components/cycles/end-cycle/modal.tsx new file mode 100644 index 00000000..754c84f9 --- /dev/null +++ b/apps/web/ce/components/cycles/end-cycle/modal.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +interface Props { + isOpen: boolean; + handleClose: () => void; + cycleId: string; + projectId: string; + workspaceSlug: string; + transferrableIssuesCount: number; + cycleName: string; +} + +export const EndCycleModal: React.FC = () => <>; diff --git a/apps/web/ce/components/cycles/end-cycle/use-end-cycle.tsx b/apps/web/ce/components/cycles/end-cycle/use-end-cycle.tsx new file mode 100644 index 00000000..c1bf6261 --- /dev/null +++ b/apps/web/ce/components/cycles/end-cycle/use-end-cycle.tsx @@ -0,0 +1,7 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useEndCycle = (isCurrentCycle: boolean) => ({ + isEndCycleModalOpen: false, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setEndCycleModalOpen: (value: boolean) => {}, + endCycleContextMenu: undefined, +}); diff --git a/apps/web/ce/components/cycles/index.ts b/apps/web/ce/components/cycles/index.ts new file mode 100644 index 00000000..1da11502 --- /dev/null +++ b/apps/web/ce/components/cycles/index.ts @@ -0,0 +1,4 @@ +export * from "./active-cycle"; +export * from "./analytics-sidebar"; +export * from "./additional-actions"; +export * from "./end-cycle"; diff --git a/apps/web/ce/components/de-dupe/de-dupe-button.tsx b/apps/web/ce/components/de-dupe/de-dupe-button.tsx new file mode 100644 index 00000000..94d800ca --- /dev/null +++ b/apps/web/ce/components/de-dupe/de-dupe-button.tsx @@ -0,0 +1,16 @@ +"use client"; +import type { FC } from "react"; +import React 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/apps/web/ce/components/de-dupe/duplicate-modal/index.ts b/apps/web/ce/components/de-dupe/duplicate-modal/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/de-dupe/duplicate-modal/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx b/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx new file mode 100644 index 00000000..55eb084f --- /dev/null +++ b/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx @@ -0,0 +1,16 @@ +"use-client"; + +import type { FC } from "react"; +// types +import type { 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/apps/web/ce/components/de-dupe/duplicate-popover/index.ts b/apps/web/ce/components/de-dupe/duplicate-popover/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/de-dupe/duplicate-popover/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx b/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx new file mode 100644 index 00000000..3dd227cc --- /dev/null +++ b/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx @@ -0,0 +1,24 @@ +"use client"; + +import type { FC } from "react"; +import React from "react"; +import { observer } from "mobx-react"; +// types +import type { TDeDupeIssue } from "@plane/types"; +import type { TIssueOperations } from "@/components/issues/issue-detail"; + +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 {} = props; + return <>; +}); diff --git a/apps/web/ce/components/de-dupe/issue-block/button-label.tsx b/apps/web/ce/components/de-dupe/issue-block/button-label.tsx new file mode 100644 index 00000000..d6e36345 --- /dev/null +++ b/apps/web/ce/components/de-dupe/issue-block/button-label.tsx @@ -0,0 +1,13 @@ +"use client"; + +import type { FC } from "react"; + +type TDeDupeIssueButtonLabelProps = { + isOpen: boolean; + buttonLabel: string; +}; + +export const DeDupeIssueButtonLabel: FC = (props) => { + const { isOpen, buttonLabel } = props; + return <>; +}; diff --git a/apps/web/ce/components/editor/embeds/index.ts b/apps/web/ce/components/editor/embeds/index.ts new file mode 100644 index 00000000..8146e94d --- /dev/null +++ b/apps/web/ce/components/editor/embeds/index.ts @@ -0,0 +1 @@ +export * from "./mentions"; diff --git a/apps/web/ce/components/editor/embeds/mentions/index.ts b/apps/web/ce/components/editor/embeds/mentions/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/editor/embeds/mentions/root.tsx b/apps/web/ce/components/editor/embeds/mentions/root.tsx new file mode 100644 index 00000000..23f15fe2 --- /dev/null +++ b/apps/web/ce/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,4 @@ +// plane editor +import type { TMentionComponentProps } from "@plane/editor"; + +export const EditorAdditionalMentionsRoot: React.FC = () => null; diff --git a/apps/web/ce/components/editor/index.ts b/apps/web/ce/components/editor/index.ts new file mode 100644 index 00000000..cf8352ae --- /dev/null +++ b/apps/web/ce/components/editor/index.ts @@ -0,0 +1 @@ +export * from "./embeds"; diff --git a/apps/web/ce/components/epics/epic-modal/index.ts b/apps/web/ce/components/epics/epic-modal/index.ts new file mode 100644 index 00000000..031608e2 --- /dev/null +++ b/apps/web/ce/components/epics/epic-modal/index.ts @@ -0,0 +1 @@ +export * from "./modal"; diff --git a/apps/web/ce/components/epics/epic-modal/modal.tsx b/apps/web/ce/components/epics/epic-modal/modal.tsx new file mode 100644 index 00000000..f1fec6ba --- /dev/null +++ b/apps/web/ce/components/epics/epic-modal/modal.tsx @@ -0,0 +1,20 @@ +"use client"; +import type { FC } from "react"; +import React from "react"; +import type { TIssue } from "@plane/types"; + +export interface EpicModalProps { + data?: Partial; + isOpen: boolean; + onClose: () => void; + beforeFormSubmit?: () => Promise; + onSubmit?: (res: TIssue) => Promise; + fetchIssueDetails?: boolean; + primaryButtonText?: { + default: string; + loading: string; + }; + isProjectSelectionDisabled?: boolean; +} + +export const CreateUpdateEpicModal: FC = (props) => <>; diff --git a/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx b/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx new file mode 100644 index 00000000..936fdc62 --- /dev/null +++ b/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx @@ -0,0 +1,49 @@ +import type { FC } from "react"; +import { observer } from "mobx-react"; +import { Pen, Trash } from "lucide-react"; +import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; +import { Tooltip } from "@plane/propel/tooltip"; +// components +import { ProIcon } from "@/components/common/pro-icon"; + +type TEstimateListItem = { + estimateId: string; + isAdmin: boolean; + isEstimateEnabled: boolean; + isEditable: boolean; + onEditClick?: (estimateId: string) => void; + onDeleteClick?: (estimateId: string) => void; +}; + +export const EstimateListItemButtons: FC = observer((props) => { + const { estimateId, isAdmin, isEditable, onDeleteClick } = props; + + if (!isAdmin || !isEditable) return <>; + return ( +
    + +
    Upgrade
    + +
    + } + position="top" + > + + + +
    + ); +}); diff --git a/apps/web/ce/components/estimates/helper.tsx b/apps/web/ce/components/estimates/helper.tsx new file mode 100644 index 00000000..71b5be8a --- /dev/null +++ b/apps/web/ce/components/estimates/helper.tsx @@ -0,0 +1,13 @@ +import type { TEstimateSystemKeys } from "@plane/types"; +import { EEstimateSystem } from "@plane/types"; + +export const isEstimateSystemEnabled = (key: TEstimateSystemKeys) => { + switch (key) { + case EEstimateSystem.POINTS: + return true; + case EEstimateSystem.CATEGORIES: + return true; + default: + return false; + } +}; diff --git a/apps/web/ce/components/estimates/index.ts b/apps/web/ce/components/estimates/index.ts new file mode 100644 index 00000000..4852874e --- /dev/null +++ b/apps/web/ce/components/estimates/index.ts @@ -0,0 +1,4 @@ +export * from "./estimate-list-item-buttons"; +export * from "./update"; +export * from "./points"; +export * from "./helper"; diff --git a/apps/web/ce/components/estimates/inputs/index.ts b/apps/web/ce/components/estimates/inputs/index.ts new file mode 100644 index 00000000..49b9c68c --- /dev/null +++ b/apps/web/ce/components/estimates/inputs/index.ts @@ -0,0 +1 @@ +export * from "./time-input"; diff --git a/apps/web/ce/components/estimates/inputs/time-input.tsx b/apps/web/ce/components/estimates/inputs/time-input.tsx new file mode 100644 index 00000000..39341ac3 --- /dev/null +++ b/apps/web/ce/components/estimates/inputs/time-input.tsx @@ -0,0 +1,8 @@ +import type { FC } from "react"; + +export type TEstimateTimeInputProps = { + value?: number; + handleEstimateInputValue: (value: string) => void; +}; + +export const EstimateTimeInput: FC = () => <>; diff --git a/apps/web/ce/components/estimates/points/delete.tsx b/apps/web/ce/components/estimates/points/delete.tsx new file mode 100644 index 00000000..522a28c9 --- /dev/null +++ b/apps/web/ce/components/estimates/points/delete.tsx @@ -0,0 +1,19 @@ +"use client"; + +import type { FC } from "react"; + +import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; + +export type TEstimatePointDelete = { + workspaceSlug: string; + projectId: string; + estimateId: string; + estimatePointId: string; + estimatePoints: TEstimatePointsObject[]; + callback: () => void; + estimatePointError?: TEstimateTypeErrorObject | undefined; + handleEstimatePointError?: (newValue: string, message: string | undefined, mode?: "add" | "delete") => void; + estimateSystem: TEstimateSystemKeys; +}; + +export const EstimatePointDelete: FC = () => <>; diff --git a/apps/web/ce/components/estimates/points/index.ts b/apps/web/ce/components/estimates/points/index.ts new file mode 100644 index 00000000..fe722bd2 --- /dev/null +++ b/apps/web/ce/components/estimates/points/index.ts @@ -0,0 +1 @@ +export * from "./delete"; diff --git a/apps/web/ce/components/estimates/update/index.ts b/apps/web/ce/components/estimates/update/index.ts new file mode 100644 index 00000000..031608e2 --- /dev/null +++ b/apps/web/ce/components/estimates/update/index.ts @@ -0,0 +1 @@ +export * from "./modal"; diff --git a/apps/web/ce/components/estimates/update/modal.tsx b/apps/web/ce/components/estimates/update/modal.tsx new file mode 100644 index 00000000..fd431719 --- /dev/null +++ b/apps/web/ce/components/estimates/update/modal.tsx @@ -0,0 +1,14 @@ +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; + +type TUpdateEstimateModal = { + workspaceSlug: string; + projectId: string; + estimateId: string | undefined; + isOpen: boolean; + handleClose: () => void; +}; + +export const UpdateEstimateModal: FC = observer(() => <>); diff --git a/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx b/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx new file mode 100644 index 00000000..ecab672d --- /dev/null +++ b/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx @@ -0,0 +1,59 @@ +import type { FC } from "react"; +// components +import type { IBlockUpdateData, IGanttBlock } from "@plane/types"; +import RenderIfVisible from "@/components/core/render-if-visible-HOC"; +// hooks +import { BlockRow } from "@/components/gantt-chart/blocks/block-row"; +import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; +// types + +export type GanttChartBlocksProps = { + blockIds: string[]; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => 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/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx b/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx new file mode 100644 index 00000000..593e8050 --- /dev/null +++ b/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx @@ -0,0 +1,53 @@ +import type { FC } from "react"; +// +import type { IBlockUpdateDependencyData } from "@plane/types"; +import { GanttChartBlock } from "@/components/gantt-chart/blocks/block"; + +export type GanttChartBlocksProps = { + blockIds: string[]; + blockToRender: (data: any) => React.ReactNode; + enableBlockLeftResize: boolean | ((blockId: string) => boolean); + enableBlockRightResize: boolean | ((blockId: string) => boolean); + enableBlockMove: boolean | ((blockId: string) => boolean); + ganttContainerRef: React.RefObject; + showAllBlocks: boolean; + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise; + enableDependency: boolean | ((blockId: string) => boolean); +}; + +export const GanttChartBlocksList: FC = (props) => { + const { + blockIds, + blockToRender, + enableBlockLeftResize, + enableBlockRightResize, + enableBlockMove, + ganttContainerRef, + showAllBlocks, + updateBlockDates, + enableDependency, + } = props; + + return ( + <> + {blockIds?.map((blockId) => ( + + ))} + + ); +}; diff --git a/apps/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts new file mode 100644 index 00000000..c2f4f8ae --- /dev/null +++ b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts @@ -0,0 +1,2 @@ +export * from "./left-draggable"; +export * from "./right-draggable"; diff --git a/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx new file mode 100644 index 00000000..a68118b6 --- /dev/null +++ b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx @@ -0,0 +1,9 @@ +import type { RefObject } from "react"; +import type { IGanttBlock } from "@plane/types"; + +type LeftDependencyDraggableProps = { + block: IGanttBlock; + ganttContainerRef: RefObject; +}; + +export const LeftDependencyDraggable = (props: LeftDependencyDraggableProps) => <>; diff --git a/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx new file mode 100644 index 00000000..7a36ec9b --- /dev/null +++ b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx @@ -0,0 +1,8 @@ +import type { RefObject } from "react"; +import type { IGanttBlock } from "@plane/types"; + +type RightDependencyDraggableProps = { + block: IGanttBlock; + ganttContainerRef: RefObject; +}; +export const RightDependencyDraggable = (props: RightDependencyDraggableProps) => <>; diff --git a/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx b/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx new file mode 100644 index 00000000..e52805e1 --- /dev/null +++ b/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx @@ -0,0 +1,9 @@ +import type { FC } from "react"; + +type Props = { + isEpic?: boolean; +}; +export const TimelineDependencyPaths: FC = (props) => { + const { isEpic = false } = props; + return <>; +}; diff --git a/apps/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx b/apps/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx new file mode 100644 index 00000000..3b4aa350 --- /dev/null +++ b/apps/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx @@ -0,0 +1 @@ +export const TimelineDraggablePath = () => <>; diff --git a/apps/web/ce/components/gantt-chart/dependency/index.ts b/apps/web/ce/components/gantt-chart/dependency/index.ts new file mode 100644 index 00000000..91d0018d --- /dev/null +++ b/apps/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/apps/web/ce/components/gantt-chart/index.ts b/apps/web/ce/components/gantt-chart/index.ts new file mode 100644 index 00000000..d08e0f7d --- /dev/null +++ b/apps/web/ce/components/gantt-chart/index.ts @@ -0,0 +1 @@ +export * from "./dependency"; diff --git a/apps/web/ce/components/global/index.ts b/apps/web/ce/components/global/index.ts new file mode 100644 index 00000000..c87c8ae0 --- /dev/null +++ b/apps/web/ce/components/global/index.ts @@ -0,0 +1,2 @@ +export * from "./version-number"; +export * from "./product-updates-header"; diff --git a/apps/web/ce/components/global/product-updates-header.tsx b/apps/web/ce/components/global/product-updates-header.tsx new file mode 100644 index 00000000..26d4ebbd --- /dev/null +++ b/apps/web/ce/components/global/product-updates-header.tsx @@ -0,0 +1,28 @@ +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +import { PlaneLogo } from "@plane/propel/icons"; +// helpers +import { cn } from "@plane/utils"; +// package.json +import packageJson from "package.json"; + +export const ProductUpdatesHeader = observer(() => { + const { t } = useTranslation(); + return ( +
    +
    +
    {t("whats_new")}
    +
    + {t("version")}: v{packageJson.version} +
    +
    +
    + +
    +
    + ); +}); diff --git a/apps/web/ce/components/global/version-number.tsx b/apps/web/ce/components/global/version-number.tsx new file mode 100644 index 00000000..f75bb10b --- /dev/null +++ b/apps/web/ce/components/global/version-number.tsx @@ -0,0 +1,12 @@ +// assets +import { useTranslation } from "@plane/i18n"; +import packageJson from "package.json"; + +export const PlaneVersionNumber: React.FC = () => { + const { t } = useTranslation(); + return ( + + {t("version")}: v{packageJson.version} + + ); +}; diff --git a/apps/web/ce/components/home/header.tsx b/apps/web/ce/components/home/header.tsx new file mode 100644 index 00000000..c95736c9 --- /dev/null +++ b/apps/web/ce/components/home/header.tsx @@ -0,0 +1 @@ +export const HomePageHeader = () => <>; diff --git a/apps/web/ce/components/home/index.ts b/apps/web/ce/components/home/index.ts new file mode 100644 index 00000000..d64a7a98 --- /dev/null +++ b/apps/web/ce/components/home/index.ts @@ -0,0 +1 @@ +export * from "./peek-overviews"; diff --git a/apps/web/ce/components/home/peek-overviews.tsx b/apps/web/ce/components/home/peek-overviews.tsx new file mode 100644 index 00000000..05544302 --- /dev/null +++ b/apps/web/ce/components/home/peek-overviews.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { IssuePeekOverview } from "@/components/issues/peek-overview"; + +export const HomePeekOverviewsRoot = () => ( + <> + + +); diff --git a/apps/web/ce/components/inbox/source-pill.tsx b/apps/web/ce/components/inbox/source-pill.tsx new file mode 100644 index 00000000..77d3038c --- /dev/null +++ b/apps/web/ce/components/inbox/source-pill.tsx @@ -0,0 +1,7 @@ +import type { EInboxIssueSource } from "@plane/types"; + +export type TInboxSourcePill = { + source: EInboxIssueSource; +}; + +export const InboxSourcePill = (props: TInboxSourcePill) => <>; diff --git a/apps/web/ce/components/instance/index.ts b/apps/web/ce/components/instance/index.ts new file mode 100644 index 00000000..960f954e --- /dev/null +++ b/apps/web/ce/components/instance/index.ts @@ -0,0 +1 @@ +export * from "./maintenance-message"; diff --git a/apps/web/ce/components/instance/maintenance-message.tsx b/apps/web/ce/components/instance/maintenance-message.tsx new file mode 100644 index 00000000..067c95f5 --- /dev/null +++ b/apps/web/ce/components/instance/maintenance-message.tsx @@ -0,0 +1,37 @@ +export const MaintenanceMessage = () => { + const linkMap = [ + { + key: "mail_to", + label: "Contact Support", + value: "mailto:support@plane.so", + }, + ]; + + return ( + <> +
    +

    + 🚧 Looks like Plane didn't start up correctly! +

    + + Some services might have failed to start. Please check your container logs to identify and resolve the issue. + If you're stuck, reach out to our support team for more help. + +
    +
    + {linkMap.map((link) => ( + + ))} +
    + + ); +}; diff --git a/apps/web/ce/components/issues/bulk-operations/index.ts b/apps/web/ce/components/issues/bulk-operations/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/issues/bulk-operations/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/issues/bulk-operations/root.tsx b/apps/web/ce/components/issues/bulk-operations/root.tsx new file mode 100644 index 00000000..fe7fcfe1 --- /dev/null +++ b/apps/web/ce/components/issues/bulk-operations/root.tsx @@ -0,0 +1,21 @@ +import { observer } from "mobx-react"; +// components +import { BulkOperationsUpgradeBanner } from "@/components/issues/bulk-operations/upgrade-banner"; +// hooks +import { useMultipleSelectStore } from "@/hooks/store/use-multiple-select-store"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; + +type Props = { + className?: string; + selectionHelpers: TSelectionHelper; +}; + +export const IssueBulkOperationsRoot: React.FC = observer((props) => { + const { className, selectionHelpers } = props; + // store hooks + const { isSelectionActive } = useMultipleSelectStore(); + + if (!isSelectionActive || selectionHelpers.isSelectionDisabled) return null; + + return ; +}); diff --git a/apps/web/ce/components/issues/filters/applied-filters/issue-types.tsx b/apps/web/ce/components/issues/filters/applied-filters/issue-types.tsx new file mode 100644 index 00000000..fd2daf9c --- /dev/null +++ b/apps/web/ce/components/issues/filters/applied-filters/issue-types.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { observer } from "mobx-react"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedIssueTypeFilters: React.FC = observer(() => null); diff --git a/apps/web/ce/components/issues/filters/issue-types.tsx b/apps/web/ce/components/issues/filters/issue-types.tsx new file mode 100644 index 00000000..4d983bb7 --- /dev/null +++ b/apps/web/ce/components/issues/filters/issue-types.tsx @@ -0,0 +1,12 @@ +"use client"; + +import type React from "react"; +import { observer } from "mobx-react"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterIssueTypes: React.FC = observer(() => null); diff --git a/apps/web/ce/components/issues/filters/team-project.tsx b/apps/web/ce/components/issues/filters/team-project.tsx new file mode 100644 index 00000000..c8975deb --- /dev/null +++ b/apps/web/ce/components/issues/filters/team-project.tsx @@ -0,0 +1,12 @@ +"use client"; + +import type React from "react"; +import { observer } from "mobx-react"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterTeamProjects: React.FC = observer(() => null); diff --git a/apps/web/ce/components/issues/header.tsx b/apps/web/ce/components/issues/header.tsx new file mode 100644 index 00000000..58f699be --- /dev/null +++ b/apps/web/ce/components/issues/header.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { Circle, ExternalLink } from "lucide-react"; +// plane imports +import { + EUserPermissions, + EUserPermissionsLevel, + SPACE_BASE_PATH, + SPACE_BASE_URL, + WORK_ITEM_TRACKER_ELEMENTS, + EProjectFeatureKey, +} from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { Tooltip } from "@plane/propel/tooltip"; +import { EIssuesStoreType } from "@plane/types"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { CountChip } from "@/components/common/count-chip"; +// constants +import { HeaderFilters } from "@/components/issues/filters"; +// helpers +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web +import { CommonProjectBreadcrumbs } from "../breadcrumbs/common"; + +export const IssuesHeader = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; + // store hooks + const { + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.PROJECT); + // i18n + const { t } = useTranslation(); + + const { currentProjectDetails, loader } = useProject(); + + const { toggleCreateIssueModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + const { isMobile } = usePlatformOS(); + + const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH; + const publishedURL = `${SPACE_APP_URL}/issues/${currentProjectDetails?.anchor}`; + + const issuesCount = getGroupIssueCount(undefined, undefined, false); + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + return ( +
    + +
    + router.back()} isLoading={loader === "init-loader"} className="flex-grow-0"> + + + {issuesCount && issuesCount > 0 ? ( + 1 ? "work items" : "work item"} in this project`} + position="bottom" + > + + + ) : null} +
    + {currentProjectDetails?.anchor ? ( + + + {t("workspace_projects.network.public.title")} + + + ) : ( + <> + )} +
    + +
    + +
    + {canUserCreateIssue ? ( + + ) : ( + <> + )} +
    +
    + ); +}); diff --git a/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx b/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx new file mode 100644 index 00000000..b0f33932 --- /dev/null +++ b/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx @@ -0,0 +1,14 @@ +import type { FC } from "react"; +// plane types +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; + +export type TWorkItemAdditionalWidgetActionButtonsProps = { + disabled: boolean; + hideWidgets: TWorkItemWidgets[]; + issueServiceType: TIssueServiceType; + projectId: string; + workItemId: string; + workspaceSlug: string; +}; + +export const WorkItemAdditionalWidgetActionButtons: FC = () => null; diff --git a/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx b/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx new file mode 100644 index 00000000..2632987f --- /dev/null +++ b/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx @@ -0,0 +1,14 @@ +import type { FC } from "react"; +// plane types +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; + +export type TWorkItemAdditionalWidgetCollapsiblesProps = { + disabled: boolean; + hideWidgets: TWorkItemWidgets[]; + issueServiceType: TIssueServiceType; + projectId: string; + workItemId: string; + workspaceSlug: string; +}; + +export const WorkItemAdditionalWidgetCollapsibles: FC = () => null; diff --git a/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx b/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx new file mode 100644 index 00000000..b478cf89 --- /dev/null +++ b/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx @@ -0,0 +1,13 @@ +import type { FC } from "react"; +// plane types +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; + +export type TWorkItemAdditionalWidgetModalsProps = { + hideWidgets: TWorkItemWidgets[]; + issueServiceType: TIssueServiceType; + projectId: string; + workItemId: string; + workspaceSlug: string; +}; + +export const WorkItemAdditionalWidgetModals: FC = () => null; diff --git a/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx b/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx new file mode 100644 index 00000000..448deabc --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx @@ -0,0 +1,13 @@ +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; + +export type TAdditionalActivityRoot = { + activityId: string; + showIssue?: boolean; + ends: "top" | "bottom" | undefined; + field: string | undefined; +}; + +export const AdditionalActivityRoot: FC = observer(() => <>); diff --git a/apps/web/ce/components/issues/issue-details/additional-properties.tsx b/apps/web/ce/components/issues/issue-details/additional-properties.tsx new file mode 100644 index 00000000..2e0a1470 --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/additional-properties.tsx @@ -0,0 +1,14 @@ +import type { FC } from "react"; +import React from "react"; +// plane imports + +export type TWorkItemAdditionalSidebarProperties = { + workItemId: string; + workItemTypeId: string | null; + projectId: string; + workspaceSlug: string; + isEditable: boolean; + isPeekView?: boolean; +}; + +export const WorkItemAdditionalSidebarProperties: FC = () => <>; diff --git a/apps/web/ce/components/issues/issue-details/index.ts b/apps/web/ce/components/issues/issue-details/index.ts new file mode 100644 index 00000000..c5724f0f --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/index.ts @@ -0,0 +1,7 @@ +export * from "./issue-identifier"; +export * from "./issue-properties-activity"; +export * from "./issue-type-switcher"; +export * from "./issue-type-activity"; +export * from "./parent-select-root"; +export * from "./issue-creator"; +export * from "./additional-activity-root"; diff --git a/apps/web/ce/components/issues/issue-details/issue-creator.tsx b/apps/web/ce/components/issues/issue-details/issue-creator.tsx new file mode 100644 index 00000000..f3435e28 --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/issue-creator.tsx @@ -0,0 +1,36 @@ +import type { FC } from "react"; +import Link from "next/link"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; + +type TIssueUser = { + activityId: string; + customUserName?: string; +}; + +export const IssueCreatorDisplay: FC = (props) => { + const { activityId, customUserName } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + + return ( + <> + {customUserName ? ( + {customUserName || "Plane"} + ) : ( + + {activity.actor_detail?.display_name} + + )} + + ); +}; diff --git a/apps/web/ce/components/issues/issue-details/issue-identifier.tsx b/apps/web/ce/components/issues/issue-details/issue-identifier.tsx new file mode 100644 index 00000000..c81b0075 --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -0,0 +1,105 @@ +import type { FC } from "react"; +import { observer } from "mobx-react"; +// types +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { IIssueDisplayProperties } from "@plane/types"; +// ui +// helpers +import { cn } from "@plane/utils"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useProject } from "@/hooks/store/use-project"; + +type TIssueIdentifierBaseProps = { + projectId: string; + size?: "xs" | "sm" | "md" | "lg"; + textContainerClassName?: string; + displayProperties?: IIssueDisplayProperties | undefined; + enableClickToCopyIdentifier?: boolean; +}; + +type TIssueIdentifierFromStore = TIssueIdentifierBaseProps & { + issueId: string; +}; + +type TIssueIdentifierWithDetails = TIssueIdentifierBaseProps & { + issueTypeId?: string | null; + projectIdentifier: string; + issueSequenceId: string | number; +}; + +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; + textContainerClassName?: string; +}; + +export const IdentifierText: React.FC = (props) => { + const { identifier, enableClickToCopyIdentifier = false, textContainerClassName } = props; + // handlers + const handleCopyIssueIdentifier = () => { + if (enableClickToCopyIdentifier) { + navigator.clipboard.writeText(identifier).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Work item ID copied to clipboard", + }); + }); + } + }; + + return ( + + + {identifier} + + + ); +}; + +export const IssueIdentifier: React.FC = observer((props) => { + const { projectId, textContainerClassName, displayProperties, enableClickToCopyIdentifier = false } = props; + // store hooks + const { getProjectIdentifierById } = useProject(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // Determine if the component is using store data or not + const isUsingStoreData = "issueId" in props; + // derived values + const issue = isUsingStoreData ? getIssueById(props.issueId) : null; + const projectIdentifier = isUsingStoreData ? getProjectIdentifierById(projectId) : props.projectIdentifier; + const issueSequenceId = isUsingStoreData ? issue?.sequence_id : props.issueSequenceId; + const shouldRenderIssueID = displayProperties ? displayProperties.key : true; + + if (!shouldRenderIssueID) return null; + + return ( +
    + +
    + ); +}); diff --git a/apps/web/ce/components/issues/issue-details/issue-properties-activity/index.ts b/apps/web/ce/components/issues/issue-details/issue-properties-activity/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/issue-properties-activity/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx b/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx new file mode 100644 index 00000000..6aeb6eda --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx @@ -0,0 +1,8 @@ +import type { FC } from "react"; + +type TIssueAdditionalPropertiesActivity = { + activityId: string; + ends: "top" | "bottom" | undefined; +}; + +export const IssueAdditionalPropertiesActivity: FC = () => <>; diff --git a/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx b/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx new file mode 100644 index 00000000..5796555c --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx @@ -0,0 +1,8 @@ +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; + +export type TIssueTypeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueTypeActivity: FC = observer(() => <>); diff --git a/apps/web/ce/components/issues/issue-details/issue-type-switcher.tsx b/apps/web/ce/components/issues/issue-details/issue-type-switcher.tsx new file mode 100644 index 00000000..09245289 --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/issue-type-switcher.tsx @@ -0,0 +1,24 @@ +import { observer } from "mobx-react"; +// store hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +// plane web components +import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; + +export type TIssueTypeSwitcherProps = { + issueId: string; + disabled: boolean; +}; + +export const IssueTypeSwitcher: React.FC = observer((props) => { + const { issueId } = props; + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + // derived values + const issue = getIssueById(issueId); + + if (!issue || !issue.project_id) return <>; + + return ; +}); diff --git a/apps/web/ce/components/issues/issue-details/parent-select-root.tsx b/apps/web/ce/components/issues/issue-details/parent-select-root.tsx new file mode 100644 index 00000000..54ae1168 --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/parent-select-root.tsx @@ -0,0 +1,83 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +// components +import type { TIssueOperations } from "@/components/issues/issue-detail"; +import { IssueParentSelect } from "@/components/issues/issue-detail/parent-select"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; + +type TIssueParentSelect = { + className?: string; + disabled?: boolean; + issueId: string; + issueOperations: TIssueOperations; + projectId: string; + workspaceSlug: string; +}; + +export const IssueParentSelectRoot: React.FC = observer((props) => { + const { issueId, issueOperations, projectId, workspaceSlug } = props; + const { t } = useTranslation(); + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { + toggleParentIssueModal, + removeSubIssue, + subIssues: { setSubIssueHelpers, fetchSubIssues }, + } = useIssueDetail(); + + // derived values + const issue = getIssueById(issueId); + const parentIssue = issue?.parent_id ? getIssueById(issue.parent_id) : undefined; + + const handleParentIssue = async (_issueId: string | null = null) => { + try { + await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }); + await issueOperations.fetch(workspaceSlug, projectId, issueId, false); + if (_issueId) await fetchSubIssues(workspaceSlug, projectId, _issueId); + toggleParentIssueModal(null); + } catch (error) { + console.error("something went wrong while fetching the issue"); + } + }; + + const handleRemoveSubIssue = async ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueId: string + ) => { + try { + setSubIssueHelpers(parentIssueId, "issue_loader", issueId); + await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId); + await fetchSubIssues(workspaceSlug, projectId, parentIssueId); + setSubIssueHelpers(parentIssueId, "issue_loader", issueId); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("common.error.label"), + message: t("common.something_went_wrong"), + }); + } + }; + + const workItemLink = `/${workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue?.id}`; + + if (!issue) return <>; + + return ( + + ); +}); diff --git a/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx b/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx new file mode 100644 index 00000000..1c397c57 --- /dev/null +++ b/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx @@ -0,0 +1,10 @@ +import type { FC } from "react"; +import React from "react"; +import type { IIssueDisplayProperties, TIssue } from "@plane/types"; + +export type TWorkItemLayoutAdditionalProperties = { + displayProperties: IIssueDisplayProperties; + issue: TIssue; +}; + +export const WorkItemLayoutAdditionalProperties: FC = (props) => <>; diff --git a/apps/web/ce/components/issues/issue-layouts/empty-states/index.ts b/apps/web/ce/components/issues/issue-layouts/empty-states/index.ts new file mode 100644 index 00000000..319b4c68 --- /dev/null +++ b/apps/web/ce/components/issues/issue-layouts/empty-states/index.ts @@ -0,0 +1,2 @@ +export * from "./team-issues"; +export * from "./team-view-issues"; diff --git a/apps/web/ce/components/issues/issue-layouts/empty-states/team-issues.tsx b/apps/web/ce/components/issues/issue-layouts/empty-states/team-issues.tsx new file mode 100644 index 00000000..1e05f40c --- /dev/null +++ b/apps/web/ce/components/issues/issue-layouts/empty-states/team-issues.tsx @@ -0,0 +1,3 @@ +import { observer } from "mobx-react"; + +export const TeamEmptyState: React.FC = observer(() => <>); diff --git a/apps/web/ce/components/issues/issue-layouts/empty-states/team-project.tsx b/apps/web/ce/components/issues/issue-layouts/empty-states/team-project.tsx new file mode 100644 index 00000000..95adbf9a --- /dev/null +++ b/apps/web/ce/components/issues/issue-layouts/empty-states/team-project.tsx @@ -0,0 +1,3 @@ +import { observer } from "mobx-react"; + +export const TeamProjectWorkItemEmptyState: React.FC = observer(() => <>); diff --git a/apps/web/ce/components/issues/issue-layouts/empty-states/team-view-issues.tsx b/apps/web/ce/components/issues/issue-layouts/empty-states/team-view-issues.tsx new file mode 100644 index 00000000..03b546be --- /dev/null +++ b/apps/web/ce/components/issues/issue-layouts/empty-states/team-view-issues.tsx @@ -0,0 +1,3 @@ +import { observer } from "mobx-react"; + +export const TeamViewEmptyState: React.FC = observer(() => <>); diff --git a/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx b/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx new file mode 100644 index 00000000..b6e80910 --- /dev/null +++ b/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx @@ -0,0 +1,14 @@ +"use client"; + +import type { FC } from "react"; +import React from "react"; + +type Props = { + issueId: string; + className?: string; + size?: number; + showProgressText?: boolean; + showLabel?: boolean; +}; + +export const IssueStats: FC = (props) => <>; diff --git a/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx new file mode 100644 index 00000000..7f4bb031 --- /dev/null +++ b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx @@ -0,0 +1,22 @@ +import type { Copy } from "lucide-react"; +import type { TContextMenuItem } from "@plane/ui"; + +export interface CopyMenuHelperProps { + baseItem: { + key: string; + title: string; + icon: typeof Copy; + action: () => void; + shouldRender: boolean; + }; + activeLayout: string; + setCreateUpdateIssueModal: (open: boolean) => void; + setDuplicateWorkItemModal?: (open: boolean) => void; + workspaceSlug?: string; +} + +export const createCopyMenuWithDuplication = (props: CopyMenuHelperProps): TContextMenuItem => { + const { baseItem } = props; + + return baseItem; +}; diff --git a/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx new file mode 100644 index 00000000..761317a0 --- /dev/null +++ b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx @@ -0,0 +1,11 @@ +import type { FC } from "react"; + +type TDuplicateWorkItemModalProps = { + workItemId: string; + onClose: () => void; + isOpen: boolean; + workspaceSlug: string; + projectId: string; +}; + +export const DuplicateWorkItemModal: FC = () => <>; diff --git a/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts new file mode 100644 index 00000000..470ae918 --- /dev/null +++ b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts @@ -0,0 +1,2 @@ +export * from "./duplicate-modal"; +export * from "./copy-menu-helper"; diff --git a/apps/web/ce/components/issues/issue-layouts/utils.tsx b/apps/web/ce/components/issues/issue-layouts/utils.tsx new file mode 100644 index 00000000..61a32c4f --- /dev/null +++ b/apps/web/ce/components/issues/issue-layouts/utils.tsx @@ -0,0 +1,99 @@ +import type { FC } from "react"; +import { + CalendarCheck2, + CalendarClock, + CalendarDays, + LayersIcon, + Link2, + Paperclip, + Signal, + Tag, + Triangle, + Users, +} from "lucide-react"; +// types +import type { ISvgIcons } from "@plane/propel/icons"; +import { CycleIcon, DoubleCircleIcon, ModuleIcon } from "@plane/propel/icons"; +import type { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types"; +// components +import { + SpreadsheetAssigneeColumn, + SpreadsheetAttachmentColumn, + SpreadsheetCreatedOnColumn, + SpreadsheetDueDateColumn, + SpreadsheetEstimateColumn, + SpreadsheetLabelColumn, + SpreadsheetModuleColumn, + SpreadsheetCycleColumn, + SpreadsheetLinkColumn, + SpreadsheetPriorityColumn, + SpreadsheetStartDateColumn, + SpreadsheetStateColumn, + SpreadsheetSubIssueColumn, + SpreadsheetUpdatedOnColumn, +} from "@/components/issues/issue-layouts/spreadsheet/columns"; +// store +import { store } from "@/lib/store-context"; + +export type TGetScopeMemberIdsResult = { + memberIds: string[]; + includeNone: boolean; +}; + +export const getScopeMemberIds = ({ isWorkspaceLevel, projectId }: TGetColumns): TGetScopeMemberIdsResult => { + // store values + const { workspaceMemberIds } = store.memberRoot.workspace; + const { projectMemberIds } = store.memberRoot.project; + // derived values + const memberIds = workspaceMemberIds; + + if (isWorkspaceLevel) { + return { memberIds: memberIds ?? [], includeNone: true }; + } + + if (projectId || (projectMemberIds && projectMemberIds.length > 0)) { + const { getProjectMemberIds } = store.memberRoot.project; + const _projectMemberIds = projectId ? getProjectMemberIds(projectId, false) : projectMemberIds; + return { + memberIds: _projectMemberIds ?? [], + includeNone: true, + }; + } + + return { memberIds: [], includeNone: true }; +}; + +export const getTeamProjectColumns = (): IGroupByColumn[] | undefined => undefined; + +export const SpreadSheetPropertyIconMap: Record> = { + Users: Users, + CalenderDays: CalendarDays, + CalenderCheck2: CalendarCheck2, + Triangle: Triangle, + Tag: Tag, + ModuleIcon: ModuleIcon, + ContrastIcon: CycleIcon, + Signal: Signal, + CalendarClock: CalendarClock, + DoubleCircleIcon: DoubleCircleIcon, + Link2: Link2, + Paperclip: Paperclip, + LayersIcon: LayersIcon, +}; + +export const SPREADSHEET_COLUMNS: { [key in keyof IIssueDisplayProperties]: TSpreadsheetColumn } = { + assignee: SpreadsheetAssigneeColumn, + created_on: SpreadsheetCreatedOnColumn, + due_date: SpreadsheetDueDateColumn, + estimate: SpreadsheetEstimateColumn, + labels: SpreadsheetLabelColumn, + modules: SpreadsheetModuleColumn, + cycle: SpreadsheetCycleColumn, + link: SpreadsheetLinkColumn, + priority: SpreadsheetPriorityColumn, + start_date: SpreadsheetStartDateColumn, + state: SpreadsheetStateColumn, + sub_issue_count: SpreadsheetSubIssueColumn, + updated_on: SpreadsheetUpdatedOnColumn, + attachment_count: SpreadsheetAttachmentColumn, +}; diff --git a/apps/web/ce/components/issues/issue-modal/index.ts b/apps/web/ce/components/issues/issue-modal/index.ts new file mode 100644 index 00000000..304be8c9 --- /dev/null +++ b/apps/web/ce/components/issues/issue-modal/index.ts @@ -0,0 +1,3 @@ +export * from "./provider"; +export * from "./issue-type-select"; +export * from "./template-select"; diff --git a/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx b/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx new file mode 100644 index 00000000..ab73750e --- /dev/null +++ b/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx @@ -0,0 +1,26 @@ +import type { Control } from "react-hook-form"; +// plane imports +import type { EditorRefApi } from "@plane/editor"; +// types +import type { TBulkIssueProperties, TIssue } from "@plane/types"; + +export type TIssueFields = TIssue & TBulkIssueProperties; + +export type TIssueTypeDropdownVariant = "xs" | "sm"; + +export type TIssueTypeSelectProps> = { + control: Control; + projectId: string | null; + editorRef?: React.MutableRefObject; + disabled?: boolean; + variant?: TIssueTypeDropdownVariant; + placeholder?: string; + isRequired?: boolean; + renderChevron?: boolean; + dropDownContainerClassName?: string; + showMandatoryFieldInfo?: boolean; // Show info about mandatory fields + handleFormChange?: () => void; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const IssueTypeSelect = >(props: TIssueTypeSelectProps) => <>; diff --git a/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx b/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx new file mode 100644 index 00000000..091d0c7a --- /dev/null +++ b/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx @@ -0,0 +1,10 @@ +import type React from "react"; + +export type TWorkItemModalAdditionalPropertiesProps = { + isDraft?: boolean; + projectId: string | null; + workItemId: string | undefined; + workspaceSlug: string; +}; + +export const WorkItemModalAdditionalProperties: React.FC = () => null; diff --git a/apps/web/ce/components/issues/issue-modal/provider.tsx b/apps/web/ce/components/issues/issue-modal/provider.tsx new file mode 100644 index 00000000..bd623cdd --- /dev/null +++ b/apps/web/ce/components/issues/issue-modal/provider.tsx @@ -0,0 +1,53 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { ISearchIssueResponse, TIssue } from "@plane/types"; +// components +import { IssueModalContext } from "@/components/issues/issue-modal/context"; +// hooks +import { useUser } from "@/hooks/store/user/user-user"; + +export type TIssueModalProviderProps = { + templateId?: string; + dataForPreload?: Partial; + allowedProjectIds?: string[]; + children: React.ReactNode; +}; + +export const IssueModalProvider = observer((props: TIssueModalProviderProps) => { + const { children, allowedProjectIds } = props; + // states + const [selectedParentIssue, setSelectedParentIssue] = useState(null); + // store hooks + const { projectsWithCreatePermissions } = useUser(); + // derived values + const projectIdsWithCreatePermissions = Object.keys(projectsWithCreatePermissions ?? {}); + + return ( + {}, + isApplyingTemplate: false, + setIsApplyingTemplate: () => {}, + selectedParentIssue, + setSelectedParentIssue, + issuePropertyValues: {}, + setIssuePropertyValues: () => {}, + issuePropertyValueErrors: {}, + setIssuePropertyValueErrors: () => {}, + getIssueTypeIdOnProjectChange: () => null, + getActiveAdditionalPropertiesLength: () => 0, + handlePropertyValuesValidation: () => true, + handleCreateUpdatePropertyValues: () => Promise.resolve(), + handleProjectEntitiesFetch: () => Promise.resolve(), + handleTemplateChange: () => Promise.resolve(), + handleConvert: () => Promise.resolve(), + handleCreateSubWorkItem: () => Promise.resolve(), + }} + > + {children} + + ); +}); diff --git a/apps/web/ce/components/issues/issue-modal/template-select.tsx b/apps/web/ce/components/issues/issue-modal/template-select.tsx new file mode 100644 index 00000000..6d58bb33 --- /dev/null +++ b/apps/web/ce/components/issues/issue-modal/template-select.tsx @@ -0,0 +1,16 @@ +export type TWorkItemTemplateDropdownSize = "xs" | "sm"; + +export type TWorkItemTemplateSelect = { + projectId: string | null; + typeId: string | null; + disabled?: boolean; + size?: TWorkItemTemplateDropdownSize; + placeholder?: string; + renderChevron?: boolean; + dropDownContainerClassName?: string; + handleModalClose: () => void; + handleFormChange?: () => void; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const WorkItemTemplateSelect = (props: TWorkItemTemplateSelect) => <>; diff --git a/apps/web/ce/components/issues/quick-add/index.ts b/apps/web/ce/components/issues/quick-add/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/issues/quick-add/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/issues/quick-add/root.tsx b/apps/web/ce/components/issues/quick-add/root.tsx new file mode 100644 index 00000000..e01d4bd2 --- /dev/null +++ b/apps/web/ce/components/issues/quick-add/root.tsx @@ -0,0 +1,78 @@ +import type { FC } from "react"; +import { useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +import type { UseFormRegister, UseFormSetFocus } from "react-hook-form"; +// plane constants +// plane helpers +import { useOutsideClickDetector } from "@plane/hooks"; +// types +import type { TIssue } from "@plane/types"; +import { EIssueLayoutTypes } from "@plane/types"; +// components +import type { TQuickAddIssueForm } from "@/components/issues/issue-layouts/quick-add"; +import { + CalendarQuickAddIssueForm, + GanttQuickAddIssueForm, + KanbanQuickAddIssueForm, + ListQuickAddIssueForm, + SpreadsheetQuickAddIssueForm, +} from "@/components/issues/issue-layouts/quick-add"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import useKeypress from "@/hooks/use-keypress"; + +export type TQuickAddIssueFormRoot = { + isOpen: boolean; + layout: EIssueLayoutTypes; + prePopulatedData?: Partial; + projectId: string; + hasError?: boolean; + setFocus: UseFormSetFocus; + register: UseFormRegister; + onSubmit: () => void; + onClose: () => void; + isEpic: boolean; +}; + +export const QuickAddIssueFormRoot: FC = observer((props) => { + const { isOpen, layout, projectId, hasError = false, setFocus, register, onSubmit, onClose, isEpic } = props; + // store hooks + const { getProjectById } = useProject(); + // derived values + const projectDetail = getProjectById(projectId); + // refs + const ref = useRef(null); + // click detection + useKeypress("Escape", onClose); + useOutsideClickDetector(ref, onClose); + // set focus on name input + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + if (!projectDetail) return <>; + + const QUICK_ADD_ISSUE_FORMS: Record> = { + [EIssueLayoutTypes.LIST]: ListQuickAddIssueForm, + [EIssueLayoutTypes.KANBAN]: KanbanQuickAddIssueForm, + [EIssueLayoutTypes.CALENDAR]: CalendarQuickAddIssueForm, + [EIssueLayoutTypes.GANTT]: GanttQuickAddIssueForm, + [EIssueLayoutTypes.SPREADSHEET]: SpreadsheetQuickAddIssueForm, + }; + + const CurrentLayoutQuickAddIssueForm = QUICK_ADD_ISSUE_FORMS[layout] ?? null; + + if (!CurrentLayoutQuickAddIssueForm) return <>; + + return ( + + ); +}); diff --git a/apps/web/ce/components/issues/worklog/activity/filter-root.tsx b/apps/web/ce/components/issues/worklog/activity/filter-root.tsx new file mode 100644 index 00000000..cbc4607f --- /dev/null +++ b/apps/web/ce/components/issues/worklog/activity/filter-root.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { FC } from "react"; +// plane imports +import type { TActivityFilters, TActivityFilterOption } from "@plane/constants"; +import { ACTIVITY_FILTER_TYPE_OPTIONS } from "@plane/constants"; +// components +import { ActivityFilter } from "@/components/issues/issue-detail/issue-activity"; + +export type TActivityFilterRoot = { + selectedFilters: TActivityFilters[]; + toggleFilter: (filter: TActivityFilters) => void; + projectId: string; + isIntakeIssue?: boolean; +}; + +export const ActivityFilterRoot: FC = (props) => { + const { selectedFilters, toggleFilter } = props; + + const filters: TActivityFilterOption[] = Object.entries(ACTIVITY_FILTER_TYPE_OPTIONS).map(([key, value]) => { + const filterKey = key as TActivityFilters; + return { + key: filterKey, + labelTranslationKey: value.labelTranslationKey, + isSelected: selectedFilters.includes(filterKey), + onClick: () => toggleFilter(filterKey), + }; + }); + + return ; +}; diff --git a/apps/web/ce/components/issues/worklog/activity/index.ts b/apps/web/ce/components/issues/worklog/activity/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/issues/worklog/activity/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/issues/worklog/activity/root.tsx b/apps/web/ce/components/issues/worklog/activity/root.tsx new file mode 100644 index 00000000..42a0a28e --- /dev/null +++ b/apps/web/ce/components/issues/worklog/activity/root.tsx @@ -0,0 +1,14 @@ +"use client"; + +import type { FC } from "react"; +import type { TIssueActivityComment } from "@plane/types"; + +type TIssueActivityWorklog = { + workspaceSlug: string; + projectId: string; + issueId: string; + activityComment: TIssueActivityComment; + ends?: "top" | "bottom"; +}; + +export const IssueActivityWorklog: FC = () => <>; diff --git a/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx b/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx new file mode 100644 index 00000000..17acfb2f --- /dev/null +++ b/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx @@ -0,0 +1,12 @@ +"use client"; + +import type { FC } from "react"; + +type TIssueActivityWorklogCreateButton = { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled: boolean; +}; + +export const IssueActivityWorklogCreateButton: FC = () => <>; diff --git a/apps/web/ce/components/issues/worklog/property/index.ts b/apps/web/ce/components/issues/worklog/property/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/issues/worklog/property/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/issues/worklog/property/root.tsx b/apps/web/ce/components/issues/worklog/property/root.tsx new file mode 100644 index 00000000..a0a4d289 --- /dev/null +++ b/apps/web/ce/components/issues/worklog/property/root.tsx @@ -0,0 +1,12 @@ +"use client"; + +import type { FC } from "react"; + +type TIssueWorklogProperty = { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled: boolean; +}; + +export const IssueWorklogProperty: FC = () => <>; diff --git a/apps/web/ce/components/license/index.ts b/apps/web/ce/components/license/index.ts new file mode 100644 index 00000000..031608e2 --- /dev/null +++ b/apps/web/ce/components/license/index.ts @@ -0,0 +1 @@ +export * from "./modal"; diff --git a/apps/web/ce/components/license/modal/index.ts b/apps/web/ce/components/license/modal/index.ts new file mode 100644 index 00000000..8add86e5 --- /dev/null +++ b/apps/web/ce/components/license/modal/index.ts @@ -0,0 +1 @@ +export * from "./upgrade-modal"; diff --git a/apps/web/ce/components/license/modal/upgrade-modal.tsx b/apps/web/ce/components/license/modal/upgrade-modal.tsx new file mode 100644 index 00000000..5917fc76 --- /dev/null +++ b/apps/web/ce/components/license/modal/upgrade-modal.tsx @@ -0,0 +1,124 @@ +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { + BUSINESS_PLAN_FEATURES, + ENTERPRISE_PLAN_FEATURES, + PLANE_COMMUNITY_PRODUCTS, + PRO_PLAN_FEATURES, + SUBSCRIPTION_REDIRECTION_URLS, + SUBSCRIPTION_WEBPAGE_URLS, + TALK_TO_SALES_URL, +} from "@plane/constants"; +import { EProductSubscriptionEnum } from "@plane/types"; +import { EModalWidth, ModalCore } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { FreePlanCard, PlanUpgradeCard } from "@/components/license"; +import type { TCheckoutParams } from "@/components/license/modal/card/checkout-button"; + +// Constants +const COMMON_CARD_CLASSNAME = "flex flex-col w-full h-full justify-end col-span-12 sm:col-span-6 xl:col-span-3"; +const COMMON_EXTRA_FEATURES_CLASSNAME = "pt-2 text-center text-xs text-custom-primary-200 font-medium hover:underline"; + +export type PaidPlanUpgradeModalProps = { + isOpen: boolean; + handleClose: () => void; +}; + +export const PaidPlanUpgradeModal: FC = observer((props) => { + const { isOpen, handleClose } = props; + // derived values + const isSelfHosted = true; + const isTrialAllowed = false; + + const handleRedirection = ({ planVariant, priceId }: TCheckoutParams) => { + // Get the product and price using plane community constants + const product = PLANE_COMMUNITY_PRODUCTS[planVariant]; + const price = product.prices.find((price) => price.id === priceId); + const frequency = price?.recurring ?? "year"; + // Redirect to the appropriate URL + const redirectUrl = SUBSCRIPTION_REDIRECTION_URLS[planVariant][frequency] ?? TALK_TO_SALES_URL; + window.open(redirectUrl, "_blank"); + }; + + return ( + +
    +
    + {/* Free Plan Section */} +
    +
    Upgrade to a paid plan and unlock missing features.
    +
    +

    + Dashboards, Workflows, Approvals, Time Management, and other superpowers are just a click away. Upgrade + today to unlock features your teams need yesterday. +

    +
    + + {/* Free plan details */} + +
    + + {/* Pro plan */} +
    + + + See full features list + +

    + } + handleCheckout={handleRedirection} + isSelfHosted={!!isSelfHosted} + isTrialAllowed={!!isTrialAllowed} + /> +
    +
    + + + See full features list + +

    + } + handleCheckout={handleRedirection} + isSelfHosted={!!isSelfHosted} + isTrialAllowed={!!isTrialAllowed} + /> +
    +
    + + + See full features list + +

    + } + handleCheckout={handleRedirection} + isSelfHosted={!!isSelfHosted} + isTrialAllowed={!!isTrialAllowed} + /> +
    +
    +
    +
    + ); +}); diff --git a/apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx b/apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx new file mode 100644 index 00000000..adeab2da --- /dev/null +++ b/apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { CircleArrowUp, CornerDownRight, RefreshCcw, Sparkles } from "lucide-react"; +// ui +import { Tooltip } from "@plane/propel/tooltip"; +// components +import { cn } from "@plane/utils"; +import { RichTextEditor } from "@/components/editor/rich-text"; +// helpers +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; + +type Props = { + handleInsertText: (insertOnNextLine: boolean) => void; + handleRegenerate: () => Promise; + isRegenerating: boolean; + response: string | undefined; + workspaceSlug: string; +}; + +export const AskPiMenu: React.FC = (props) => { + const { handleInsertText, handleRegenerate, isRegenerating, response, workspaceSlug } = props; + // states + const [query, setQuery] = useState(""); + // store hooks + const { getWorkspaceBySlug } = useWorkspace(); + // derived values + const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? ""; + + return ( + <> +
    + + + + {response ? ( +
    + +
    + + + + + + + +
    +
    + ) : ( +

    Pi is answering...

    + )} +
    +
    +
    + + + + setQuery(e.target.value)} + placeholder="Tell Pi what to do..." + /> + + + +
    +
    + + ); +}; diff --git a/apps/web/ce/components/pages/editor/ai/index.ts b/apps/web/ce/components/pages/editor/ai/index.ts new file mode 100644 index 00000000..d21eb63d --- /dev/null +++ b/apps/web/ce/components/pages/editor/ai/index.ts @@ -0,0 +1,2 @@ +export * from "./ask-pi-menu"; +export * from "./menu"; diff --git a/apps/web/ce/components/pages/editor/ai/menu.tsx b/apps/web/ce/components/pages/editor/ai/menu.tsx new file mode 100644 index 00000000..109af797 --- /dev/null +++ b/apps/web/ce/components/pages/editor/ai/menu.tsx @@ -0,0 +1,304 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import type { LucideIcon } from "lucide-react"; +import { ChevronRight, CornerDownRight, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react"; +// plane editor +import type { EditorRefApi } from "@plane/editor"; +// plane ui +import { Tooltip } from "@plane/propel/tooltip"; +// components +import { cn } from "@plane/utils"; +import { RichTextEditor } from "@/components/editor/rich-text"; +// plane web constants +import { AI_EDITOR_TASKS, LOADING_TEXTS } from "@/plane-web/constants/ai"; +// plane web services +import type { TTaskPayload } from "@/services/ai.service"; +import { AIService } from "@/services/ai.service"; +import { AskPiMenu } from "./ask-pi-menu"; +const aiService = new AIService(); + +type Props = { + editorRef: EditorRefApi | null; + isOpen: boolean; + onClose: () => void; + workspaceId: string; + workspaceSlug: string; +}; + +const MENU_ITEMS: { + icon: LucideIcon; + key: AI_EDITOR_TASKS; + label: string; +}[] = [ + { + key: AI_EDITOR_TASKS.ASK_ANYTHING, + icon: Sparkles, + label: "Ask Pi", + }, +]; + +const TONES_LIST = [ + { + key: "default", + label: "Default", + casual_score: 5, + formal_score: 5, + }, + { + key: "professional", + label: "💼 Professional", + casual_score: 0, + formal_score: 10, + }, + { + key: "casual", + label: "😃 Casual", + casual_score: 10, + formal_score: 0, + }, +]; + +export const EditorAIMenu: React.FC = (props) => { + const { editorRef, isOpen, onClose, workspaceId, workspaceSlug } = props; + // states + const [activeTask, setActiveTask] = useState(null); + const [response, setResponse] = useState(undefined); + const [isRegenerating, setIsRegenerating] = useState(false); + // refs + const responseContainerRef = useRef(null); + // params + const handleGenerateResponse = async (payload: TTaskPayload) => { + if (!workspaceSlug) return; + await aiService.performEditorTask(workspaceSlug.toString(), payload).then((res) => setResponse(res.response)); + }; + // handle task click + const handleClick = async (key: AI_EDITOR_TASKS) => { + const selection = editorRef?.getSelectedText(); + if (!selection || activeTask === key) return; + setActiveTask(key); + if (key === AI_EDITOR_TASKS.ASK_ANYTHING) return; + setResponse(undefined); + setIsRegenerating(false); + await handleGenerateResponse({ + task: key, + text_input: selection, + }); + }; + // handle re-generate response + const handleRegenerate = async () => { + const selection = editorRef?.getSelectedText(); + if (!selection || !activeTask) return; + setIsRegenerating(true); + await handleGenerateResponse({ + task: activeTask, + text_input: selection, + }) + .then(() => + responseContainerRef.current?.scrollTo({ + top: 0, + behavior: "smooth", + }) + ) + .finally(() => setIsRegenerating(false)); + }; + // handle re-generate response + const handleToneChange = async (key: string) => { + const selectedTone = TONES_LIST.find((t) => t.key === key); + const selection = editorRef?.getSelectedText(); + if (!selectedTone || !selection || !activeTask) return; + setResponse(undefined); + setIsRegenerating(false); + await handleGenerateResponse({ + casual_score: selectedTone.casual_score, + formal_score: selectedTone.formal_score, + task: activeTask, + text_input: selection, + }).then(() => + responseContainerRef.current?.scrollTo({ + top: 0, + behavior: "smooth", + }) + ); + }; + // handle replace selected text with the response + const handleInsertText = (insertOnNextLine: boolean) => { + if (!response) return; + editorRef?.insertText(response, insertOnNextLine); + onClose(); + }; + + // reset on close + useEffect(() => { + if (!isOpen) { + setActiveTask(null); + setResponse(undefined); + } + }, [isOpen]); + + return ( +
    +
    +
    + {MENU_ITEMS.map((item) => { + const isActiveTask = activeTask === item.key; + + return ( + + ); + })} +
    +
    + {activeTask === AI_EDITOR_TASKS.ASK_ANYTHING ? ( + + ) : ( + <> +
    + + + + {response ? ( +
    + +
    + + + + + + + +
    +
    + ) : ( +

    + {activeTask ? LOADING_TEXTS[activeTask] : "Pi is writing"}... +

    + )} +
    +
    + {TONES_LIST.map((tone) => ( + + ))} +
    + + )} +
    +
    + {activeTask && ( +
    + + + +

    + By using this feature, you consent to sharing the message with a 3rd party service. +

    +
    + )} +
    + ); +}; diff --git a/apps/web/ce/components/pages/editor/embed/index.ts b/apps/web/ce/components/pages/editor/embed/index.ts new file mode 100644 index 00000000..e1682283 --- /dev/null +++ b/apps/web/ce/components/pages/editor/embed/index.ts @@ -0,0 +1 @@ +export * from "./issue-embed-upgrade-card"; diff --git a/apps/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx b/apps/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx new file mode 100644 index 00000000..bd61ceaa --- /dev/null +++ b/apps/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx @@ -0,0 +1,31 @@ +// plane imports +import { getButtonStyling } from "@plane/propel/button"; +import { cn } from "@plane/utils"; +// components +import { ProIcon } from "@/components/common/pro-icon"; + +export const IssueEmbedUpgradeCard: React.FC = (props) => ( +
    +
    + +

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

    +
    + + Upgrade + +
    +); diff --git a/apps/web/ce/components/pages/editor/index.ts b/apps/web/ce/components/pages/editor/index.ts new file mode 100644 index 00000000..88b26fa2 --- /dev/null +++ b/apps/web/ce/components/pages/editor/index.ts @@ -0,0 +1,2 @@ +export * from "./ai"; +export * from "./embed"; diff --git a/apps/web/ce/components/pages/extra-actions.tsx b/apps/web/ce/components/pages/extra-actions.tsx new file mode 100644 index 00000000..9e70d8d8 --- /dev/null +++ b/apps/web/ce/components/pages/extra-actions.tsx @@ -0,0 +1,10 @@ +// store +import type { EPageStoreType } from "@/plane-web/hooks/store"; +import type { TPageInstance } from "@/store/pages/base-page"; + +export type TPageHeaderExtraActionsProps = { + page: TPageInstance; + storeType: EPageStoreType; +}; + +export const PageDetailsHeaderExtraActions: React.FC = () => null; diff --git a/apps/web/ce/components/pages/header/collaborators-list.tsx b/apps/web/ce/components/pages/header/collaborators-list.tsx new file mode 100644 index 00000000..eefc533e --- /dev/null +++ b/apps/web/ce/components/pages/header/collaborators-list.tsx @@ -0,0 +1,10 @@ +"use client"; + +// store +import type { TPageInstance } from "@/store/pages/base-page"; + +export type TPageCollaboratorsListProps = { + page: TPageInstance; +}; + +export const PageCollaboratorsList = ({}: TPageCollaboratorsListProps) => null; diff --git a/apps/web/ce/components/pages/header/lock-control.tsx b/apps/web/ce/components/pages/header/lock-control.tsx new file mode 100644 index 00000000..2403b688 --- /dev/null +++ b/apps/web/ce/components/pages/header/lock-control.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +import { LockKeyhole, LockKeyholeOpen } from "lucide-react"; +// plane imports +import { PROJECT_PAGE_TRACKER_ELEMENTS } from "@plane/constants"; +import { Tooltip } from "@plane/propel/tooltip"; +// helpers +import { captureClick } from "@/helpers/event-tracker.helper"; +// hooks +import { usePageOperations } from "@/hooks/use-page-operations"; +// store +import type { TPageInstance } from "@/store/pages/base-page"; + +// Define our lock display states, renaming "icon-only" to "neutral" +type LockDisplayState = "neutral" | "locked" | "unlocked"; + +type Props = { + page: TPageInstance; +}; + +export const PageLockControl = observer(({ page }: Props) => { + // Initial state: if locked, then "locked", otherwise default to "neutral" + const [displayState, setDisplayState] = useState(page.is_locked ? "locked" : "neutral"); + // derived values + const { canCurrentUserLockPage, is_locked } = page; + // Ref for the transition timer + const timerRef = useRef | null>(null); + // Ref to store the previous value of isLocked for detecting transitions + const prevLockedRef = useRef(is_locked); + // page operations + const { + pageOperations: { toggleLock }, + } = usePageOperations({ + page, + }); + + // Cleanup any running timer on unmount + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current); + }, + [] + ); + + // Update display state when isLocked changes + useEffect(() => { + // Clear any previous timer to avoid overlapping transitions + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + + // Transition logic: + // If locked, ensure the display state is "locked" + // If unlocked after being locked, show "unlocked" briefly then revert to "neutral" + if (is_locked) { + setDisplayState("locked"); + } else if (prevLockedRef.current === true) { + setDisplayState("unlocked"); + timerRef.current = setTimeout(() => { + setDisplayState("neutral"); + timerRef.current = null; + }, 600); + } else { + setDisplayState("neutral"); + } + + // Update the previous locked state + prevLockedRef.current = is_locked; + }, [is_locked]); + + if (!canCurrentUserLockPage) return null; + + // Render different UI based on the current display state + return ( + <> + {displayState === "neutral" && ( + + + + )} + + {displayState === "locked" && ( + + )} + + {displayState === "unlocked" && ( +
    + + + Unlocked + +
    + )} + + ); +}); diff --git a/apps/web/ce/components/pages/header/move-control.tsx b/apps/web/ce/components/pages/header/move-control.tsx new file mode 100644 index 00000000..40376d7e --- /dev/null +++ b/apps/web/ce/components/pages/header/move-control.tsx @@ -0,0 +1,10 @@ +"use client"; + +// store +import type { TPageInstance } from "@/store/pages/base-page"; + +export type TPageMoveControlProps = { + page: TPageInstance; +}; + +export const PageMoveControl = ({}: TPageMoveControlProps) => null; diff --git a/apps/web/ce/components/pages/header/share-control.tsx b/apps/web/ce/components/pages/header/share-control.tsx new file mode 100644 index 00000000..4a79e039 --- /dev/null +++ b/apps/web/ce/components/pages/header/share-control.tsx @@ -0,0 +1,12 @@ +"use client"; + +import type { EPageStoreType } from "@/plane-web/hooks/store"; +// store +import type { TPageInstance } from "@/store/pages/base-page"; + +export type TPageShareControlProps = { + page: TPageInstance; + storeType: EPageStoreType; +}; + +export const PageShareControl = ({}: TPageShareControlProps) => null; diff --git a/apps/web/ce/components/pages/index.ts b/apps/web/ce/components/pages/index.ts new file mode 100644 index 00000000..c4131c5f --- /dev/null +++ b/apps/web/ce/components/pages/index.ts @@ -0,0 +1,3 @@ +export * from "./editor"; +export * from "./modals"; +export * from "./extra-actions"; diff --git a/apps/web/ce/components/pages/modals/index.ts b/apps/web/ce/components/pages/modals/index.ts new file mode 100644 index 00000000..c1c5c24d --- /dev/null +++ b/apps/web/ce/components/pages/modals/index.ts @@ -0,0 +1,2 @@ +export * from "./move-page-modal"; +export * from "./modals"; diff --git a/apps/web/ce/components/pages/modals/modals.tsx b/apps/web/ce/components/pages/modals/modals.tsx new file mode 100644 index 00000000..d47dbae3 --- /dev/null +++ b/apps/web/ce/components/pages/modals/modals.tsx @@ -0,0 +1,15 @@ +"use client"; + +import type React from "react"; +import { observer } from "mobx-react"; +// components +import type { EPageStoreType } from "@/plane-web/hooks/store"; +// store +import type { TPageInstance } from "@/store/pages/base-page"; + +export type TPageModalsProps = { + page: TPageInstance; + storeType: EPageStoreType; +}; + +export const PageModals: React.FC = observer((props) => null); diff --git a/apps/web/ce/components/pages/modals/move-page-modal.tsx b/apps/web/ce/components/pages/modals/move-page-modal.tsx new file mode 100644 index 00000000..b8726a42 --- /dev/null +++ b/apps/web/ce/components/pages/modals/move-page-modal.tsx @@ -0,0 +1,10 @@ +// store types +import type { TPageInstance } from "@/store/pages/base-page"; + +export type TMovePageModalProps = { + isOpen: boolean; + onClose: () => void; + page: TPageInstance; +}; + +export const MovePageModal: React.FC = () => null; diff --git a/apps/web/ce/components/pages/navigation-pane/index.ts b/apps/web/ce/components/pages/navigation-pane/index.ts new file mode 100644 index 00000000..79ee20c2 --- /dev/null +++ b/apps/web/ce/components/pages/navigation-pane/index.ts @@ -0,0 +1,31 @@ +export type TPageNavigationPaneTab = "outline" | "info" | "assets"; + +export const PAGE_NAVIGATION_PANE_TABS_LIST: Record< + TPageNavigationPaneTab, + { + key: TPageNavigationPaneTab; + i18n_label: string; + } +> = { + outline: { + key: "outline", + i18n_label: "page_navigation_pane.tabs.outline.label", + }, + info: { + key: "info", + i18n_label: "page_navigation_pane.tabs.info.label", + }, + assets: { + key: "assets", + i18n_label: "page_navigation_pane.tabs.assets.label", + }, +}; + +export const ORDERED_PAGE_NAVIGATION_TABS_LIST: { + key: TPageNavigationPaneTab; + i18n_label: string; +}[] = [ + PAGE_NAVIGATION_PANE_TABS_LIST.outline, + PAGE_NAVIGATION_PANE_TABS_LIST.info, + PAGE_NAVIGATION_PANE_TABS_LIST.assets, +]; diff --git a/apps/web/ce/components/pages/navigation-pane/tab-panels/assets.tsx b/apps/web/ce/components/pages/navigation-pane/tab-panels/assets.tsx new file mode 100644 index 00000000..0e3cc49c --- /dev/null +++ b/apps/web/ce/components/pages/navigation-pane/tab-panels/assets.tsx @@ -0,0 +1,13 @@ +// plane imports +import type { TEditorAsset } from "@plane/editor"; +// store +import type { TPageInstance } from "@/store/pages/base-page"; + +export type TAdditionalPageNavigationPaneAssetItemProps = { + asset: TEditorAsset; + assetSrc: string; + assetDownloadSrc: string; + page: TPageInstance; +}; + +export const AdditionalPageNavigationPaneAssetItem: React.FC = () => null; diff --git a/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/assets.tsx b/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/assets.tsx new file mode 100644 index 00000000..c0bd72cc --- /dev/null +++ b/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/assets.tsx @@ -0,0 +1,26 @@ +import Image from "next/image"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// hooks +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +export const PageNavigationPaneAssetsTabEmptyState = () => { + // asset resolved path + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/wiki/navigation-pane/assets" }); + // translation + const { t } = useTranslation(); + + return ( +
    +
    + An image depicting the assets of a page +
    +

    {t("page_navigation_pane.tabs.assets.empty_state.title")}

    +

    + {t("page_navigation_pane.tabs.assets.empty_state.description")} +

    +
    +
    +
    + ); +}; diff --git a/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx b/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx new file mode 100644 index 00000000..1e692ea3 --- /dev/null +++ b/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx @@ -0,0 +1,26 @@ +import Image from "next/image"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// hooks +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +export const PageNavigationPaneOutlineTabEmptyState = () => { + // asset resolved path + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/wiki/navigation-pane/outline" }); + // translation + const { t } = useTranslation(); + + return ( +
    +
    + An image depicting the outline of a page +
    +

    {t("page_navigation_pane.tabs.outline.empty_state.title")}

    +

    + {t("page_navigation_pane.tabs.outline.empty_state.description")} +

    +
    +
    +
    + ); +}; diff --git a/apps/web/ce/components/pages/navigation-pane/tab-panels/root.tsx b/apps/web/ce/components/pages/navigation-pane/tab-panels/root.tsx new file mode 100644 index 00000000..1581b494 --- /dev/null +++ b/apps/web/ce/components/pages/navigation-pane/tab-panels/root.tsx @@ -0,0 +1,13 @@ +// store +import type { TPageInstance } from "@/store/pages/base-page"; +// local imports +import type { TPageNavigationPaneTab } from ".."; + +export type TPageNavigationPaneAdditionalTabPanelsRootProps = { + activeTab: TPageNavigationPaneTab; + page: TPageInstance; +}; + +export const PageNavigationPaneAdditionalTabPanelsRoot: React.FC< + TPageNavigationPaneAdditionalTabPanelsRootProps +> = () => null; diff --git a/apps/web/ce/components/preferences/config.ts b/apps/web/ce/components/preferences/config.ts new file mode 100644 index 00000000..1a67ab7d --- /dev/null +++ b/apps/web/ce/components/preferences/config.ts @@ -0,0 +1,7 @@ +import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference"; +import { ThemeSwitcher } from "./theme-switcher"; + +export const PREFERENCE_COMPONENTS = { + theme: ThemeSwitcher, + start_of_week: StartOfWeekPreference, +}; diff --git a/apps/web/ce/components/preferences/theme-switcher.tsx b/apps/web/ce/components/preferences/theme-switcher.tsx new file mode 100644 index 00000000..0460fbca --- /dev/null +++ b/apps/web/ce/components/preferences/theme-switcher.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +// plane imports +import type { I_THEME_OPTION } from "@plane/constants"; +import { THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { setPromiseToast } from "@plane/propel/toast"; +import type { IUserTheme } from "@plane/types"; +import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; +// components +import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; +import { ThemeSwitch } from "@/components/core/theme/theme-switch"; +// helpers +import { PreferencesSection } from "@/components/preferences/section"; +// hooks +import { useUserProfile } from "@/hooks/store/user"; + +export const ThemeSwitcher = observer( + (props: { + option: { + id: string; + title: string; + description: string; + }; + }) => { + // hooks + const { setTheme } = useTheme(); + const { data: userProfile, updateUserTheme } = useUserProfile(); + + // states + const [currentTheme, setCurrentTheme] = useState(null); + + const { t } = useTranslation(); + + // initialize theme + useEffect(() => { + if (!userProfile?.theme?.theme) return; + + const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile.theme.theme); + + if (userThemeOption) { + setCurrentTheme(userThemeOption); + } + }, [userProfile?.theme?.theme]); + + // handlers + const applyThemeChange = useCallback( + (theme: Partial) => { + const themeValue = theme?.theme || "system"; + setTheme(themeValue); + + if (theme?.theme === "custom" && theme?.palette) { + const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5"; + const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette; + applyTheme(palette, false); + } else { + unsetCustomCssVariables(); + } + }, + [setTheme] + ); + + const handleThemeChange = useCallback( + async (themeOption: I_THEME_OPTION) => { + try { + applyThemeChange({ theme: themeOption.value }); + + const updatePromise = updateUserTheme({ theme: themeOption.value }); + setPromiseToast(updatePromise, { + loading: "Updating theme...", + success: { + title: "Success!", + message: () => "Theme updated successfully!", + }, + error: { + title: "Error!", + message: () => "Failed to update the theme", + }, + }); + } catch (error) { + console.error("Error updating theme:", error); + } + }, + [applyThemeChange, updateUserTheme] + ); + + if (!userProfile) return null; + + return ( + <> + + +
    + } + /> + {userProfile.theme?.theme === "custom" && } + + ); + } +); diff --git a/apps/web/ce/components/projects/create/attributes.tsx b/apps/web/ce/components/projects/create/attributes.tsx new file mode 100644 index 00000000..e1119f05 --- /dev/null +++ b/apps/web/ce/components/projects/create/attributes.tsx @@ -0,0 +1,94 @@ +"use client"; +import type { FC } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +// plane imports +import { NETWORK_CHOICES, ETabIndices } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { IProject } from "@plane/types"; +import { CustomSelect } from "@plane/ui"; +import { getTabIndex } from "@plane/utils"; +// components +import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; +import { ProjectNetworkIcon } from "@/components/project/project-network-icon"; + +type Props = { + isMobile?: boolean; +}; + +const ProjectAttributes: FC = (props) => { + const { isMobile = false } = props; + const { t } = useTranslation(); + const { control } = useFormContext(); + const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile); + return ( +
    + { + const currentNetwork = NETWORK_CHOICES.find((n) => n.key === value); + + return ( +
    + + {currentNetwork ? ( + <> + + {t(currentNetwork.i18n_label)} + + ) : ( + {t("select_network")} + )} +
    + } + placement="bottom-start" + className="h-full" + buttonClassName="h-full" + noChevron + tabIndex={getIndex("network")} + > + {NETWORK_CHOICES.map((network) => ( + +
    + +
    +

    {t(network.i18n_label)}

    +

    {t(network.description)}

    +
    +
    +
    + ))} + +
    + ); + }} + /> + { + if (value === undefined || value === null || typeof value === "string") + return ( +
    + onChange(lead === value ? null : lead)} + placeholder={t("lead")} + multiple={false} + buttonVariant="border-with-text" + tabIndex={5} + /> +
    + ); + else return <>; + }} + /> +
    + ); +}; + +export default ProjectAttributes; diff --git a/apps/web/ce/components/projects/create/root.tsx b/apps/web/ce/components/projects/create/root.tsx new file mode 100644 index 00000000..27abe7aa --- /dev/null +++ b/apps/web/ce/components/projects/create/root.tsx @@ -0,0 +1,167 @@ +"use client"; + +import type { FC } from "react"; +import { useState } from "react"; +import { observer } from "mobx-react"; +import { FormProvider, useForm } from "react-hook-form"; +import { DEFAULT_PROJECT_FORM_VALUES, PROJECT_TRACKER_EVENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// ui +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +// constants +import ProjectCommonAttributes from "@/components/project/create/common-attributes"; +import ProjectCreateHeader from "@/components/project/create/header"; +import ProjectCreateButtons from "@/components/project/create/project-create-buttons"; +// hooks +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +import { useProject } from "@/hooks/store/use-project"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web types +import type { TProject } from "@/plane-web/types/projects"; +import ProjectAttributes from "./attributes"; + +export type TCreateProjectFormProps = { + setToFavorite?: boolean; + workspaceSlug: string; + onClose: () => void; + handleNextStep: (projectId: string) => void; + data?: Partial; + templateId?: string; + updateCoverImageStatus: (projectId: string, coverImage: string) => Promise; +}; + +export const CreateProjectForm: FC = observer((props) => { + const { setToFavorite, workspaceSlug, data, onClose, handleNextStep, updateCoverImageStatus } = props; + // store + const { t } = useTranslation(); + const { addProjectToFavorites, createProject } = useProject(); + // states + const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true); + // form info + const methods = useForm({ + defaultValues: { ...DEFAULT_PROJECT_FORM_VALUES, ...data }, + reValidateMode: "onChange", + }); + const { handleSubmit, reset, setValue } = methods; + const { isMobile } = usePlatformOS(); + const handleAddToFavorites = (projectId: string) => { + if (!workspaceSlug) return; + + addProjectToFavorites(workspaceSlug.toString(), projectId).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("failed_to_remove_project_from_favorites"), + }); + }); + }; + + 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(async (res) => { + if (coverImage) { + await updateCoverImageStatus(res.id, coverImage); + } + captureSuccess({ + eventName: PROJECT_TRACKER_EVENTS.create, + payload: { + identifier: formData.identifier, + }, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("success"), + message: t("project_created_successfully"), + }); + + if (setToFavorite) { + handleAddToFavorites(res.id); + } + handleNextStep(res.id); + }) + .catch((err) => { + try { + captureError({ + eventName: PROJECT_TRACKER_EVENTS.create, + payload: { + identifier: formData.identifier, + }, + }); + + // Handle the new error format where codes are nested in arrays under field names + const errorData = err?.data ?? {}; + + const nameError = errorData.name?.includes("PROJECT_NAME_ALREADY_EXIST"); + const identifierError = errorData?.identifier?.includes("PROJECT_IDENTIFIER_ALREADY_EXIST"); + + if (nameError || identifierError) { + if (nameError) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("project_name_already_taken"), + }); + } + + if (identifierError) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("project_identifier_already_taken"), + }); + } + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("something_went_wrong"), + }); + } + } catch (error) { + // Fallback error handling if the error processing fails + console.error("Error processing API error:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("something_went_wrong"), + }); + } + }); + }; + + const handleClose = () => { + onClose(); + setIsChangeInIdentifierRequired(true); + setTimeout(() => { + reset(); + }, 300); + }; + + return ( + + + +
    +
    + + +
    + + +
    + ); +}); diff --git a/apps/web/ce/components/projects/create/template-select.tsx b/apps/web/ce/components/projects/create/template-select.tsx new file mode 100644 index 00000000..e304af83 --- /dev/null +++ b/apps/web/ce/components/projects/create/template-select.tsx @@ -0,0 +1,12 @@ +type TProjectTemplateDropdownSize = "xs" | "sm"; + +export type TProjectTemplateSelect = { + disabled?: boolean; + size?: TProjectTemplateDropdownSize; + placeholder?: string; + dropDownContainerClassName?: string; + handleModalClose: () => void; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const ProjectTemplateSelect = (props: TProjectTemplateSelect) => <>; diff --git a/apps/web/ce/components/projects/header.tsx b/apps/web/ce/components/projects/header.tsx new file mode 100644 index 00000000..08871ec9 --- /dev/null +++ b/apps/web/ce/components/projects/header.tsx @@ -0,0 +1,5 @@ +"use client"; + +import { ProjectsBaseHeader } from "@/components/project/header"; + +export const ProjectsListHeader = () => ; diff --git a/apps/web/ce/components/projects/mobile-header.tsx b/apps/web/ce/components/projects/mobile-header.tsx new file mode 100644 index 00000000..82936743 --- /dev/null +++ b/apps/web/ce/components/projects/mobile-header.tsx @@ -0,0 +1,94 @@ +"use client"; +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { ChevronDown, ListFilter } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import type { TProjectFilters } from "@plane/types"; +import { calculateTotalFilters } from "@plane/utils"; +// components +import { FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { ProjectFiltersSelection } from "@/components/project/dropdowns/filters"; +import { ProjectOrderByDropdown } from "@/components/project/dropdowns/order-by"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useProjectFilter } from "@/hooks/store/use-project-filter"; + +export const ProjectsListMobileHeader = observer(() => { + // i18n + const { t } = useTranslation(); + // router + const { workspaceSlug } = useParams(); + const { + currentWorkspaceDisplayFilters: displayFilters, + currentWorkspaceFilters: filters, + updateDisplayFilters, + updateFilters, + } = useProjectFilter(); + + const { + workspace: { workspaceMemberIds }, + } = useMember(); + + const handleFilters = useCallback( + (key: keyof TProjectFilters, value: string | string[]) => { + if (!workspaceSlug) return; + const newValues = filters?.[key] ?? []; + if (Array.isArray(value)) + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + else { + if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + updateFilters(workspaceSlug.toString(), { [key]: newValues }); + }, + [filters, updateFilters, workspaceSlug] + ); + + const isFiltersApplied = calculateTotalFilters(filters ?? {}) !== 0; + + return ( +
    + { + if (!workspaceSlug || val === displayFilters?.order_by) return; + updateDisplayFilters(workspaceSlug.toString(), { + order_by: val, + }); + }} + isMobile + /> +
    + } + title={t("common.filters")} + placement="bottom-end" + menuButton={ +
    + + {t("common.filters")} + +
    + } + isFiltersApplied={isFiltersApplied} + > + { + if (!workspaceSlug) return; + updateDisplayFilters(workspaceSlug.toString(), val); + }} + memberIds={workspaceMemberIds ?? undefined} + /> +
    +
    +
    + ); +}); diff --git a/apps/web/ce/components/projects/navigation/helper.tsx b/apps/web/ce/components/projects/navigation/helper.tsx new file mode 100644 index 00000000..811eb9a1 --- /dev/null +++ b/apps/web/ce/components/projects/navigation/helper.tsx @@ -0,0 +1,78 @@ +// plane imports +import { EUserPermissions, EProjectFeatureKey } from "@plane/constants"; +import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons"; +// components +import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation"; + +export const getProjectFeatureNavigation = ( + workspaceSlug: string, + projectId: string, + project: { + cycle_view: boolean; + module_view: boolean; + issue_views_view: boolean; + page_view: boolean; + inbox_view: boolean; + } +): TNavigationItem[] => [ + { + i18n_key: "sidebar.work_items", + key: EProjectFeatureKey.WORK_ITEMS, + name: "Work items", + href: `/${workspaceSlug}/projects/${projectId}/issues`, + icon: WorkItemsIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: true, + sortOrder: 1, + }, + { + i18n_key: "sidebar.cycles", + key: EProjectFeatureKey.CYCLES, + name: "Cycles", + href: `/${workspaceSlug}/projects/${projectId}/cycles`, + icon: CycleIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + shouldRender: project.cycle_view, + sortOrder: 2, + }, + { + i18n_key: "sidebar.modules", + key: EProjectFeatureKey.MODULES, + name: "Modules", + href: `/${workspaceSlug}/projects/${projectId}/modules`, + icon: ModuleIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + shouldRender: project.module_view, + sortOrder: 3, + }, + { + i18n_key: "sidebar.views", + key: EProjectFeatureKey.VIEWS, + name: "Views", + href: `/${workspaceSlug}/projects/${projectId}/views`, + icon: ViewsIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: project.issue_views_view, + sortOrder: 4, + }, + { + i18n_key: "sidebar.pages", + key: EProjectFeatureKey.PAGES, + name: "Pages", + href: `/${workspaceSlug}/projects/${projectId}/pages`, + icon: PageIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: project.page_view, + sortOrder: 5, + }, + { + i18n_key: "sidebar.intake", + key: EProjectFeatureKey.INTAKE, + name: "Intake", + href: `/${workspaceSlug}/projects/${projectId}/intake`, + icon: IntakeIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: project.inbox_view, + sortOrder: 6, + }, +]; diff --git a/apps/web/ce/components/projects/page.tsx b/apps/web/ce/components/projects/page.tsx new file mode 100644 index 00000000..65151def --- /dev/null +++ b/apps/web/ce/components/projects/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// components +import { ProjectRoot } from "@/components/project/root"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useWorkspace } from "@/hooks/store/use-workspace"; + +export const ProjectPageRoot = observer(() => { + // router + const { workspaceSlug } = useParams(); + // store + const { currentWorkspace } = useWorkspace(); + const { fetchProjects } = useProject(); + // fetching workspace projects + useSWR( + workspaceSlug && currentWorkspace ? `WORKSPACE_PROJECTS_${workspaceSlug}` : null, + workspaceSlug && currentWorkspace ? () => fetchProjects(workspaceSlug.toString()) : null, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + + return ; +}); diff --git a/apps/web/ce/components/projects/settings/features-list.tsx b/apps/web/ce/components/projects/settings/features-list.tsx new file mode 100644 index 00000000..26fc591f --- /dev/null +++ b/apps/web/ce/components/projects/settings/features-list.tsx @@ -0,0 +1 @@ +export { ProjectFeaturesList } from "@/components/project/settings/features-list"; diff --git a/apps/web/ce/components/projects/settings/intake/header.tsx b/apps/web/ce/components/projects/settings/intake/header.tsx new file mode 100644 index 00000000..692eecd1 --- /dev/null +++ b/apps/web/ce/components/projects/settings/intake/header.tsx @@ -0,0 +1,81 @@ +"use client"; + +import type { FC } from "react"; +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { RefreshCcw } from "lucide-react"; +// ui +import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { InboxIssueCreateModalRoot } from "@/components/inbox/modals/create-modal"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useProjectInbox } from "@/hooks/store/use-project-inbox"; +import { useUserPermissions } from "@/hooks/store/user"; +// plane web +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const ProjectInboxHeader: FC = observer(() => { + // states + const [createIssueModal, setCreateIssueModal] = useState(false); + // router + const { workspaceSlug, projectId } = useParams(); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); + + const { currentProjectDetails, loader: currentProjectDetailsLoader } = useProject(); + const { loader } = useProjectInbox(); + + // derived value + const isAuthorized = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + EUserPermissionsLevel.PROJECT + ); + + return ( +
    + +
    + + + + + {loader === "pagination-loading" && ( +
    + +

    {t("syncing")}...

    +
    + )} +
    +
    + + {currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? ( +
    + setCreateIssueModal(false)} + /> + + +
    + ) : ( + <> + )} +
    +
    + ); +}); diff --git a/apps/web/ce/components/projects/settings/useProjectColumns.tsx b/apps/web/ce/components/projects/settings/useProjectColumns.tsx new file mode 100644 index 00000000..43f8983c --- /dev/null +++ b/apps/web/ce/components/projects/settings/useProjectColumns.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import type { IWorkspaceMember, TProjectMembership } from "@plane/types"; +import { renderFormattedDate } from "@plane/utils"; +// components +import { MemberHeaderColumn } from "@/components/project/member-header-column"; +import { AccountTypeColumn, NameColumn } from "@/components/project/settings/member-columns"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useUser, useUserPermissions } from "@/hooks/store/user"; +import type { IMemberFilters } from "@/store/member/utils"; + +export interface RowData extends Pick { + member: IWorkspaceMember; +} + +type TUseProjectColumnsProps = { + projectId: string; + workspaceSlug: string; +}; + +export const useProjectColumns = (props: TUseProjectColumnsProps) => { + const { projectId, workspaceSlug } = props; + // states + const [removeMemberModal, setRemoveMemberModal] = useState(null); + + // store hooks + const { data: currentUser } = useUser(); + const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); + const { + project: { + filters: { getFilters, updateFilters }, + }, + } = useMember(); + // derived values + const isAdmin = allowPermissions( + [EUserPermissions.ADMIN], + EUserPermissionsLevel.PROJECT, + workspaceSlug.toString(), + projectId.toString() + ); + const currentProjectRole = + getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug.toString(), projectId.toString()) ?? EUserPermissions.GUEST; + + const displayFilters = getFilters(projectId); + + // handlers + const handleDisplayFilterUpdate = (filters: Partial) => { + updateFilters(projectId, filters); + }; + + const columns = [ + { + key: "Full Name", + content: "Full name", + thClassName: "text-left", + thRender: () => ( + + ), + tdRender: (rowData: RowData) => ( + + ), + }, + { + key: "Display Name", + content: "Display name", + thRender: () => ( + + ), + tdRender: (rowData: RowData) =>
    {rowData.member.display_name}
    , + }, + { + key: "Email", + content: "Email", + thRender: () => ( + + ), + tdRender: (rowData: RowData) =>
    {rowData.member.email}
    , + }, + { + key: "Account Type", + content: "Account type", + thRender: () => ( + + ), + tdRender: (rowData: RowData) => ( + + ), + }, + { + key: "Joining Date", + content: "Joining date", + thRender: () => ( + + ), + tdRender: (rowData: RowData) =>
    {renderFormattedDate(rowData?.member?.joining_date)}
    , + }, + ]; + return { + columns, + removeMemberModal, + setRemoveMemberModal, + displayFilters, + handleDisplayFilterUpdate, + }; +}; diff --git a/apps/web/ce/components/projects/teamspaces/teamspace-list.tsx b/apps/web/ce/components/projects/teamspaces/teamspace-list.tsx new file mode 100644 index 00000000..05d401c5 --- /dev/null +++ b/apps/web/ce/components/projects/teamspaces/teamspace-list.tsx @@ -0,0 +1,6 @@ +export type TProjectTeamspaceList = { + workspaceSlug: string; + projectId: string; +}; + +export const ProjectTeamspaceList: React.FC = () => null; diff --git a/apps/web/ce/components/relations/activity.ts b/apps/web/ce/components/relations/activity.ts new file mode 100644 index 00000000..820966f4 --- /dev/null +++ b/apps/web/ce/components/relations/activity.ts @@ -0,0 +1,24 @@ +import type { TIssueActivity } from "@plane/types"; + +export const getRelationActivityContent = (activity: TIssueActivity | undefined): string | undefined => { + if (!activity) return; + + switch (activity.field) { + case "blocking": + return activity.old_value === "" + ? `marked this work item is blocking work item ` + : `removed the blocking work item `; + case "blocked_by": + return activity.old_value === "" + ? `marked this work item is being blocked by ` + : `removed this work item being blocked by work item `; + case "duplicate": + return activity.old_value === "" + ? `marked this work item as duplicate of ` + : `removed this work item as a duplicate of `; + case "relates_to": + return activity.old_value === "" ? `marked that this work item relates to ` : `removed the relation from `; + } + + return; +}; diff --git a/apps/web/ce/components/relations/index.tsx b/apps/web/ce/components/relations/index.tsx new file mode 100644 index 00000000..2a7ebf0e --- /dev/null +++ b/apps/web/ce/components/relations/index.tsx @@ -0,0 +1,39 @@ +import { CircleDot, CopyPlus, XCircle } from "lucide-react"; +import { RelatedIcon } from "@plane/propel/icons"; +import type { TRelationObject } from "@/components/issues/issue-detail-widgets/relations"; +import type { TIssueRelationTypes } from "../../types"; + +export * from "./activity"; + +export const ISSUE_RELATION_OPTIONS: Record = { + relates_to: { + key: "relates_to", + i18n_label: "issue.relation.relates_to", + className: "bg-custom-background-80 text-custom-text-200", + icon: (size) => , + placeholder: "Add related work items", + }, + duplicate: { + key: "duplicate", + i18n_label: "issue.relation.duplicate", + className: "bg-custom-background-80 text-custom-text-200", + icon: (size) => , + placeholder: "None", + }, + blocked_by: { + key: "blocked_by", + i18n_label: "issue.relation.blocked_by", + className: "bg-red-500/20 text-red-700", + icon: (size) => , + placeholder: "None", + }, + blocking: { + key: "blocking", + i18n_label: "issue.relation.blocking", + className: "bg-yellow-500/20 text-yellow-700", + icon: (size) => , + placeholder: "None", + }, +}; + +export const useTimeLineRelationOptions = () => ISSUE_RELATION_OPTIONS; diff --git a/apps/web/ce/components/rich-filters/filter-value-input/root.tsx b/apps/web/ce/components/rich-filters/filter-value-input/root.tsx new file mode 100644 index 00000000..f2ef9aba --- /dev/null +++ b/apps/web/ce/components/rich-filters/filter-value-input/root.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { TFilterValue, TFilterProperty } from "@plane/types"; +// local imports +import type { TFilterValueInputProps } from "@/components/rich-filters/shared"; + +export const AdditionalFilterValueInput = observer( +

    (_props: TFilterValueInputProps) => ( + // Fallback +

    + Filter type not supported +
    + ) +); diff --git a/apps/web/ce/components/sidebar/app-switcher.tsx b/apps/web/ce/components/sidebar/app-switcher.tsx new file mode 100644 index 00000000..1344211b --- /dev/null +++ b/apps/web/ce/components/sidebar/app-switcher.tsx @@ -0,0 +1 @@ +export const SidebarAppSwitcher = () => null; diff --git a/apps/web/ce/components/sidebar/index.ts b/apps/web/ce/components/sidebar/index.ts new file mode 100644 index 00000000..129f4202 --- /dev/null +++ b/apps/web/ce/components/sidebar/index.ts @@ -0,0 +1,2 @@ +export * from "./app-switcher"; +export * from "./project-navigation-root"; diff --git a/apps/web/ce/components/sidebar/project-navigation-root.tsx b/apps/web/ce/components/sidebar/project-navigation-root.tsx new file mode 100644 index 00000000..d4ca7bc3 --- /dev/null +++ b/apps/web/ce/components/sidebar/project-navigation-root.tsx @@ -0,0 +1,15 @@ +"use client"; + +import type { FC } from "react"; +// components +import { ProjectNavigation } from "@/components/workspace/sidebar/project-navigation"; + +type TProjectItemsRootProps = { + workspaceSlug: string; + projectId: string; +}; + +export const ProjectNavigationRoot: FC = (props) => { + const { workspaceSlug, projectId } = props; + return ; +}; diff --git a/apps/web/ce/components/views/access-controller.tsx b/apps/web/ce/components/views/access-controller.tsx new file mode 100644 index 00000000..8eefff02 --- /dev/null +++ b/apps/web/ce/components/views/access-controller.tsx @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const AccessController = (props: any) => <>; diff --git a/apps/web/ce/components/views/filters/access-filter.tsx b/apps/web/ce/components/views/filters/access-filter.tsx new file mode 100644 index 00000000..8c3232d4 --- /dev/null +++ b/apps/web/ce/components/views/filters/access-filter.tsx @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const FilterByAccess = (props: any) => <>; diff --git a/apps/web/ce/components/views/helper.tsx b/apps/web/ce/components/views/helper.tsx new file mode 100644 index 00000000..d2932dda --- /dev/null +++ b/apps/web/ce/components/views/helper.tsx @@ -0,0 +1,80 @@ +import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +import type { EIssueLayoutTypes, IProjectView } from "@plane/types"; +import type { TContextMenuItem } from "@plane/ui"; +import type { TWorkspaceLayoutProps } from "@/components/views/helper"; + +export type TLayoutSelectionProps = { + onChange: (layout: EIssueLayoutTypes) => void; + selectedLayout: EIssueLayoutTypes; + workspaceSlug: string; +}; + +export const GlobalViewLayoutSelection = (props: TLayoutSelectionProps) => <>; + +export const WorkspaceAdditionalLayouts = (props: TWorkspaceLayoutProps) => <>; + +export type TMenuItemsFactoryProps = { + isOwner: boolean; + isAdmin: boolean; + setDeleteViewModal: (open: boolean) => void; + setCreateUpdateViewModal: (open: boolean) => void; + handleOpenInNewTab: () => void; + handleCopyText: () => void; + isLocked: boolean; + workspaceSlug: string; + projectId?: string; + viewId: string; +}; + +export const useMenuItemsFactory = (props: TMenuItemsFactoryProps) => { + const { isOwner, isAdmin, setDeleteViewModal, setCreateUpdateViewModal, handleOpenInNewTab, handleCopyText } = props; + + const { t } = useTranslation(); + + const editMenuItem = () => ({ + key: "edit", + action: () => setCreateUpdateViewModal(true), + title: t("edit"), + icon: Pencil, + shouldRender: isOwner, + }); + + const openInNewTabMenuItem = () => ({ + key: "open-new-tab", + action: handleOpenInNewTab, + title: t("open_in_new_tab"), + icon: ExternalLink, + }); + + const copyLinkMenuItem = () => ({ + key: "copy-link", + action: handleCopyText, + title: t("copy_link"), + icon: Link, + }); + + const deleteMenuItem = () => ({ + key: "delete", + action: () => setDeleteViewModal(true), + title: t("delete"), + icon: Trash2, + shouldRender: isOwner || isAdmin, + }); + + return { + editMenuItem, + openInNewTabMenuItem, + copyLinkMenuItem, + deleteMenuItem, + }; +}; + +export const useViewMenuItems = (props: TMenuItemsFactoryProps): TContextMenuItem[] => { + const factory = useMenuItemsFactory(props); + + return [factory.editMenuItem(), factory.openInNewTabMenuItem(), factory.copyLinkMenuItem(), factory.deleteMenuItem()]; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const AdditionalHeaderItems = (view: IProjectView) => <>; diff --git a/apps/web/ce/components/views/publish/index.ts b/apps/web/ce/components/views/publish/index.ts new file mode 100644 index 00000000..8c04a4e3 --- /dev/null +++ b/apps/web/ce/components/views/publish/index.ts @@ -0,0 +1,2 @@ +export * from "./modal"; +export * from "./use-view-publish"; diff --git a/apps/web/ce/components/views/publish/modal.tsx b/apps/web/ce/components/views/publish/modal.tsx new file mode 100644 index 00000000..f92b3138 --- /dev/null +++ b/apps/web/ce/components/views/publish/modal.tsx @@ -0,0 +1,12 @@ +"use client"; + +import type { IProjectView } from "@plane/types"; + +type Props = { + isOpen: boolean; + view: IProjectView; + onClose: () => void; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const PublishViewModal = (props: Props) => <>; diff --git a/apps/web/ce/components/views/publish/use-view-publish.tsx b/apps/web/ce/components/views/publish/use-view-publish.tsx new file mode 100644 index 00000000..687a79ed --- /dev/null +++ b/apps/web/ce/components/views/publish/use-view-publish.tsx @@ -0,0 +1,7 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useViewPublish = (isPublished: boolean, isAuthorized: boolean) => ({ + isPublishModalOpen: false, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setPublishModalOpen: (value: boolean) => {}, + publishContextMenu: undefined, +}); diff --git a/apps/web/ce/components/workflow/index.ts b/apps/web/ce/components/workflow/index.ts new file mode 100644 index 00000000..07b59ccc --- /dev/null +++ b/apps/web/ce/components/workflow/index.ts @@ -0,0 +1,5 @@ +export * from "./state-option"; +export * from "./use-workflow-drag-n-drop"; +export * from "./workflow-disabled-message"; +export * from "./workflow-group-tree"; +export * from "./workflow-disabled-overlay"; diff --git a/apps/web/ce/components/workflow/state-option.tsx b/apps/web/ce/components/workflow/state-option.tsx new file mode 100644 index 00000000..d9d911a9 --- /dev/null +++ b/apps/web/ce/components/workflow/state-option.tsx @@ -0,0 +1,38 @@ +import { observer } from "mobx-react"; +import { Check } from "lucide-react"; +import { Combobox } from "@headlessui/react"; + +export type TStateOptionProps = { + projectId: string | null | undefined; + option: { + value: string | undefined; + query: string; + content: React.ReactNode; + }; + selectedValue: string | null | undefined; + className?: string; + filterAvailableStateIds?: boolean; + isForWorkItemCreation?: boolean; + alwaysAllowStateChange?: boolean; +}; + +export const StateOption = observer((props: TStateOptionProps) => { + const { option, className = "" } = props; + + return ( + + `${className} ${active ? "bg-custom-background-80" : ""} ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + ); +}); diff --git a/apps/web/ce/components/workflow/use-workflow-drag-n-drop.ts b/apps/web/ce/components/workflow/use-workflow-drag-n-drop.ts new file mode 100644 index 00000000..20c97eb4 --- /dev/null +++ b/apps/web/ce/components/workflow/use-workflow-drag-n-drop.ts @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { TIssueGroupByOptions } from "@plane/types"; + +export const useWorkFlowFDragNDrop = ( + groupBy: TIssueGroupByOptions | undefined, + subGroupBy?: TIssueGroupByOptions +) => ({ + workflowDisabledSource: undefined, + isWorkflowDropDisabled: false, + getIsWorkflowWorkItemCreationDisabled: (groupId: string, subGroupId?: string) => false, + handleWorkFlowState: ( + sourceGroupId: string, + destinationGroupId: string, + sourceSubGroupId?: string, + destinationSubGroupId?: string + ) => {}, +}); diff --git a/apps/web/ce/components/workflow/workflow-disabled-message.tsx b/apps/web/ce/components/workflow/workflow-disabled-message.tsx new file mode 100644 index 00000000..af348955 --- /dev/null +++ b/apps/web/ce/components/workflow/workflow-disabled-message.tsx @@ -0,0 +1,7 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +type Props = { + parentStateId: string; + className?: string; +}; + +export const WorkFlowDisabledMessage = (props: Props) => <>; diff --git a/apps/web/ce/components/workflow/workflow-disabled-overlay.tsx b/apps/web/ce/components/workflow/workflow-disabled-overlay.tsx new file mode 100644 index 00000000..5ec69f43 --- /dev/null +++ b/apps/web/ce/components/workflow/workflow-disabled-overlay.tsx @@ -0,0 +1,10 @@ +import { observer } from "mobx-react"; + +export type TWorkflowDisabledOverlayProps = { + messageContainerRef: React.RefObject; + workflowDisabledSource: string; + shouldOverlayBeVisible: boolean; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const WorkFlowDisabledOverlay = observer((props: TWorkflowDisabledOverlayProps) => <>); diff --git a/apps/web/ce/components/workflow/workflow-group-tree.tsx b/apps/web/ce/components/workflow/workflow-group-tree.tsx new file mode 100644 index 00000000..bc4cf9b1 --- /dev/null +++ b/apps/web/ce/components/workflow/workflow-group-tree.tsx @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { TIssueGroupByOptions } from "@plane/types"; + +type Props = { + groupBy?: TIssueGroupByOptions; + groupId: string | undefined; +}; + +export const WorkFlowGroupTree = (props: Props) => <>; diff --git a/apps/web/ce/components/workspace-notifications/index.ts b/apps/web/ce/components/workspace-notifications/index.ts new file mode 100644 index 00000000..ed26efa2 --- /dev/null +++ b/apps/web/ce/components/workspace-notifications/index.ts @@ -0,0 +1 @@ +export * from "./list-root"; diff --git a/apps/web/ce/components/workspace-notifications/list-root.tsx b/apps/web/ce/components/workspace-notifications/list-root.tsx new file mode 100644 index 00000000..55fd68c3 --- /dev/null +++ b/apps/web/ce/components/workspace-notifications/list-root.tsx @@ -0,0 +1,8 @@ +import { NotificationCardListRoot } from "./notification-card/root"; + +export type TNotificationListRoot = { + workspaceSlug: string; + workspaceId: string; +}; + +export const NotificationListRoot = (props: TNotificationListRoot) => ; diff --git a/apps/web/ce/components/workspace-notifications/notification-card/root.tsx b/apps/web/ce/components/workspace-notifications/notification-card/root.tsx new file mode 100644 index 00000000..214b0fb9 --- /dev/null +++ b/apps/web/ce/components/workspace-notifications/notification-card/root.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { NotificationItem } from "@/components/workspace-notifications/sidebar/notification-card/item"; +// hooks +import { useWorkspaceNotifications } from "@/hooks/store/notifications"; + +type TNotificationCardListRoot = { + workspaceSlug: string; + workspaceId: string; +}; + +export const NotificationCardListRoot: FC = observer((props) => { + const { workspaceSlug, workspaceId } = props; + // hooks + const { loader, paginationInfo, getNotifications, notificationIdsByWorkspaceId } = useWorkspaceNotifications(); + const notificationIds = notificationIdsByWorkspaceId(workspaceId); + const { t } = useTranslation(); + + const getNextNotifications = async () => { + try { + await getNotifications(workspaceSlug, ENotificationLoader.PAGINATION_LOADER, ENotificationQueryParamType.NEXT); + } catch (error) { + console.error(error); + } + }; + + if (!workspaceSlug || !workspaceId || !notificationIds) return <>; + return ( +
    + {notificationIds.map((notificationId: string) => ( + + ))} + + {/* fetch next page notifications */} + {paginationInfo && paginationInfo?.next_page_results && ( + <> + {loader === ENotificationLoader.PAGINATION_LOADER ? ( +
    +
    {t("loading")}...
    +
    + ) : ( +
    +
    + {t("load_more")} +
    +
    + )} + + )} +
    + ); +}); diff --git a/apps/web/ce/components/workspace/app-switcher.tsx b/apps/web/ce/components/workspace/app-switcher.tsx new file mode 100644 index 00000000..8650d2a5 --- /dev/null +++ b/apps/web/ce/components/workspace/app-switcher.tsx @@ -0,0 +1,5 @@ +"use client"; + +import React from "react"; + +export const WorkspaceAppSwitcher = () => <>; diff --git a/apps/web/ce/components/workspace/billing/billing-actions-button.tsx b/apps/web/ce/components/workspace/billing/billing-actions-button.tsx new file mode 100644 index 00000000..b15d67b9 --- /dev/null +++ b/apps/web/ce/components/workspace/billing/billing-actions-button.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { observer } from "mobx-react"; + +export type TBillingActionsButtonProps = { + canPerformWorkspaceAdminActions: boolean; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const BillingActionsButton = observer((props: TBillingActionsButtonProps) => <>); diff --git a/apps/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx b/apps/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx new file mode 100644 index 00000000..2993f329 --- /dev/null +++ b/apps/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx @@ -0,0 +1,61 @@ +import type { FC } from "react"; +// plane imports +import { observer } from "mobx-react"; +import type { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; +import { getSubscriptionBackgroundColor, getDiscountPillStyle } from "@plane/ui"; +import { calculateYearlyDiscount, cn } from "@plane/utils"; + +type TPlanFrequencyToggleProps = { + subscriptionType: EProductSubscriptionEnum; + monthlyPrice: number; + yearlyPrice: number; + selectedFrequency: TBillingFrequency; + setSelectedFrequency: (frequency: TBillingFrequency) => void; +}; + +export const PlanFrequencyToggle: FC = observer((props) => { + const { subscriptionType, monthlyPrice, yearlyPrice, selectedFrequency, setSelectedFrequency } = props; + // derived values + const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice); + + return ( +
    +
    +
    setSelectedFrequency("month")} + className={cn( + "w-full rounded px-1 py-0.5 text-xs font-medium leading-5 text-center", + selectedFrequency === "month" + ? "bg-custom-background-100 text-custom-text-100 shadow" + : "text-custom-text-300 hover:text-custom-text-200" + )} + > + Monthly +
    +
    setSelectedFrequency("year")} + className={cn( + "w-full rounded px-1 py-0.5 text-xs font-medium leading-5 text-center", + selectedFrequency === "year" + ? "bg-custom-background-100 text-custom-text-100 shadow" + : "text-custom-text-300 hover:text-custom-text-200" + )} + > + Yearly + {yearlyDiscount > 0 && ( + + -{yearlyDiscount}% + + )} +
    +
    +
    + ); +}); diff --git a/apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx b/apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx new file mode 100644 index 00000000..03af2fa3 --- /dev/null +++ b/apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx @@ -0,0 +1,127 @@ +import type { FC } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { + SUBSCRIPTION_REDIRECTION_URLS, + SUBSCRIPTION_WITH_BILLING_FREQUENCY, + TALK_TO_SALES_URL, + WORKSPACE_SETTINGS_TRACKER_ELEMENTS, + WORKSPACE_SETTINGS_TRACKER_EVENTS, +} from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { getButtonStyling } from "@plane/propel/button"; +import type { TBillingFrequency } from "@plane/types"; +import { EProductSubscriptionEnum } from "@plane/types"; +import { getUpgradeButtonStyle } from "@plane/ui"; +import { cn, getSubscriptionName } from "@plane/utils"; +// components +import { DiscountInfo } from "@/components/license/modal/card/discount-info"; +import type { TPlanDetail } from "@/constants/plans"; +// local imports +import { captureSuccess } from "@/helpers/event-tracker.helper"; +import { PlanFrequencyToggle } from "./frequency-toggle"; + +type TPlanDetailProps = { + subscriptionType: EProductSubscriptionEnum; + planDetail: TPlanDetail; + billingFrequency: TBillingFrequency | undefined; + setBillingFrequency: (frequency: TBillingFrequency) => void; +}; + +const COMMON_BUTTON_STYLE = + "relative inline-flex items-center justify-center w-full px-4 py-1.5 text-xs font-medium rounded-lg focus:outline-none transition-all duration-300 animate-slide-up"; + +export const PlanDetail: FC = observer((props) => { + const { subscriptionType, planDetail, billingFrequency, setBillingFrequency } = props; + // plane hooks + const { t } = useTranslation(); + // subscription details + const subscriptionName = getSubscriptionName(subscriptionType); + const isSubscriptionActive = planDetail.isActive; + // pricing details + const displayPrice = billingFrequency === "month" ? planDetail.monthlyPrice : planDetail.yearlyPrice; + const pricingDescription = isSubscriptionActive ? "a user per month" : "Quote on request"; + const pricingSecondaryDescription = + billingFrequency === "month" + ? planDetail.monthlyPriceSecondaryDescription + : planDetail.yearlyPriceSecondaryDescription; + // helper styles + const upgradeButtonStyle = getUpgradeButtonStyle(subscriptionType, false) ?? getButtonStyling("primary", "lg"); + + const handleRedirection = () => { + const frequency = billingFrequency ?? "year"; + // Get the redirection URL based on the subscription type and billing frequency + const redirectUrl = SUBSCRIPTION_REDIRECTION_URLS[subscriptionType][frequency] ?? TALK_TO_SALES_URL; + captureSuccess({ + eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.upgrade_plan_redirected, + payload: { + subscriptionType, + }, + }); + // Open the URL in a new tab + window.open(redirectUrl, "_blank"); + }; + + return ( +
    + {/* Plan name and pricing section */} +
    +
    + {subscriptionName} + {subscriptionType === EProductSubscriptionEnum.PRO && ( + Popular + )} +
    +
    + {isSubscriptionActive && displayPrice !== undefined && ( +
    + +
    + )} +
    + {pricingDescription &&
    {pricingDescription}
    } + {pricingSecondaryDescription && ( +
    + {pricingSecondaryDescription} +
    + )} +
    +
    +
    + + {/* Billing frequency toggle */} + {SUBSCRIPTION_WITH_BILLING_FREQUENCY.includes(subscriptionType) && billingFrequency && ( +
    + +
    + )} + + {/* Subscription button */} +
    + +
    +
    + ); +}); diff --git a/apps/web/ce/components/workspace/billing/comparison/root.tsx b/apps/web/ce/components/workspace/billing/comparison/root.tsx new file mode 100644 index 00000000..3c095827 --- /dev/null +++ b/apps/web/ce/components/workspace/billing/comparison/root.tsx @@ -0,0 +1,48 @@ +import { observer } from "mobx-react"; +// plane imports +import type { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; +// components +import { PlansComparisonBase, shouldRenderPlanDetail } from "@/components/workspace/billing/comparison/base"; +import type { TPlanePlans } from "@/constants/plans"; +import { PLANE_PLANS } from "@/constants/plans"; +// plane web imports +import { PlanDetail } from "./plan-detail"; + +type TPlansComparisonProps = { + isCompareAllFeaturesSectionOpen: boolean; + getBillingFrequency: (subscriptionType: EProductSubscriptionEnum) => TBillingFrequency | undefined; + setBillingFrequency: (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency) => void; + setIsCompareAllFeaturesSectionOpen: React.Dispatch>; +}; + +export const PlansComparison = observer((props: TPlansComparisonProps) => { + const { + isCompareAllFeaturesSectionOpen, + getBillingFrequency, + setBillingFrequency, + setIsCompareAllFeaturesSectionOpen, + } = props; + // plan details + const { planDetails } = PLANE_PLANS; + + return ( + { + const currentPlanKey = planKey as TPlanePlans; + if (!shouldRenderPlanDetail(currentPlanKey)) return null; + return ( + setBillingFrequency(plan.id, frequency)} + /> + ); + })} + isSelfManaged + isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen} + setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen} + /> + ); +}); diff --git a/apps/web/ce/components/workspace/billing/index.ts b/apps/web/ce/components/workspace/billing/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/ce/components/workspace/billing/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/workspace/billing/root.tsx b/apps/web/ce/components/workspace/billing/root.tsx new file mode 100644 index 00000000..e9ac6e18 --- /dev/null +++ b/apps/web/ce/components/workspace/billing/root.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { DEFAULT_PRODUCT_BILLING_FREQUENCY, SUBSCRIPTION_WITH_BILLING_FREQUENCY } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { TBillingFrequency, TProductBillingFrequency } from "@plane/types"; +import { EProductSubscriptionEnum } from "@plane/types"; +import { getSubscriptionTextColor } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { SettingsHeading } from "@/components/settings/heading"; +// local imports +import { PlansComparison } from "./comparison/root"; + +export const BillingRoot = observer(() => { + const [isCompareAllFeaturesSectionOpen, setIsCompareAllFeaturesSectionOpen] = useState(false); + const [productBillingFrequency, setProductBillingFrequency] = useState( + DEFAULT_PRODUCT_BILLING_FREQUENCY + ); + const { t } = useTranslation(); + + /** + * Retrieves the billing frequency for a given subscription type + * @param {EProductSubscriptionEnum} subscriptionType - Type of subscription to get frequency for + * @returns {TBillingFrequency | undefined} - Billing frequency if subscription supports it, undefined otherwise + */ + const getBillingFrequency = (subscriptionType: EProductSubscriptionEnum): TBillingFrequency | undefined => + SUBSCRIPTION_WITH_BILLING_FREQUENCY.includes(subscriptionType) + ? productBillingFrequency[subscriptionType] + : undefined; + + /** + * Updates the billing frequency for a specific subscription type + * @param {EProductSubscriptionEnum} subscriptionType - Type of subscription to update + * @param {TBillingFrequency} frequency - New billing frequency to set + * @returns {void} + */ + const setBillingFrequency = (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency): void => + setProductBillingFrequency({ ...productBillingFrequency, [subscriptionType]: frequency }); + + return ( +
    + +
    +
    +
    +
    +
    +

    + Community +

    +
    + Unlimited projects, issues, cycles, modules, pages, and storage +
    +
    +
    +
    +
    +
    All plans
    +
    + +
    + ); +}); diff --git a/apps/web/ce/components/workspace/content-wrapper.tsx b/apps/web/ce/components/workspace/content-wrapper.tsx new file mode 100644 index 00000000..79a74f56 --- /dev/null +++ b/apps/web/ce/components/workspace/content-wrapper.tsx @@ -0,0 +1,9 @@ +"use client"; +import React from "react"; +import { observer } from "mobx-react"; + +export const WorkspaceContentWrapper = observer(({ children }: { children: React.ReactNode }) => ( +
    +
    {children}
    +
    +)); diff --git a/apps/web/ce/components/workspace/delete-workspace-modal.tsx b/apps/web/ce/components/workspace/delete-workspace-modal.tsx new file mode 100644 index 00000000..cccee781 --- /dev/null +++ b/apps/web/ce/components/workspace/delete-workspace-modal.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import type { IWorkspace } from "@plane/types"; +// ui +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; +// constants +// hooks + +import { DeleteWorkspaceForm } from "@/components/workspace/delete-workspace-form"; + +type Props = { + isOpen: boolean; + data: IWorkspace | null; + onClose: () => void; +}; + +export const DeleteWorkspaceModal: React.FC = observer((props) => { + const { isOpen, data, onClose } = props; + + return ( + onClose()} position={EModalPosition.CENTER} width={EModalWidth.XL}> + + + ); +}); diff --git a/apps/web/ce/components/workspace/delete-workspace-section.tsx b/apps/web/ce/components/workspace/delete-workspace-section.tsx new file mode 100644 index 00000000..aa72fdc3 --- /dev/null +++ b/apps/web/ce/components/workspace/delete-workspace-section.tsx @@ -0,0 +1,68 @@ +import type { FC } from "react"; +import { useState } from "react"; +import { observer } from "mobx-react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +// types +import { WORKSPACE_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import type { IWorkspace } from "@plane/types"; +// ui +import { Collapsible } from "@plane/ui"; +import { DeleteWorkspaceModal } from "./delete-workspace-modal"; +// components + +type TDeleteWorkspace = { + workspace: IWorkspace | null; +}; + +export const DeleteWorkspaceSection: FC = observer((props) => { + const { workspace } = props; + // states + const [isOpen, setIsOpen] = useState(false); + const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState(false); + const { t } = useTranslation(); + + return ( + <> + setDeleteWorkspaceModal(false)} + /> +
    +
    + setIsOpen(!isOpen)} + className="w-full" + buttonClassName="flex w-full items-center justify-between py-4" + title={ + <> + + {t("workspace_settings.settings.general.delete_workspace")} + + {isOpen ? : } + + } + > +
    + + {t("workspace_settings.settings.general.delete_workspace_description")} + +
    + +
    +
    +
    +
    +
    + + ); +}); diff --git a/apps/web/ce/components/workspace/edition-badge.tsx b/apps/web/ce/components/workspace/edition-badge.tsx new file mode 100644 index 00000000..bd846c93 --- /dev/null +++ b/apps/web/ce/components/workspace/edition-badge.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// ui +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { Tooltip } from "@plane/propel/tooltip"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; +import packageJson from "package.json"; +// local components +import { PaidPlanUpgradeModal } from "../license"; + +export const WorkspaceEditionBadge = observer(() => { + // states + const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false); + // translation + const { t } = useTranslation(); + // platform + const { isMobile } = usePlatformOS(); + + return ( + <> + setIsPaidPlanPurchaseModalOpen(false)} + /> + + + + + ); +}); diff --git a/apps/web/ce/components/workspace/members/invite-modal.tsx b/apps/web/ce/components/workspace/members/invite-modal.tsx new file mode 100644 index 00000000..f83234e2 --- /dev/null +++ b/apps/web/ce/components/workspace/members/invite-modal.tsx @@ -0,0 +1,60 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import type { IWorkspaceBulkInviteFormData } from "@plane/types"; +import { EModalWidth, EModalPosition, ModalCore } from "@plane/ui"; +// components +import { InvitationModalActions } from "@/components/workspace/invite-modal/actions"; +import { InvitationFields } from "@/components/workspace/invite-modal/fields"; +import { InvitationForm } from "@/components/workspace/invite-modal/form"; +// hooks +import { useWorkspaceInvitationActions } from "@/hooks/use-workspace-invitation"; + +export type TSendWorkspaceInvitationModalProps = { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: IWorkspaceBulkInviteFormData) => Promise | undefined; +}; + +export const SendWorkspaceInvitationModal: React.FC = observer((props) => { + const { isOpen, onClose, onSubmit } = props; + // store hooks + const { t } = useTranslation(); + // router + const { workspaceSlug } = useParams(); + // derived values + const { control, fields, formState, remove, onFormSubmit, handleClose, appendField } = useWorkspaceInvitationActions({ + onSubmit, + onClose, + }); + + return ( + + + } + className="p-5" + > + + + + ); +}); diff --git a/apps/web/ce/components/workspace/settings/useMemberColumns.tsx b/apps/web/ce/components/workspace/settings/useMemberColumns.tsx new file mode 100644 index 00000000..8f2286a6 --- /dev/null +++ b/apps/web/ce/components/workspace/settings/useMemberColumns.tsx @@ -0,0 +1,131 @@ +import { useState } from "react"; +import { useParams } from "next/navigation"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { renderFormattedDate } from "@plane/utils"; +import { MemberHeaderColumn } from "@/components/project/member-header-column"; +import type { RowData } from "@/components/workspace/settings/member-columns"; +import { AccountTypeColumn, NameColumn } from "@/components/workspace/settings/member-columns"; +import { useMember } from "@/hooks/store/use-member"; +import { useUser, useUserPermissions } from "@/hooks/store/user"; +import type { IMemberFilters } from "@/store/member/utils"; + +export const useMemberColumns = () => { + // states + const [removeMemberModal, setRemoveMemberModal] = useState(null); + + const { workspaceSlug } = useParams(); + + const { data: currentUser } = useUser(); + const { allowPermissions } = useUserPermissions(); + const { + workspace: { + filtersStore: { filters, updateFilters }, + }, + } = useMember(); + const { t } = useTranslation(); + + // derived values + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + const isSuspended = (rowData: RowData) => rowData.is_active === false; + // handlers + const handleDisplayFilterUpdate = (filterUpdates: Partial) => { + updateFilters(filterUpdates); + }; + + const columns = [ + { + key: "Full name", + content: t("workspace_settings.settings.members.details.full_name"), + thClassName: "text-left", + thRender: () => ( + + ), + tdRender: (rowData: RowData) => ( + + ), + }, + + { + key: "Display name", + content: t("workspace_settings.settings.members.details.display_name"), + tdRender: (rowData: RowData) => ( +
    + {rowData.member.display_name} +
    + ), + thRender: () => ( + + ), + }, + + { + key: "Email address", + content: t("workspace_settings.settings.members.details.email_address"), + tdRender: (rowData: RowData) => ( +
    + {rowData.member.email} +
    + ), + thRender: () => ( + + ), + }, + + { + key: "Account type", + content: t("workspace_settings.settings.members.details.account_type"), + thRender: () => ( + + ), + tdRender: (rowData: RowData) => , + }, + + { + key: "Authentication", + content: t("workspace_settings.settings.members.details.authentication"), + tdRender: (rowData: RowData) => + isSuspended(rowData) ? null : ( +
    {rowData.member.last_login_medium?.replace("-", " ")}
    + ), + }, + + { + key: "Joining date", + content: t("workspace_settings.settings.members.details.joining_date"), + tdRender: (rowData: RowData) => + isSuspended(rowData) ? null :
    {renderFormattedDate(rowData?.member?.joining_date)}
    , + thRender: () => ( + + ), + }, + ]; + return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal }; +}; diff --git a/apps/web/ce/components/workspace/sidebar/app-search.tsx b/apps/web/ce/components/workspace/sidebar/app-search.tsx new file mode 100644 index 00000000..9e0f4cd9 --- /dev/null +++ b/apps/web/ce/components/workspace/sidebar/app-search.tsx @@ -0,0 +1,23 @@ +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// hooks +import { SidebarSearchButton } from "@/components/sidebar/search-button"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; + +export const AppSearch = observer(() => { + // store hooks + const { toggleCommandPaletteModal } = useCommandPalette(); + // translation + const { t } = useTranslation(); + + return ( + + ); +}); diff --git a/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx b/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx new file mode 100644 index 00000000..44b47c70 --- /dev/null +++ b/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx @@ -0,0 +1,222 @@ +import type { FC } from "react"; +import { useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +import { Pin, PinOff } from "lucide-react"; +// plane imports +import type { IWorkspaceSidebarNavigationItem } from "@plane/constants"; +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; +import { DragHandle, DropIndicator } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUser, useUserPermissions } from "@/hooks/store/user"; +// plane web imports +// local imports +import { UpgradeBadge } from "../upgrade-badge"; +import { getSidebarNavigationItemIcon } from "./helper"; + +type TExtendedSidebarItemProps = { + item: IWorkspaceSidebarNavigationItem; + handleOnNavigationItemDrop?: ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => void; + disableDrag?: boolean; + disableDrop?: boolean; + isLastChild: boolean; +}; + +export const ExtendedSidebarItem: FC = observer((props) => { + const { item, handleOnNavigationItemDrop, disableDrag = false, disableDrop = false, isLastChild } = props; + const { t } = useTranslation(); + // states + const [isDragging, setIsDragging] = useState(false); + const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined); + // refs + const navigationIemRef = useRef(null); + const dragHandleRef = useRef(null); + + // nextjs hooks + const pathname = usePathname(); + const { workspaceSlug } = useParams(); + // store hooks + const { getNavigationPreferences, updateSidebarPreference } = useWorkspace(); + const { toggleExtendedSidebar } = useAppTheme(); + const { data } = useUser(); + const { allowPermissions } = useUserPermissions(); + + // derived values + const sidebarPreference = getNavigationPreferences(workspaceSlug.toString()); + const isPinned = sidebarPreference?.[item.key]?.is_pinned; + + const handleLinkClick = () => toggleExtendedSidebar(true); + + if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) { + return null; + } + + const itemHref = + item.key === "your_work" + ? `/${workspaceSlug.toString()}${item.href}${data?.id}` + : `/${workspaceSlug.toString()}${item.href}`; + const isActive = itemHref === pathname; + + const pinNavigationItem = (workspaceSlug: string, key: string) => { + updateSidebarPreference(workspaceSlug, key, { is_pinned: true }); + }; + + const unPinNavigationItem = (workspaceSlug: string, key: string) => { + updateSidebarPreference(workspaceSlug, key, { is_pinned: false }); + }; + + const icon = getSidebarNavigationItemIcon(item.key); + + useEffect(() => { + const element = navigationIemRef.current; + const dragHandleElement = dragHandleRef.current; + + if (!element) return; + + return combine( + draggable({ + element, + canDrag: () => !disableDrag, + dragHandle: dragHandleElement ?? undefined, + getInitialData: () => ({ id: item.key, dragInstanceId: "NAVIGATION" }), // var1 + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + }, + }), + dropTargetForElements({ + element, + canDrop: ({ source }) => + !disableDrop && source?.data?.id !== item.key && source?.data?.dragInstanceId === "NAVIGATION", + getData: ({ input, element }) => { + const data = { id: item.key }; + + // attach instruction for last in list + return attachInstruction(data, { + input, + element, + currentLevel: 0, + indentPerLevel: 0, + mode: isLastChild ? "last-in-group" : "standard", + }); + }, + onDrag: ({ self }) => { + const extractedInstruction = extractInstruction(self?.data)?.type; + // check if the highlight is to be shown above or below + setInstruction( + extractedInstruction + ? extractedInstruction === "reorder-below" && isLastChild + ? "DRAG_BELOW" + : "DRAG_OVER" + : undefined + ); + }, + onDragLeave: () => { + setInstruction(undefined); + }, + onDrop: ({ self, source }) => { + setInstruction(undefined); + const extractedInstruction = extractInstruction(self?.data)?.type; + const currentInstruction = extractedInstruction + ? extractedInstruction === "reorder-below" && isLastChild + ? "DRAG_BELOW" + : "DRAG_OVER" + : undefined; + if (!currentInstruction) return; + + const sourceId = source?.data?.id as string | undefined; + const destinationId = self?.data?.id as string | undefined; + + if (handleOnNavigationItemDrop) + handleOnNavigationItemDrop(sourceId, destinationId, currentInstruction === "DRAG_BELOW"); + }, + }) + ); + }, [isLastChild, handleOnNavigationItemDrop, disableDrag, disableDrop, item.key]); + + return ( +
    + +
    + {!disableDrag && ( + + + + )} + + handleLinkClick()} className="group flex-grow"> +
    + {icon} +

    {t(item.labelTranslationKey)}

    +
    + +
    + {item.key === "active_cycles" && ( +
    + +
    + )} + {isPinned ? ( + + unPinNavigationItem(workspaceSlug.toString(), item.key)} + /> + + ) : ( + + pinNavigationItem(workspaceSlug.toString(), item.key)} + /> + + )} +
    +
    +
    + {isLastChild && } +
    + ); +}); diff --git a/apps/web/ce/components/workspace/sidebar/helper.tsx b/apps/web/ce/components/workspace/sidebar/helper.tsx new file mode 100644 index 00000000..316f77b5 --- /dev/null +++ b/apps/web/ce/components/workspace/sidebar/helper.tsx @@ -0,0 +1,35 @@ +import { + AnalyticsIcon, + ArchiveIcon, + CycleIcon, + DraftIcon, + HomeIcon, + InboxIcon, + ProjectIcon, + ViewsIcon, + YourWorkIcon, +} from "@plane/propel/icons"; +import { cn } from "@plane/utils"; + +export const getSidebarNavigationItemIcon = (key: string, className: string = "") => { + switch (key) { + case "home": + return ; + case "inbox": + return ; + case "projects": + return ; + case "views": + return ; + case "active_cycles": + return ; + case "analytics": + return ; + case "your_work": + return ; + case "drafts": + return ; + case "archives": + return ; + } +}; diff --git a/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx b/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx new file mode 100644 index 00000000..3fbc8d0a --- /dev/null +++ b/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx @@ -0,0 +1,9 @@ +import type { FC } from "react"; +import type { IWorkspaceSidebarNavigationItem } from "@plane/constants"; +import { SidebarItemBase } from "@/components/workspace/sidebar/sidebar-item"; + +type Props = { + item: IWorkspaceSidebarNavigationItem; +}; + +export const SidebarItem: FC = ({ item }) => ; diff --git a/apps/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx b/apps/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx new file mode 100644 index 00000000..92cbdfc5 --- /dev/null +++ b/apps/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx @@ -0,0 +1 @@ +export const SidebarTeamsList = () => null; diff --git a/apps/web/ce/components/workspace/upgrade-badge.tsx b/apps/web/ce/components/workspace/upgrade-badge.tsx new file mode 100644 index 00000000..17efeb01 --- /dev/null +++ b/apps/web/ce/components/workspace/upgrade-badge.tsx @@ -0,0 +1,30 @@ +import type { FC } from "react"; +// helpers +import { useTranslation } from "@plane/i18n"; +import { cn } from "@plane/utils"; + +type TUpgradeBadge = { + className?: string; + size?: "sm" | "md"; +}; + +export const UpgradeBadge: FC = (props) => { + const { className, size = "sm" } = props; + + const { t } = useTranslation(); + + return ( +
    + {t("sidebar.pro")} +
    + ); +}; diff --git a/apps/web/ce/constants/ai.ts b/apps/web/ce/constants/ai.ts new file mode 100644 index 00000000..c5c1b04f --- /dev/null +++ b/apps/web/ce/constants/ai.ts @@ -0,0 +1,9 @@ +export enum AI_EDITOR_TASKS { + ASK_ANYTHING = "ASK_ANYTHING", +} + +export const LOADING_TEXTS: { + [key in AI_EDITOR_TASKS]: string; +} = { + [AI_EDITOR_TASKS.ASK_ANYTHING]: "Pi is generating response", +}; diff --git a/apps/web/ce/constants/editor.ts b/apps/web/ce/constants/editor.ts new file mode 100644 index 00000000..f0187960 --- /dev/null +++ b/apps/web/ce/constants/editor.ts @@ -0,0 +1,4 @@ +// plane types +import type { TSearchEntities } from "@plane/types"; + +export const EDITOR_MENTION_TYPES: TSearchEntities[] = ["user_mention"]; diff --git a/apps/web/ce/constants/gantt-chart.ts b/apps/web/ce/constants/gantt-chart.ts new file mode 100644 index 00000000..95e39bcc --- /dev/null +++ b/apps/web/ce/constants/gantt-chart.ts @@ -0,0 +1,8 @@ +import type { TIssueRelationTypes } from "../types"; + +export const REVERSE_RELATIONS: { [key in TIssueRelationTypes]: TIssueRelationTypes } = { + blocked_by: "blocking", + blocking: "blocked_by", + relates_to: "relates_to", + duplicate: "duplicate", +}; diff --git a/apps/web/ce/constants/project/index.ts b/apps/web/ce/constants/project/index.ts new file mode 100644 index 00000000..dcf101b0 --- /dev/null +++ b/apps/web/ce/constants/project/index.ts @@ -0,0 +1 @@ +export * from "./settings"; diff --git a/apps/web/ce/constants/project/settings/features.tsx b/apps/web/ce/constants/project/settings/features.tsx new file mode 100644 index 00000000..380272ea --- /dev/null +++ b/apps/web/ce/constants/project/settings/features.tsx @@ -0,0 +1,118 @@ +import type { ReactNode } from "react"; +import { Timer } from "lucide-react"; +// plane imports +import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon } from "@plane/propel/icons"; +import type { IProject } from "@plane/types"; + +export type TProperties = { + key: string; + property: string; + title: string; + description: string; + icon: ReactNode; + isPro: boolean; + isEnabled: boolean; + renderChildren?: (currentProjectDetails: IProject, workspaceSlug: string) => ReactNode; + href?: string; +}; + +type TProjectBaseFeatureKeys = "cycles" | "modules" | "views" | "pages" | "inbox"; +type TProjectOtherFeatureKeys = "is_time_tracking_enabled"; + +type TBaseFeatureList = { + [key in TProjectBaseFeatureKeys]: TProperties; +}; + +export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = { + cycles: { + key: "cycles", + property: "cycle_view", + title: "Cycles", + description: "Timebox work as you see fit per project and change frequency from one period to the next.", + icon: , + isPro: false, + isEnabled: true, + }, + modules: { + key: "modules", + property: "module_view", + title: "Modules", + description: "Group work into sub-project-like set-ups with their own leads and assignees.", + icon: , + isPro: false, + isEnabled: true, + }, + views: { + key: "views", + property: "issue_views_view", + title: "Views", + description: "Save sorts, filters, and display options for later or share them.", + icon: , + isPro: false, + isEnabled: true, + }, + pages: { + key: "pages", + property: "page_view", + title: "Pages", + description: "Write anything like you write anything.", + icon: , + isPro: false, + isEnabled: true, + }, + inbox: { + key: "intake", + property: "inbox_view", + title: "Intake", + description: "Consider and discuss work items before you add them to your project.", + icon: , + isPro: false, + isEnabled: true, + }, +}; + +type TOtherFeatureList = { + [key in TProjectOtherFeatureKeys]: TProperties; +}; + +export const PROJECT_OTHER_FEATURES_LIST: TOtherFeatureList = { + is_time_tracking_enabled: { + key: "time_tracking", + property: "is_time_tracking_enabled", + title: "Time Tracking", + description: "Log time, see timesheets, and download full CSVs for your entire workspace.", + icon: , + isPro: true, + isEnabled: false, + }, +}; + +type TProjectFeatures = { + project_features: { + key: string; + title: string; + description: string; + featureList: TBaseFeatureList; + }; + project_others: { + key: string; + title: string; + description: string; + featureList: TOtherFeatureList; + }; +}; + +export const PROJECT_FEATURES_LIST: TProjectFeatures = { + project_features: { + key: "projects_and_issues", + title: "Projects and work items", + description: "Toggle these on or off this project.", + featureList: PROJECT_BASE_FEATURES_LIST, + }, + project_others: { + key: "work_management", + title: "Work management", + description: "Available only on some plans as indicated by the label next to the feature below.", + featureList: PROJECT_OTHER_FEATURES_LIST, + }, +}; diff --git a/apps/web/ce/constants/project/settings/index.ts b/apps/web/ce/constants/project/settings/index.ts new file mode 100644 index 00000000..a6a842e7 --- /dev/null +++ b/apps/web/ce/constants/project/settings/index.ts @@ -0,0 +1,2 @@ +export * from "./features"; +export * from "./tabs"; diff --git a/apps/web/ce/constants/project/settings/tabs.ts b/apps/web/ce/constants/project/settings/tabs.ts new file mode 100644 index 00000000..f78b51a7 --- /dev/null +++ b/apps/web/ce/constants/project/settings/tabs.ts @@ -0,0 +1,82 @@ +// icons +import { EUserPermissions } from "@plane/constants"; +import { SettingIcon } from "@/components/icons/attachment"; +// types +import type { Props } from "@/components/icons/types"; +// constants + +export const PROJECT_SETTINGS = { + general: { + key: "general", + i18n_label: "common.general", + href: ``, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`, + Icon: SettingIcon, + }, + members: { + key: "members", + i18n_label: "common.members", + href: `/members`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`, + Icon: SettingIcon, + }, + features: { + key: "features", + i18n_label: "common.features", + href: `/features`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/`, + Icon: SettingIcon, + }, + states: { + key: "states", + i18n_label: "common.states", + href: `/states`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/states/`, + Icon: SettingIcon, + }, + labels: { + key: "labels", + i18n_label: "common.labels", + href: `/labels`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/labels/`, + Icon: SettingIcon, + }, + estimates: { + key: "estimates", + i18n_label: "common.estimates", + href: `/estimates`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/estimates/`, + Icon: SettingIcon, + }, + automations: { + key: "automations", + i18n_label: "project_settings.automations.label", + href: `/automations`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/automations/`, + Icon: SettingIcon, + }, +}; + +export const PROJECT_SETTINGS_LINKS: { + key: string; + i18n_label: string; + href: string; + access: EUserPermissions[]; + highlight: (pathname: string, baseUrl: string) => boolean; + Icon: React.FC; +}[] = [ + PROJECT_SETTINGS["general"], + PROJECT_SETTINGS["members"], + PROJECT_SETTINGS["features"], + PROJECT_SETTINGS["states"], + PROJECT_SETTINGS["labels"], + PROJECT_SETTINGS["estimates"], + PROJECT_SETTINGS["automations"], +]; diff --git a/apps/web/ce/constants/sidebar-favorites.ts b/apps/web/ce/constants/sidebar-favorites.ts new file mode 100644 index 00000000..aaa615e8 --- /dev/null +++ b/apps/web/ce/constants/sidebar-favorites.ts @@ -0,0 +1,42 @@ +import type { LucideIcon } from "lucide-react"; +// plane imports +import type { ISvgIcons } from "@plane/propel/icons"; +import { CycleIcon, FavoriteFolderIcon, ModuleIcon, PageIcon, ProjectIcon, ViewsIcon } from "@plane/propel/icons"; +import type { IFavorite } from "@plane/types"; + +export const FAVORITE_ITEM_ICONS: Record | LucideIcon> = { + page: PageIcon, + project: ProjectIcon, + view: ViewsIcon, + module: ModuleIcon, + cycle: CycleIcon, + folder: FavoriteFolderIcon, +}; + +export const FAVORITE_ITEM_LINKS: { + [key: string]: { + itemLevel: "project" | "workspace"; + getLink: (favorite: IFavorite) => string; + }; +} = { + project: { + itemLevel: "project", + getLink: () => `issues`, + }, + cycle: { + itemLevel: "project", + getLink: (favorite) => `cycles/${favorite.entity_identifier}`, + }, + module: { + itemLevel: "project", + getLink: (favorite) => `modules/${favorite.entity_identifier}`, + }, + view: { + itemLevel: "project", + getLink: (favorite) => `views/${favorite.entity_identifier}`, + }, + page: { + itemLevel: "project", + getLink: (favorite) => `pages/${favorite.entity_identifier}`, + }, +}; diff --git a/apps/web/ce/helpers/command-palette.ts b/apps/web/ce/helpers/command-palette.ts new file mode 100644 index 00000000..d29660a1 --- /dev/null +++ b/apps/web/ce/helpers/command-palette.ts @@ -0,0 +1,122 @@ +// types +import { + CYCLE_TRACKER_ELEMENTS, + MODULE_TRACKER_ELEMENTS, + PROJECT_PAGE_TRACKER_ELEMENTS, + PROJECT_TRACKER_ELEMENTS, + PROJECT_VIEW_TRACKER_ELEMENTS, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; +import type { TCommandPaletteActionList, TCommandPaletteShortcut, TCommandPaletteShortcutList } from "@plane/types"; +// store +import { captureClick } from "@/helpers/event-tracker.helper"; +import { store } from "@/lib/store-context"; + +export const getGlobalShortcutsList: () => TCommandPaletteActionList = () => { + const { toggleCreateIssueModal } = store.commandPalette; + + return { + c: { + title: "Create a new work item", + description: "Create a new work item in the current project", + action: () => { + toggleCreateIssueModal(true); + captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON }); + }, + }, + }; +}; + +export const getWorkspaceShortcutsList: () => TCommandPaletteActionList = () => { + const { toggleCreateProjectModal } = store.commandPalette; + + return { + p: { + title: "Create a new project", + description: "Create a new project in the current workspace", + action: () => { + toggleCreateProjectModal(true); + captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_CREATE_BUTTON }); + }, + }, + }; +}; + +export const getProjectShortcutsList: () => TCommandPaletteActionList = () => { + const { + toggleCreatePageModal, + toggleCreateModuleModal, + toggleCreateCycleModal, + toggleCreateViewModal, + toggleBulkDeleteIssueModal, + } = store.commandPalette; + + return { + d: { + title: "Create a new page", + description: "Create a new page in the current project", + action: () => { + toggleCreatePageModal({ isOpen: true }); + captureClick({ elementName: PROJECT_PAGE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_CREATE_BUTTON }); + }, + }, + m: { + title: "Create a new module", + description: "Create a new module in the current project", + action: () => { + toggleCreateModuleModal(true); + captureClick({ elementName: MODULE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM }); + }, + }, + q: { + title: "Create a new cycle", + description: "Create a new cycle in the current project", + action: () => { + toggleCreateCycleModal(true); + captureClick({ elementName: CYCLE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM }); + }, + }, + v: { + title: "Create a new view", + description: "Create a new view in the current project", + action: () => { + toggleCreateViewModal(true); + captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM }); + }, + }, + backspace: { + title: "Bulk delete work items", + description: "Bulk delete work items in the current project", + action: () => toggleBulkDeleteIssueModal(true), + }, + delete: { + title: "Bulk delete work items", + description: "Bulk delete work items in the current project", + action: () => toggleBulkDeleteIssueModal(true), + }, + }; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const handleAdditionalKeyDownEvents = (e: KeyboardEvent) => null; + +export const getNavigationShortcutsList = (): TCommandPaletteShortcut[] => [ + { keys: "Ctrl,K", description: "Open command menu" }, +]; + +export const getCommonShortcutsList = (platform: string): TCommandPaletteShortcut[] => [ + { keys: "P", description: "Create project" }, + { keys: "C", description: "Create work item" }, + { keys: "Q", description: "Create cycle" }, + { keys: "M", description: "Create module" }, + { keys: "V", description: "Create view" }, + { keys: "D", description: "Create page" }, + { keys: "Delete", description: "Bulk delete work items" }, + { keys: "Shift,/", description: "Open shortcuts guide" }, + { + keys: platform === "MacOS" ? "Ctrl,control,C" : "Ctrl,Alt,C", + description: "Copy work item URL from the work item details page", + }, +]; + +export const getAdditionalShortcutsList = (): TCommandPaletteShortcutList[] => []; diff --git a/apps/web/ce/helpers/epic-analytics.ts b/apps/web/ce/helpers/epic-analytics.ts new file mode 100644 index 00000000..1a7a9df4 --- /dev/null +++ b/apps/web/ce/helpers/epic-analytics.ts @@ -0,0 +1,15 @@ +import type { TEpicAnalyticsGroup } from "@plane/types"; + +export const updateEpicAnalytics = () => { + const updateAnalytics = ( + workspaceSlug: string, + projectId: string, + epicId: string, + data: { + incrementStateGroupCount?: TEpicAnalyticsGroup; + decrementStateGroupCount?: TEpicAnalyticsGroup; + } + ) => {}; + + return { updateAnalytics }; +}; diff --git a/apps/web/ce/helpers/instance.helper.ts b/apps/web/ce/helpers/instance.helper.ts new file mode 100644 index 00000000..622ef4af --- /dev/null +++ b/apps/web/ce/helpers/instance.helper.ts @@ -0,0 +1,7 @@ +import { store } from "@/lib/store-context"; + +export const getIsWorkspaceCreationDisabled = () => { + const instanceConfig = store.instance.config; + + return instanceConfig?.is_workspace_creation_disabled; +}; diff --git a/apps/web/ce/helpers/issue-action-helper.ts b/apps/web/ce/helpers/issue-action-helper.ts new file mode 100644 index 00000000..a3c66e27 --- /dev/null +++ b/apps/web/ce/helpers/issue-action-helper.ts @@ -0,0 +1,22 @@ +import type { IssueActions } from "@/hooks/use-issues-actions"; + +export const useTeamIssueActions: () => IssueActions = () => ({ + fetchIssues: () => Promise.resolve(undefined), + fetchNextIssues: () => Promise.resolve(undefined), + removeIssue: () => Promise.resolve(undefined), + updateFilters: () => Promise.resolve(undefined), +}); + +export const useTeamViewIssueActions: () => IssueActions = () => ({ + fetchIssues: () => Promise.resolve(undefined), + fetchNextIssues: () => Promise.resolve(undefined), + removeIssue: () => Promise.resolve(undefined), + updateFilters: () => Promise.resolve(undefined), +}); + +export const useTeamProjectWorkItemsActions: () => IssueActions = () => ({ + fetchIssues: () => Promise.resolve(undefined), + fetchNextIssues: () => Promise.resolve(undefined), + removeIssue: () => Promise.resolve(undefined), + updateFilters: () => Promise.resolve(undefined), +}); diff --git a/apps/web/ce/helpers/issue-filter.helper.ts b/apps/web/ce/helpers/issue-filter.helper.ts new file mode 100644 index 00000000..48a893d3 --- /dev/null +++ b/apps/web/ce/helpers/issue-filter.helper.ts @@ -0,0 +1,30 @@ +// types +import type { IIssueDisplayProperties } from "@plane/types"; +// lib +import { store } from "@/lib/store-context"; + +export type TShouldRenderDisplayProperty = { + workspaceSlug: string; + projectId: string | undefined; + key: keyof IIssueDisplayProperties; +}; + +export const shouldRenderDisplayProperty = (props: TShouldRenderDisplayProperty) => { + const { key } = props; + switch (key) { + case "issue_type": + return false; + default: + return true; + } +}; + +export const shouldRenderColumn = (key: keyof IIssueDisplayProperties): boolean => { + const isEstimateEnabled: boolean = store.projectRoot.project.currentProjectDetails?.estimate !== null; + switch (key) { + case "estimate": + return isEstimateEnabled; + default: + return true; + } +}; diff --git a/apps/web/ce/helpers/pi-chat.helper.ts b/apps/web/ce/helpers/pi-chat.helper.ts new file mode 100644 index 00000000..5676e9c0 --- /dev/null +++ b/apps/web/ce/helpers/pi-chat.helper.ts @@ -0,0 +1,3 @@ +export const hideFloatingBot = () => {}; + +export const showFloatingBot = () => {}; diff --git a/apps/web/ce/helpers/project-settings.ts b/apps/web/ce/helpers/project-settings.ts new file mode 100644 index 00000000..dbe06507 --- /dev/null +++ b/apps/web/ce/helpers/project-settings.ts @@ -0,0 +1,7 @@ +/** + * @description Get the i18n key for the project settings page label + * @param _settingsKey - The key of the project settings page + * @param defaultLabelKey - The default i18n key for the project settings page label + * @returns The i18n key for the project settings page label + */ +export const getProjectSettingsPageLabelI18nKey = (_settingsKey: string, defaultLabelKey: string) => defaultLabelKey; diff --git a/apps/web/ce/helpers/work-item-filters/project-level.ts b/apps/web/ce/helpers/work-item-filters/project-level.ts new file mode 100644 index 00000000..be0bc64e --- /dev/null +++ b/apps/web/ce/helpers/work-item-filters/project-level.ts @@ -0,0 +1,20 @@ +// plane imports +import type { EIssuesStoreType } from "@plane/types"; +// plane web imports +import type { TWorkItemFiltersEntityProps } from "@/plane-web/hooks/work-item-filters/use-work-item-filters-config"; + +export type TGetAdditionalPropsForProjectLevelFiltersHOCParams = { + entityType: EIssuesStoreType; + workspaceSlug: string; + projectId: string; +}; + +export type TGetAdditionalPropsForProjectLevelFiltersHOC = ( + params: TGetAdditionalPropsForProjectLevelFiltersHOCParams +) => TWorkItemFiltersEntityProps; + +export const getAdditionalProjectLevelFiltersHOCProps: TGetAdditionalPropsForProjectLevelFiltersHOC = ({ + workspaceSlug, +}) => ({ + workspaceSlug, +}); diff --git a/apps/web/ce/helpers/workspace.helper.ts b/apps/web/ce/helpers/workspace.helper.ts new file mode 100644 index 00000000..5e4bf3e4 --- /dev/null +++ b/apps/web/ce/helpers/workspace.helper.ts @@ -0,0 +1,2 @@ +export type TRenderSettingsLink = (workspaceSlug: string, settingKey: string) => boolean; +export const shouldRenderSettingLink: TRenderSettingsLink = (workspaceSlug, settingKey) => true; diff --git a/apps/web/ce/hooks/editor/use-extended-editor-config.ts b/apps/web/ce/hooks/editor/use-extended-editor-config.ts new file mode 100644 index 00000000..9ca7b74a --- /dev/null +++ b/apps/web/ce/hooks/editor/use-extended-editor-config.ts @@ -0,0 +1,23 @@ +import { useCallback } from "react"; +// plane imports +import type { TExtendedFileHandler } from "@plane/editor"; + +export type TExtendedEditorFileHandlersArgs = { + projectId?: string; + workspaceSlug: string; +}; + +export type TExtendedEditorConfig = { + getExtendedEditorFileHandlers: (args: TExtendedEditorFileHandlersArgs) => TExtendedFileHandler; +}; + +export const useExtendedEditorConfig = (): TExtendedEditorConfig => { + const getExtendedEditorFileHandlers: TExtendedEditorConfig["getExtendedEditorFileHandlers"] = useCallback( + () => ({}), + [] + ); + + return { + getExtendedEditorFileHandlers, + }; +}; diff --git a/apps/web/ce/hooks/pages/index.ts b/apps/web/ce/hooks/pages/index.ts new file mode 100644 index 00000000..e67eaa79 --- /dev/null +++ b/apps/web/ce/hooks/pages/index.ts @@ -0,0 +1,2 @@ +export * from "./use-pages-pane-extensions"; +export * from "./use-extended-editor-extensions"; diff --git a/apps/web/ce/hooks/pages/use-extended-editor-extensions.ts b/apps/web/ce/hooks/pages/use-extended-editor-extensions.ts new file mode 100644 index 00000000..73757848 --- /dev/null +++ b/apps/web/ce/hooks/pages/use-extended-editor-extensions.ts @@ -0,0 +1,20 @@ +import type { IEditorPropsExtended } from "@plane/editor"; +import type { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; +import type { TPageInstance } from "@/store/pages/base-page"; +import type { EPageStoreType } from "../store"; + +export type TExtendedEditorExtensionsHookParams = { + workspaceSlug: string; + page: TPageInstance; + storeType: EPageStoreType; + fetchEntity: (payload: TSearchEntityRequestPayload) => Promise; + getRedirectionLink: (pageId?: string) => string; + extensionHandlers?: Map; + projectId?: string; +}; + +export type TExtendedEditorExtensionsConfig = IEditorPropsExtended; + +export const useExtendedEditorProps = ( + _params: TExtendedEditorExtensionsHookParams +): TExtendedEditorExtensionsConfig => ({}); diff --git a/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts b/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts new file mode 100644 index 00000000..5aef069c --- /dev/null +++ b/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts @@ -0,0 +1,62 @@ +import { useCallback, useMemo } from "react"; +import type { RefObject } from "react"; +import { useSearchParams } from "next/navigation"; +import type { EditorRefApi } from "@plane/editor"; +import { + PAGE_NAVIGATION_PANE_TAB_KEYS, + PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, + PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, +} from "@/components/pages/navigation-pane"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useQueryParams } from "@/hooks/use-query-params"; +import type { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane"; +import type { INavigationPaneExtension } from "@/plane-web/types/pages/pane-extensions"; +import type { TPageInstance } from "@/store/pages/base-page"; + +export type TPageExtensionHookParams = { + page: TPageInstance; + editorRef: RefObject; +}; + +export const usePagesPaneExtensions = (_params: TPageExtensionHookParams) => { + const router = useAppRouter(); + const { updateQueryParams } = useQueryParams(); + const searchParams = useSearchParams(); + + // Generic navigation pane logic - hook manages feature-specific routing + const navigationPaneQueryParam = searchParams.get( + PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM + ) as TPageNavigationPaneTab | null; + + const isNavigationPaneOpen = + !!navigationPaneQueryParam && PAGE_NAVIGATION_PANE_TAB_KEYS.includes(navigationPaneQueryParam); + + const handleOpenNavigationPane = useCallback(() => { + const updatedRoute = updateQueryParams({ + paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: "outline" }, + }); + router.push(updatedRoute); + }, [router, updateQueryParams]); + + const editorExtensionHandlers: Map = useMemo(() => { + const map: Map = new Map(); + return map; + }, []); + + const navigationPaneExtensions: INavigationPaneExtension[] = []; + + const handleCloseNavigationPane = useCallback(() => { + const updatedRoute = updateQueryParams({ + paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], + }); + router.push(updatedRoute); + }, [router, updateQueryParams]); + + return { + editorExtensionHandlers, + navigationPaneExtensions, + handleOpenNavigationPane, + isNavigationPaneOpen, + handleCloseNavigationPane, + }; +}; diff --git a/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts b/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts new file mode 100644 index 00000000..0c65a4de --- /dev/null +++ b/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts @@ -0,0 +1,16 @@ +import type { TSupportedOperators } from "@plane/types"; +import { CORE_OPERATORS } from "@plane/types"; + +export type TFiltersOperatorConfigs = { + allowedOperators: Set; + allowNegative: boolean; +}; + +export type TUseFiltersOperatorConfigsProps = { + workspaceSlug: string; +}; + +export const useFiltersOperatorConfigs = (_props: TUseFiltersOperatorConfigsProps): TFiltersOperatorConfigs => ({ + allowedOperators: new Set(Object.values(CORE_OPERATORS)), + allowNegative: false, +}); diff --git a/apps/web/ce/hooks/store/index.ts b/apps/web/ce/hooks/store/index.ts new file mode 100644 index 00000000..1962c9b2 --- /dev/null +++ b/apps/web/ce/hooks/store/index.ts @@ -0,0 +1,2 @@ +export * from "./use-page-store"; +export * from "./use-page"; diff --git a/apps/web/ce/hooks/store/use-page-store.ts b/apps/web/ce/hooks/store/use-page-store.ts new file mode 100644 index 00000000..025e0383 --- /dev/null +++ b/apps/web/ce/hooks/store/use-page-store.ts @@ -0,0 +1,24 @@ +import { useContext } from "react"; +// context +import { StoreContext } from "@/lib/store-context"; +// mobx store +import type { IProjectPageStore } from "@/store/pages/project-page.store"; + +export enum EPageStoreType { + PROJECT = "PROJECT_PAGE", +} + +export type TReturnType = { + [EPageStoreType.PROJECT]: IProjectPageStore; +}; + +export const usePageStore = (storeType: T): TReturnType[T] => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("usePageStore must be used within StoreProvider"); + + if (storeType === EPageStoreType.PROJECT) { + return context.projectPages; + } + + throw new Error(`Invalid store type: ${storeType}`); +}; diff --git a/apps/web/ce/hooks/store/use-page.ts b/apps/web/ce/hooks/store/use-page.ts new file mode 100644 index 00000000..d4c531fe --- /dev/null +++ b/apps/web/ce/hooks/store/use-page.ts @@ -0,0 +1,24 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "@/lib/store-context"; +// plane web hooks +import type { EPageStoreType } from "@/plane-web/hooks/store"; +import { usePageStore } from "@/plane-web/hooks/store"; + +export type TArgs = { + pageId: string; + storeType: EPageStoreType; +}; + +export const usePage = (args: TArgs) => { + const { pageId, storeType } = args; + // context + const context = useContext(StoreContext); + // store hooks + const pageStore = usePageStore(storeType); + + if (context === undefined) throw new Error("usePage must be used within StoreProvider"); + if (!pageId) throw new Error("pageId is required"); + + return pageStore.getPageById(pageId); +}; diff --git a/apps/web/ce/hooks/use-additional-editor-mention.tsx b/apps/web/ce/hooks/use-additional-editor-mention.tsx new file mode 100644 index 00000000..604649e7 --- /dev/null +++ b/apps/web/ce/hooks/use-additional-editor-mention.tsx @@ -0,0 +1,41 @@ +import { useCallback } from "react"; +// plane editor +import type { TMentionSection } from "@plane/editor"; +// plane types +import type { TSearchEntities, TSearchResponse } from "@plane/types"; + +export type TAdditionalEditorMentionHandlerArgs = { + response: TSearchResponse; + sections: TMentionSection[]; +}; + +export type TAdditionalParseEditorContentArgs = { + id: string; + entityType: TSearchEntities; +}; + +export type TAdditionalParseEditorContentReturnType = + | { + redirectionPath: string; + textContent: string; + } + | undefined; + +export const useAdditionalEditorMention = () => { + const updateAdditionalSections = useCallback((args: TAdditionalEditorMentionHandlerArgs) => { + const {} = args; + }, []); + + const parseAdditionalEditorContent = useCallback( + (args: TAdditionalParseEditorContentArgs): TAdditionalParseEditorContentReturnType => { + const {} = args; + return undefined; + }, + [] + ); + + return { + updateAdditionalSections, + parseAdditionalEditorContent, + }; +}; diff --git a/apps/web/ce/hooks/use-additional-favorite-item-details.ts b/apps/web/ce/hooks/use-additional-favorite-item-details.ts new file mode 100644 index 00000000..7d1a6d36 --- /dev/null +++ b/apps/web/ce/hooks/use-additional-favorite-item-details.ts @@ -0,0 +1,26 @@ +// plane imports +import type { IFavorite } from "@plane/types"; +// components +import { getFavoriteItemIcon } from "@/components/workspace/sidebar/favorites/favorite-items/common"; + +export const useAdditionalFavoriteItemDetails = () => { + const getAdditionalFavoriteItemDetails = (_workspaceSlug: string, favorite: IFavorite) => { + const { entity_type: favoriteItemEntityType } = favorite; + const favoriteItemName = favorite?.entity_data?.name || favorite?.name; + + let itemIcon; + let itemTitle; + + switch (favoriteItemEntityType) { + default: + itemTitle = favoriteItemName; + itemIcon = getFavoriteItemIcon(favoriteItemEntityType); + break; + } + return { itemIcon, itemTitle }; + }; + + return { + getAdditionalFavoriteItemDetails, + }; +}; diff --git a/apps/web/ce/hooks/use-bulk-operation-status.ts b/apps/web/ce/hooks/use-bulk-operation-status.ts new file mode 100644 index 00000000..0bb67681 --- /dev/null +++ b/apps/web/ce/hooks/use-bulk-operation-status.ts @@ -0,0 +1 @@ +export const useBulkOperationStatus = () => false; diff --git a/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx b/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx new file mode 100644 index 00000000..b8c32d1b --- /dev/null +++ b/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx @@ -0,0 +1,12 @@ +import type { TDeDupeIssue } from "@plane/types"; + +export const useDebouncedDuplicateIssues = ( + workspaceSlug: string | undefined, + workspaceId: string | undefined, + projectId: string | undefined, + formData: { name: string | undefined; description_html?: string | undefined; issueId?: string | undefined } +) => { + const duplicateIssues: TDeDupeIssue[] = []; + + return { duplicateIssues }; +}; diff --git a/apps/web/ce/hooks/use-editor-flagging.ts b/apps/web/ce/hooks/use-editor-flagging.ts new file mode 100644 index 00000000..731c6715 --- /dev/null +++ b/apps/web/ce/hooks/use-editor-flagging.ts @@ -0,0 +1,41 @@ +// editor +import type { TExtensions } from "@plane/editor"; +import type { EPageStoreType } from "@/plane-web/hooks/store"; + +export type TEditorFlaggingHookReturnType = { + document: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + liteText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + richText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; +}; + +export type TEditorFlaggingHookProps = { + workspaceSlug: string; + storeType?: EPageStoreType; +}; + +/** + * @description extensions disabled in various editors + */ +export const useEditorFlagging = (_props: TEditorFlaggingHookProps): TEditorFlaggingHookReturnType => ({ + document: { + disabled: ["ai", "collaboration-cursor"], + flagged: [], + }, + liteText: { + disabled: ["ai", "collaboration-cursor"], + flagged: [], + }, + richText: { + disabled: ["ai", "collaboration-cursor"], + flagged: [], + }, +}); diff --git a/apps/web/ce/hooks/use-file-size.ts b/apps/web/ce/hooks/use-file-size.ts new file mode 100644 index 00000000..c72e96da --- /dev/null +++ b/apps/web/ce/hooks/use-file-size.ts @@ -0,0 +1,17 @@ +// plane imports +import { MAX_FILE_SIZE } from "@plane/constants"; +// hooks +import { useInstance } from "@/hooks/store/use-instance"; + +type TReturnProps = { + maxFileSize: number; +}; + +export const useFileSize = (): TReturnProps => { + // store hooks + const { config } = useInstance(); + + return { + maxFileSize: config?.file_size_limit ?? MAX_FILE_SIZE, + }; +}; diff --git a/apps/web/ce/hooks/use-issue-embed.tsx b/apps/web/ce/hooks/use-issue-embed.tsx new file mode 100644 index 00000000..67bc79b7 --- /dev/null +++ b/apps/web/ce/hooks/use-issue-embed.tsx @@ -0,0 +1,25 @@ +// editor +import type { TEmbedConfig } from "@plane/editor"; +// plane types +import type { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; +// plane web components +import { IssueEmbedUpgradeCard } from "@/plane-web/components/pages"; + +export type TIssueEmbedHookProps = { + fetchEmbedSuggestions?: (payload: TSearchEntityRequestPayload) => Promise; + projectId?: string; + workspaceSlug?: string; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useIssueEmbed = (props: TIssueEmbedHookProps) => { + const widgetCallback = () => ; + + const issueEmbedProps: TEmbedConfig["issue"] = { + widgetCallback, + }; + + return { + issueEmbedProps, + }; +}; diff --git a/apps/web/ce/hooks/use-issue-properties.tsx b/apps/web/ce/hooks/use-issue-properties.tsx new file mode 100644 index 00000000..12a020c8 --- /dev/null +++ b/apps/web/ce/hooks/use-issue-properties.tsx @@ -0,0 +1,10 @@ +import type { TIssueServiceType } from "@plane/types"; + +export const useWorkItemProperties = ( + projectId: string | null | undefined, + workspaceSlug: string | null | undefined, + workItemId: string | null | undefined, + issueServiceType: TIssueServiceType +) => { + if (!projectId || !workspaceSlug || !workItemId) return; +}; diff --git a/apps/web/ce/hooks/use-notification-preview.tsx b/apps/web/ce/hooks/use-notification-preview.tsx new file mode 100644 index 00000000..6e21868a --- /dev/null +++ b/apps/web/ce/hooks/use-notification-preview.tsx @@ -0,0 +1,25 @@ +import type { IWorkItemPeekOverview } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; +import { IssuePeekOverview } from "@/components/issues/peek-overview"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import type { TPeekIssue } from "@/store/issue/issue-details/root.store"; + +export type TNotificationPreview = { + isWorkItem: boolean; + PeekOverviewComponent: React.ComponentType; + setPeekWorkItem: (peekIssue: TPeekIssue | undefined) => void; +}; + +/** + * This function returns if the current active notification is related to work item or an epic. + * @returns isWorkItem: boolean, peekOverviewComponent: IWorkItemPeekOverview, setPeekWorkItem + */ +export const useNotificationPreview = (): TNotificationPreview => { + const { peekIssue, setPeekIssue } = useIssueDetail(EIssueServiceType.ISSUES); + + return { + isWorkItem: Boolean(peekIssue), + PeekOverviewComponent: IssuePeekOverview, + setPeekWorkItem: setPeekIssue, + }; +}; diff --git a/apps/web/ce/hooks/use-page-flag.ts b/apps/web/ce/hooks/use-page-flag.ts new file mode 100644 index 00000000..94d72065 --- /dev/null +++ b/apps/web/ce/hooks/use-page-flag.ts @@ -0,0 +1,16 @@ +export type TPageFlagHookArgs = { + workspaceSlug: string; +}; + +export type TPageFlagHookReturnType = { + isMovePageEnabled: boolean; + isPageSharingEnabled: boolean; +}; + +export const usePageFlag = (args: TPageFlagHookArgs): TPageFlagHookReturnType => { + const {} = args; + return { + isMovePageEnabled: false, + isPageSharingEnabled: false, + }; +}; diff --git a/apps/web/ce/hooks/use-workspace-issue-properties-extended.tsx b/apps/web/ce/hooks/use-workspace-issue-properties-extended.tsx new file mode 100644 index 00000000..6e9ba79f --- /dev/null +++ b/apps/web/ce/hooks/use-workspace-issue-properties-extended.tsx @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useWorkspaceIssuePropertiesExtended = (workspaceSlug: string | string[] | undefined) => {}; diff --git a/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx b/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx new file mode 100644 index 00000000..edc6f54f --- /dev/null +++ b/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx @@ -0,0 +1,401 @@ +import { useCallback, useMemo } from "react"; +import { + AtSign, + Briefcase, + Calendar, + CalendarCheck2, + CalendarClock, + CircleUserRound, + SignalHigh, + Tag, + Users, +} from "lucide-react"; +// plane imports +import { + CycleGroupIcon, + CycleIcon, + ModuleIcon, + DoubleCircleIcon, + PriorityIcon, + StateGroupIcon, +} from "@plane/propel/icons"; +import type { + ICycle, + IState, + IUserLite, + TFilterConfig, + TFilterValue, + IIssueLabel, + IModule, + IProject, + TWorkItemFilterProperty, +} from "@plane/types"; +import { Avatar, Logo } from "@plane/ui"; +import { + getAssigneeFilterConfig, + getCreatedAtFilterConfig, + getCreatedByFilterConfig, + getCycleFilterConfig, + getFileURL, + getLabelFilterConfig, + getMentionFilterConfig, + getModuleFilterConfig, + getPriorityFilterConfig, + getProjectFilterConfig, + getStartDateFilterConfig, + getStateFilterConfig, + getStateGroupFilterConfig, + getSubscriberFilterConfig, + getTargetDateFilterConfig, + getUpdatedAtFilterConfig, + isLoaderReady, +} from "@plane/utils"; +// store hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useLabel } from "@/hooks/store/use-label"; +import { useMember } from "@/hooks/store/use-member"; +import { useModule } from "@/hooks/store/use-module"; +import { useProject } from "@/hooks/store/use-project"; +import { useProjectState } from "@/hooks/store/use-project-state"; +// plane web imports +import { useFiltersOperatorConfigs } from "@/plane-web/hooks/rich-filters/use-filters-operator-configs"; + +export type TWorkItemFiltersEntityProps = { + workspaceSlug: string; + cycleIds?: string[]; + labelIds?: string[]; + memberIds?: string[]; + moduleIds?: string[]; + projectId?: string; + projectIds?: string[]; + stateIds?: string[]; +}; + +export type TUseWorkItemFiltersConfigProps = { + allowedFilters: TWorkItemFilterProperty[]; +} & TWorkItemFiltersEntityProps; + +export type TWorkItemFiltersConfig = { + areAllConfigsInitialized: boolean; + configs: TFilterConfig[]; + configMap: { + [key in TWorkItemFilterProperty]?: TFilterConfig; + }; + isFilterEnabled: (key: TWorkItemFilterProperty) => boolean; + members: IUserLite[]; +}; + +export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): TWorkItemFiltersConfig => { + const { allowedFilters, cycleIds, labelIds, memberIds, moduleIds, projectId, projectIds, stateIds, workspaceSlug } = + props; + // store hooks + const { loader: projectLoader, getProjectById } = useProject(); + const { getCycleById } = useCycle(); + const { getLabelById } = useLabel(); + const { getModuleById } = useModule(); + const { getStateById } = useProjectState(); + const { getUserDetails } = useMember(); + // derived values + const operatorConfigs = useFiltersOperatorConfigs({ workspaceSlug }); + const filtersToShow = useMemo(() => new Set(allowedFilters), [allowedFilters]); + const project = useMemo(() => getProjectById(projectId), [projectId, getProjectById]); + const members: IUserLite[] | undefined = useMemo( + () => + memberIds + ? (memberIds.map((memberId) => getUserDetails(memberId)).filter((member) => member) as IUserLite[]) + : undefined, + [memberIds, getUserDetails] + ); + const workItemStates: IState[] | undefined = useMemo( + () => + stateIds ? (stateIds.map((stateId) => getStateById(stateId)).filter((state) => state) as IState[]) : undefined, + [stateIds, getStateById] + ); + const workItemLabels: IIssueLabel[] | undefined = useMemo( + () => + labelIds + ? (labelIds.map((labelId) => getLabelById(labelId)).filter((label) => label) as IIssueLabel[]) + : undefined, + [labelIds, getLabelById] + ); + const cycles = useMemo( + () => (cycleIds ? (cycleIds.map((cycleId) => getCycleById(cycleId)).filter((cycle) => cycle) as ICycle[]) : []), + [cycleIds, getCycleById] + ); + const modules = useMemo( + () => + moduleIds ? (moduleIds.map((moduleId) => getModuleById(moduleId)).filter((module) => module) as IModule[]) : [], + [moduleIds, getModuleById] + ); + const projects = useMemo( + () => + projectIds + ? (projectIds.map((projectId) => getProjectById(projectId)).filter((project) => project) as IProject[]) + : [], + [projectIds, getProjectById] + ); + const areAllConfigsInitialized = useMemo(() => isLoaderReady(projectLoader), [projectLoader]); + + /** + * Checks if a filter is enabled based on the filters to show. + * @param key - The filter key. + * @param level - The level of the filter. + * @returns True if the filter is enabled, false otherwise. + */ + const isFilterEnabled = useCallback((key: TWorkItemFilterProperty) => filtersToShow.has(key), [filtersToShow]); + + // state group filter config + const stateGroupFilterConfig = useMemo( + () => + getStateGroupFilterConfig("state_group")({ + isEnabled: isFilterEnabled("state_group"), + filterIcon: DoubleCircleIcon, + getOptionIcon: (stateGroupKey) => , + ...operatorConfigs, + }), + [isFilterEnabled, operatorConfigs] + ); + + // state filter config + const stateFilterConfig = useMemo( + () => + getStateFilterConfig("state_id")({ + isEnabled: isFilterEnabled("state_id") && workItemStates !== undefined, + filterIcon: DoubleCircleIcon, + getOptionIcon: (state) => , + states: workItemStates ?? [], + ...operatorConfigs, + }), + [isFilterEnabled, workItemStates, operatorConfigs] + ); + + // label filter config + const labelFilterConfig = useMemo( + () => + getLabelFilterConfig("label_id")({ + isEnabled: isFilterEnabled("label_id") && workItemLabels !== undefined, + filterIcon: Tag, + labels: workItemLabels ?? [], + getOptionIcon: (color) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, workItemLabels, operatorConfigs] + ); + + // cycle filter config + const cycleFilterConfig = useMemo( + () => + getCycleFilterConfig("cycle_id")({ + isEnabled: isFilterEnabled("cycle_id") && project?.cycle_view === true && cycles !== undefined, + filterIcon: CycleIcon, + getOptionIcon: (cycleGroup) => , + cycles: cycles ?? [], + ...operatorConfigs, + }), + [isFilterEnabled, project?.cycle_view, cycles, operatorConfigs] + ); + + // module filter config + const moduleFilterConfig = useMemo( + () => + getModuleFilterConfig("module_id")({ + isEnabled: isFilterEnabled("module_id") && project?.module_view === true && modules !== undefined, + filterIcon: ModuleIcon, + getOptionIcon: () => , + modules: modules ?? [], + ...operatorConfigs, + }), + [isFilterEnabled, project?.module_view, modules, operatorConfigs] + ); + + // assignee filter config + const assigneeFilterConfig = useMemo( + () => + getAssigneeFilterConfig("assignee_id")({ + isEnabled: isFilterEnabled("assignee_id") && members !== undefined, + filterIcon: Users, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // mention filter config + const mentionFilterConfig = useMemo( + () => + getMentionFilterConfig("mention_id")({ + isEnabled: isFilterEnabled("mention_id") && members !== undefined, + filterIcon: AtSign, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // created by filter config + const createdByFilterConfig = useMemo( + () => + getCreatedByFilterConfig("created_by_id")({ + isEnabled: isFilterEnabled("created_by_id") && members !== undefined, + filterIcon: CircleUserRound, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // subscriber filter config + const subscriberFilterConfig = useMemo( + () => + getSubscriberFilterConfig("subscriber_id")({ + isEnabled: isFilterEnabled("subscriber_id") && members !== undefined, + filterIcon: Users, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // priority filter config + const priorityFilterConfig = useMemo( + () => + getPriorityFilterConfig("priority")({ + isEnabled: isFilterEnabled("priority"), + filterIcon: SignalHigh, + getOptionIcon: (priority) => , + ...operatorConfigs, + }), + [isFilterEnabled, operatorConfigs] + ); + + // start date filter config + const startDateFilterConfig = useMemo( + () => + getStartDateFilterConfig("start_date")({ + isEnabled: true, + filterIcon: CalendarClock, + ...operatorConfigs, + }), + [operatorConfigs] + ); + + // target date filter config + const targetDateFilterConfig = useMemo( + () => + getTargetDateFilterConfig("target_date")({ + isEnabled: true, + filterIcon: CalendarCheck2, + ...operatorConfigs, + }), + [operatorConfigs] + ); + + // created at filter config + const createdAtFilterConfig = useMemo( + () => + getCreatedAtFilterConfig("created_at")({ + isEnabled: true, + filterIcon: Calendar, + ...operatorConfigs, + }), + [operatorConfigs] + ); + + // updated at filter config + const updatedAtFilterConfig = useMemo( + () => + getUpdatedAtFilterConfig("updated_at")({ + isEnabled: true, + filterIcon: Calendar, + ...operatorConfigs, + }), + [operatorConfigs] + ); + + // project filter config + const projectFilterConfig = useMemo( + () => + getProjectFilterConfig("project_id")({ + isEnabled: isFilterEnabled("project_id") && projects !== undefined, + filterIcon: Briefcase, + projects: projects, + getOptionIcon: (project) => , + ...operatorConfigs, + }), + [isFilterEnabled, projects, operatorConfigs] + ); + + return { + areAllConfigsInitialized, + configs: [ + stateFilterConfig, + stateGroupFilterConfig, + assigneeFilterConfig, + priorityFilterConfig, + projectFilterConfig, + mentionFilterConfig, + labelFilterConfig, + cycleFilterConfig, + moduleFilterConfig, + startDateFilterConfig, + targetDateFilterConfig, + createdAtFilterConfig, + updatedAtFilterConfig, + createdByFilterConfig, + subscriberFilterConfig, + ], + configMap: { + project_id: projectFilterConfig, + state_group: stateGroupFilterConfig, + state_id: stateFilterConfig, + label_id: labelFilterConfig, + cycle_id: cycleFilterConfig, + module_id: moduleFilterConfig, + assignee_id: assigneeFilterConfig, + mention_id: mentionFilterConfig, + created_by_id: createdByFilterConfig, + subscriber_id: subscriberFilterConfig, + priority: priorityFilterConfig, + start_date: startDateFilterConfig, + target_date: targetDateFilterConfig, + created_at: createdAtFilterConfig, + updated_at: updatedAtFilterConfig, + }, + isFilterEnabled, + members: members ?? [], + }; +}; diff --git a/apps/web/ce/layouts/project-wrapper.tsx b/apps/web/ce/layouts/project-wrapper.tsx new file mode 100644 index 00000000..71dba504 --- /dev/null +++ b/apps/web/ce/layouts/project-wrapper.tsx @@ -0,0 +1,21 @@ +import type { FC } from "react"; +import { observer } from "mobx-react"; +// layouts +import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper"; + +export type IProjectAuthWrapper = { + workspaceSlug: string; + projectId: string; + children: React.ReactNode; +}; + +export const ProjectAuthWrapper: FC = observer((props) => { + // props + const { workspaceSlug, projectId, children } = props; + + return ( + + {children} + + ); +}); diff --git a/apps/web/ce/layouts/workspace-wrapper.tsx b/apps/web/ce/layouts/workspace-wrapper.tsx new file mode 100644 index 00000000..47a272d6 --- /dev/null +++ b/apps/web/ce/layouts/workspace-wrapper.tsx @@ -0,0 +1,15 @@ +import type { FC } from "react"; +import { observer } from "mobx-react"; +// layouts +import { WorkspaceAuthWrapper as CoreWorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper"; + +export type IWorkspaceAuthWrapper = { + children: React.ReactNode; +}; + +export const WorkspaceAuthWrapper: FC = observer((props) => { + // props + const { children } = props; + + return {children}; +}); diff --git a/apps/web/ce/services/index.ts b/apps/web/ce/services/index.ts new file mode 100644 index 00000000..7e406b1b --- /dev/null +++ b/apps/web/ce/services/index.ts @@ -0,0 +1,2 @@ +export * from "./project"; +export * from "@/services/workspace.service"; diff --git a/apps/web/ce/services/project/estimate.service.ts b/apps/web/ce/services/project/estimate.service.ts new file mode 100644 index 00000000..95b9a39a --- /dev/null +++ b/apps/web/ce/services/project/estimate.service.ts @@ -0,0 +1,106 @@ +/* eslint-disable no-useless-catch */ + +// types +import { API_BASE_URL } from "@plane/constants"; +import type { IEstimate, IEstimateFormData, IEstimatePoint } from "@plane/types"; +// helpers +// services +import { APIService } from "@/services/api.service"; + +export class EstimateService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async fetchWorkspaceEstimates(workspaceSlug: string): Promise { + try { + const { data } = await this.get(`/api/workspaces/${workspaceSlug}/estimates/`); + return data || undefined; + } catch (error) { + throw error; + } + } + + async fetchProjectEstimates(workspaceSlug: string, projectId: string): Promise { + try { + const { data } = await this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/`); + return data || undefined; + } catch (error) { + throw error; + } + } + + async fetchEstimateById( + workspaceSlug: string, + projectId: string, + estimateId: string + ): Promise { + try { + const { data } = await this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/` + ); + return data || undefined; + } catch (error) { + throw error; + } + } + + async createEstimate( + workspaceSlug: string, + projectId: string, + payload: IEstimateFormData + ): Promise { + try { + const { data } = await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/`, payload); + return data || undefined; + } catch (error) { + throw error; + } + } + + async deleteEstimate(workspaceSlug: string, projectId: string, estimateId: string): Promise { + try { + await this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`); + } catch (error) { + throw error; + } + } + + async createEstimatePoint( + workspaceSlug: string, + projectId: string, + estimateId: string, + payload: Partial + ): Promise { + try { + const { data } = await this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/estimate-points/`, + payload + ); + return data || undefined; + } catch (error) { + throw error; + } + } + + async updateEstimatePoint( + workspaceSlug: string, + projectId: string, + estimateId: string, + estimatePointId: string, + payload: Partial + ): Promise { + try { + const { data } = await this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/estimate-points/${estimatePointId}/`, + payload + ); + return data || undefined; + } catch (error) { + throw error; + } + } +} +const estimateService = new EstimateService(); + +export default estimateService; diff --git a/apps/web/ce/services/project/index.ts b/apps/web/ce/services/project/index.ts new file mode 100644 index 00000000..8b75f6bf --- /dev/null +++ b/apps/web/ce/services/project/index.ts @@ -0,0 +1,2 @@ +export * from "./estimate.service"; +export * from "@/services/view.service"; diff --git a/apps/web/ce/services/project/project-state.service.ts b/apps/web/ce/services/project/project-state.service.ts new file mode 100644 index 00000000..f4a48ae7 --- /dev/null +++ b/apps/web/ce/services/project/project-state.service.ts @@ -0,0 +1 @@ +export * from "@/services/project/project-state.service"; diff --git a/apps/web/ce/store/analytics.store.ts b/apps/web/ce/store/analytics.store.ts new file mode 100644 index 00000000..9556dcf3 --- /dev/null +++ b/apps/web/ce/store/analytics.store.ts @@ -0,0 +1,9 @@ +import type { IBaseAnalyticsStore } from "@/store/analytics.store"; +import { BaseAnalyticsStore } from "@/store/analytics.store"; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface IAnalyticsStore extends IBaseAnalyticsStore { + //observables +} + +export class AnalyticsStore extends BaseAnalyticsStore {} diff --git a/apps/web/ce/store/command-palette.store.ts b/apps/web/ce/store/command-palette.store.ts new file mode 100644 index 00000000..6a3f8aba --- /dev/null +++ b/apps/web/ce/store/command-palette.store.ts @@ -0,0 +1,27 @@ +import { computed, makeObservable } from "mobx"; +// types / constants +import type { IBaseCommandPaletteStore } from "@/store/base-command-palette.store"; +import { BaseCommandPaletteStore } from "@/store/base-command-palette.store"; + +export interface ICommandPaletteStore extends IBaseCommandPaletteStore { + // computed + isAnyModalOpen: boolean; +} + +export class CommandPaletteStore extends BaseCommandPaletteStore implements ICommandPaletteStore { + constructor() { + super(); + makeObservable(this, { + // computed + isAnyModalOpen: computed, + }); + } + + /** + * Checks whether any modal is open or not in the base command palette. + * @returns boolean + */ + get isAnyModalOpen(): boolean { + return Boolean(super.getCoreModalsState()); + } +} diff --git a/apps/web/ce/store/cycle/index.ts b/apps/web/ce/store/cycle/index.ts new file mode 100644 index 00000000..d9b3080a --- /dev/null +++ b/apps/web/ce/store/cycle/index.ts @@ -0,0 +1 @@ +export type { ICycleStore } from "@/store/cycle.store"; diff --git a/apps/web/ce/store/estimates/estimate.ts b/apps/web/ce/store/estimates/estimate.ts new file mode 100644 index 00000000..8a32799b --- /dev/null +++ b/apps/web/ce/store/estimates/estimate.ts @@ -0,0 +1,155 @@ +import { orderBy, set } from "lodash-es"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// types +import type { + IEstimate as IEstimateType, + IEstimatePoint as IEstimatePointType, + TEstimateSystemKeys, +} from "@plane/types"; +// plane web services +import estimateService from "@/plane-web/services/project/estimate.service"; +// store +import type { IEstimatePoint } from "@/store/estimates/estimate-point"; +import { EstimatePoint } from "@/store/estimates/estimate-point"; +import type { CoreRootStore } from "@/store/root.store"; + +type TErrorCodes = { + status: string; + message?: string; +}; + +export interface IEstimate extends Omit { + // observables + error: TErrorCodes | undefined; + estimatePoints: Record; + // computed + asJson: Omit; + estimatePointIds: string[] | undefined; + estimatePointById: (estimatePointId: string) => IEstimatePointType | undefined; + // actions + creteEstimatePoint: ( + workspaceSlug: string, + projectId: string, + payload: Partial + ) => Promise; +} + +export class Estimate implements IEstimate { + // data model observables + id: string | undefined = undefined; + name: string | undefined = undefined; + description: string | undefined = undefined; + type: TEstimateSystemKeys | undefined = undefined; + workspace: string | undefined = undefined; + project: string | undefined = undefined; + last_used: boolean | undefined = undefined; + created_at: Date | undefined = undefined; + updated_at: Date | undefined = undefined; + created_by: string | undefined = undefined; + updated_by: string | undefined = undefined; + // observables + error: TErrorCodes | undefined = undefined; + estimatePoints: Record = {}; + + constructor( + public store: CoreRootStore, + public data: IEstimateType + ) { + makeObservable(this, { + // data model observables + id: observable.ref, + name: observable.ref, + description: observable.ref, + type: observable.ref, + workspace: observable.ref, + project: observable.ref, + last_used: observable.ref, + created_at: observable.ref, + updated_at: observable.ref, + created_by: observable.ref, + updated_by: observable.ref, + // observables + error: observable.ref, + estimatePoints: observable, + // computed + asJson: computed, + estimatePointIds: computed, + // actions + creteEstimatePoint: action, + }); + this.id = this.data.id; + this.name = this.data.name; + this.description = this.data.description; + this.type = this.data.type; + this.workspace = this.data.workspace; + this.project = this.data.project; + this.last_used = this.data.last_used; + this.created_at = this.data.created_at; + this.updated_at = this.data.updated_at; + this.created_by = this.data.created_by; + this.updated_by = this.data.updated_by; + this.data.points?.forEach((estimationPoint) => { + if (estimationPoint.id) + set(this.estimatePoints, [estimationPoint.id], new EstimatePoint(this.store, this.data, estimationPoint)); + }); + } + + // computed + get asJson() { + return { + id: this.id, + name: this.name, + description: this.description, + type: this.type, + workspace: this.workspace, + project: this.project, + last_used: this.last_used, + created_at: this.created_at, + updated_at: this.updated_at, + created_by: this.created_by, + updated_by: this.updated_by, + }; + } + + get estimatePointIds() { + const { estimatePoints } = this; + if (!estimatePoints) return undefined; + let currentEstimatePoints = Object.values(estimatePoints).filter( + (estimatePoint) => estimatePoint?.estimate === this.id + ); + currentEstimatePoints = orderBy(currentEstimatePoints, ["key"], "asc"); + const estimatePointIds = currentEstimatePoints.map((estimatePoint) => estimatePoint.id) as string[]; + return estimatePointIds ?? undefined; + } + + estimatePointById = computedFn((estimatePointId: string) => { + if (!estimatePointId) return undefined; + return this.estimatePoints[estimatePointId] ?? undefined; + }); + + // actions + /** + * @description create an estimate point + * @param { string } workspaceSlug + * @param { string } projectId + * @param { Partial } payload + * @returns { IEstimatePointType | undefined } + */ + creteEstimatePoint = async ( + workspaceSlug: string, + projectId: string, + payload: Partial + ): Promise => { + if (!this.id || !payload) return; + + const estimatePoint = await estimateService.createEstimatePoint(workspaceSlug, projectId, this.id, payload); + if (estimatePoint) { + runInAction(() => { + if (estimatePoint.id) { + set(this.estimatePoints, [estimatePoint.id], new EstimatePoint(this.store, this.data, estimatePoint)); + } + }); + } + }; +} diff --git a/apps/web/ce/store/global-view.store.ts b/apps/web/ce/store/global-view.store.ts new file mode 100644 index 00000000..f0d5cdfb --- /dev/null +++ b/apps/web/ce/store/global-view.store.ts @@ -0,0 +1 @@ +export * from "@/store/global-view.store"; diff --git a/apps/web/ce/store/issue/epic/filter.store.ts b/apps/web/ce/store/issue/epic/filter.store.ts new file mode 100644 index 00000000..999e1515 --- /dev/null +++ b/apps/web/ce/store/issue/epic/filter.store.ts @@ -0,0 +1,16 @@ +import type { IProjectIssuesFilter } from "@/store/issue/project"; +import { ProjectIssuesFilter } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type IProjectEpicsFilter = IProjectIssuesFilter; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class ProjectEpicsFilter extends ProjectIssuesFilter implements IProjectEpicsFilter { + constructor(_rootStore: IIssueRootStore) { + super(_rootStore); + + // root store + this.rootIssueStore = _rootStore; + } +} diff --git a/apps/web/ce/store/issue/epic/index.ts b/apps/web/ce/store/issue/epic/index.ts new file mode 100644 index 00000000..0fe6c946 --- /dev/null +++ b/apps/web/ce/store/issue/epic/index.ts @@ -0,0 +1,2 @@ +export * from "./filter.store"; +export * from "./issue.store"; diff --git a/apps/web/ce/store/issue/epic/issue.store.ts b/apps/web/ce/store/issue/epic/issue.store.ts new file mode 100644 index 00000000..702a4c05 --- /dev/null +++ b/apps/web/ce/store/issue/epic/issue.store.ts @@ -0,0 +1,15 @@ +import type { IProjectIssues } from "@/store/issue/project"; +import { ProjectIssues } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { IProjectEpicsFilter } from "./filter.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors + +export type IProjectEpics = IProjectIssues; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class ProjectEpics extends ProjectIssues implements IProjectEpics { + constructor(_rootStore: IIssueRootStore, issueFilterStore: IProjectEpicsFilter) { + super(_rootStore, issueFilterStore); + } +} diff --git a/apps/web/ce/store/issue/helpers/base-issue-store.ts b/apps/web/ce/store/issue/helpers/base-issue-store.ts new file mode 100644 index 00000000..eac5cec1 --- /dev/null +++ b/apps/web/ce/store/issue/helpers/base-issue-store.ts @@ -0,0 +1,4 @@ +import type { TIssue } from "@plane/types"; +import { getIssueIds } from "@/store/issue/helpers/base-issues-utils"; + +export const workItemSortWithOrderByExtended = (array: TIssue[], key?: string) => getIssueIds(array); diff --git a/apps/web/ce/store/issue/helpers/base-issue.store.ts b/apps/web/ce/store/issue/helpers/base-issue.store.ts new file mode 100644 index 00000000..eac5cec1 --- /dev/null +++ b/apps/web/ce/store/issue/helpers/base-issue.store.ts @@ -0,0 +1,4 @@ +import type { TIssue } from "@plane/types"; +import { getIssueIds } from "@/store/issue/helpers/base-issues-utils"; + +export const workItemSortWithOrderByExtended = (array: TIssue[], key?: string) => getIssueIds(array); diff --git a/apps/web/ce/store/issue/issue-details/activity.store.ts b/apps/web/ce/store/issue/issue-details/activity.store.ts new file mode 100644 index 00000000..ab86bc1c --- /dev/null +++ b/apps/web/ce/store/issue/issue-details/activity.store.ts @@ -0,0 +1,173 @@ +import { concat, orderBy, set, uniq, update } from "lodash-es"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// plane package imports +import type { E_SORT_ORDER } from "@plane/constants"; +import { EActivityFilterType } from "@plane/constants"; +import type { + TIssueActivityComment, + TIssueActivity, + TIssueActivityMap, + TIssueActivityIdMap, + TIssueServiceType, +} from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; +// plane web constants +// services +import { IssueActivityService } from "@/services/issue"; +// store +import type { CoreRootStore } from "@/store/root.store"; + +export type TActivityLoader = "fetch" | "mutate" | undefined; + +export interface IIssueActivityStoreActions { + // actions + fetchActivities: ( + workspaceSlug: string, + projectId: string, + issueId: string, + loaderType?: TActivityLoader + ) => Promise; +} + +export interface IIssueActivityStore extends IIssueActivityStoreActions { + // observables + loader: TActivityLoader; + activities: TIssueActivityIdMap; + activityMap: TIssueActivityMap; + // helper methods + getActivitiesByIssueId: (issueId: string) => string[] | undefined; + getActivityById: (activityId: string) => TIssueActivity | undefined; + getActivityAndCommentsByIssueId: (issueId: string, sortOrder: E_SORT_ORDER) => TIssueActivityComment[] | undefined; +} + +export class IssueActivityStore implements IIssueActivityStore { + // observables + loader: TActivityLoader = "fetch"; + activities: TIssueActivityIdMap = {}; + activityMap: TIssueActivityMap = {}; + // services + serviceType; + issueActivityService; + + constructor( + protected store: CoreRootStore, + serviceType: TIssueServiceType = EIssueServiceType.ISSUES + ) { + makeObservable(this, { + // observables + loader: observable.ref, + activities: observable, + activityMap: observable, + // actions + fetchActivities: action, + }); + this.serviceType = serviceType; + // services + this.issueActivityService = new IssueActivityService(this.serviceType); + } + + // helper methods + getActivitiesByIssueId = (issueId: string) => { + if (!issueId) return undefined; + return this.activities[issueId] ?? undefined; + }; + + getActivityById = (activityId: string) => { + if (!activityId) return undefined; + return this.activityMap[activityId] ?? undefined; + }; + + protected buildActivityAndCommentItems(issueId: string): TIssueActivityComment[] | undefined { + if (!issueId) return undefined; + + const activityComments: TIssueActivityComment[] = []; + + const currentStore = + this.serviceType === EIssueServiceType.EPICS ? this.store.issue.epicDetail : this.store.issue.issueDetail; + + const activities = this.getActivitiesByIssueId(issueId); + const comments = currentStore.comment.getCommentsByIssueId(issueId); + + if (!activities || !comments) return undefined; + + activities.forEach((activityId) => { + const activity = this.getActivityById(activityId); + if (!activity) return; + const type = + activity.field === "state" + ? EActivityFilterType.STATE + : activity.field === "assignees" + ? EActivityFilterType.ASSIGNEE + : activity.field === null + ? EActivityFilterType.DEFAULT + : EActivityFilterType.ACTIVITY; + activityComments.push({ + id: activity.id, + activity_type: type, + created_at: activity.created_at, + }); + }); + + comments.forEach((commentId) => { + const comment = currentStore.comment.getCommentById(commentId); + if (!comment) return; + activityComments.push({ + id: comment.id, + activity_type: EActivityFilterType.COMMENT, + created_at: comment.created_at, + }); + }); + + return activityComments; + } + + protected sortActivityComments(items: TIssueActivityComment[], sortOrder: E_SORT_ORDER): TIssueActivityComment[] { + return orderBy(items, (e) => new Date(e.created_at || 0), sortOrder); + } + + getActivityAndCommentsByIssueId = computedFn((issueId: string, sortOrder: E_SORT_ORDER) => { + const baseItems = this.buildActivityAndCommentItems(issueId); + if (!baseItems) return undefined; + return this.sortActivityComments(baseItems, sortOrder); + }); + + // actions + public async fetchActivities( + workspaceSlug: string, + projectId: string, + issueId: string, + loaderType: TActivityLoader = "fetch" + ) { + try { + this.loader = loaderType; + + let props = {}; + const currentActivityIds = this.getActivitiesByIssueId(issueId); + if (currentActivityIds && currentActivityIds.length > 0) { + const currentActivity = this.getActivityById(currentActivityIds[currentActivityIds.length - 1]); + if (currentActivity) props = { created_at__gt: currentActivity.created_at }; + } + + const activities = await this.issueActivityService.getIssueActivities(workspaceSlug, projectId, issueId, props); + + const activityIds = activities.map((activity) => activity.id); + + runInAction(() => { + update(this.activities, issueId, (currentActivityIds) => { + if (!currentActivityIds) return activityIds; + return uniq(concat(currentActivityIds, activityIds)); + }); + activities.forEach((activity) => { + set(this.activityMap, activity.id, activity); + }); + this.loader = undefined; + }); + + return activities; + } catch (error) { + this.loader = undefined; + throw error; + } + } +} diff --git a/apps/web/ce/store/issue/issue-details/root.store.ts b/apps/web/ce/store/issue/issue-details/root.store.ts new file mode 100644 index 00000000..2bc4f03e --- /dev/null +++ b/apps/web/ce/store/issue/issue-details/root.store.ts @@ -0,0 +1,16 @@ +import { makeObservable } from "mobx"; +import type { TIssueServiceType } from "@plane/types"; +import type { IIssueDetail as IIssueDetailCore } from "@/store/issue/issue-details/root.store"; +import { IssueDetail as IssueDetailCore } from "@/store/issue/issue-details/root.store"; +import type { IIssueRootStore } from "@/store/issue/root.store"; + +export type IIssueDetail = IIssueDetailCore; + +export class IssueDetail extends IssueDetailCore { + constructor(rootStore: IIssueRootStore, serviceType: TIssueServiceType) { + super(rootStore, serviceType); + makeObservable(this, { + // observables + }); + } +} diff --git a/apps/web/ce/store/issue/team-project/filter.store.ts b/apps/web/ce/store/issue/team-project/filter.store.ts new file mode 100644 index 00000000..8cdb7787 --- /dev/null +++ b/apps/web/ce/store/issue/team-project/filter.store.ts @@ -0,0 +1,13 @@ +import type { IProjectIssuesFilter } from "@/store/issue/project"; +import { ProjectIssuesFilter } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamProjectWorkItemsFilter = IProjectIssuesFilter; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamProjectWorkItemsFilter extends ProjectIssuesFilter implements ITeamProjectWorkItemsFilter { + constructor(_rootStore: IIssueRootStore) { + super(_rootStore); + } +} diff --git a/apps/web/ce/store/issue/team-project/index.ts b/apps/web/ce/store/issue/team-project/index.ts new file mode 100644 index 00000000..0fe6c946 --- /dev/null +++ b/apps/web/ce/store/issue/team-project/index.ts @@ -0,0 +1,2 @@ +export * from "./filter.store"; +export * from "./issue.store"; diff --git a/apps/web/ce/store/issue/team-project/issue.store.ts b/apps/web/ce/store/issue/team-project/issue.store.ts new file mode 100644 index 00000000..496d5fda --- /dev/null +++ b/apps/web/ce/store/issue/team-project/issue.store.ts @@ -0,0 +1,14 @@ +import type { IProjectIssues } from "@/store/issue/project"; +import { ProjectIssues } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { ITeamProjectWorkItemsFilter } from "./filter.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamProjectWorkItems = IProjectIssues; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamProjectWorkItems extends ProjectIssues implements ITeamProjectWorkItems { + constructor(_rootStore: IIssueRootStore, teamProjectWorkItemsFilterStore: ITeamProjectWorkItemsFilter) { + super(_rootStore, teamProjectWorkItemsFilterStore); + } +} diff --git a/apps/web/ce/store/issue/team-views/filter.store.ts b/apps/web/ce/store/issue/team-views/filter.store.ts new file mode 100644 index 00000000..a40a3eaa --- /dev/null +++ b/apps/web/ce/store/issue/team-views/filter.store.ts @@ -0,0 +1,13 @@ +import type { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { ProjectViewIssuesFilter } from "@/store/issue/project-views"; +import type { IIssueRootStore } from "@/store/issue/root.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamViewIssuesFilter = IProjectViewIssuesFilter; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamViewIssuesFilter extends ProjectViewIssuesFilter implements IProjectViewIssuesFilter { + constructor(_rootStore: IIssueRootStore) { + super(_rootStore); + } +} diff --git a/apps/web/ce/store/issue/team-views/index.ts b/apps/web/ce/store/issue/team-views/index.ts new file mode 100644 index 00000000..0fe6c946 --- /dev/null +++ b/apps/web/ce/store/issue/team-views/index.ts @@ -0,0 +1,2 @@ +export * from "./filter.store"; +export * from "./issue.store"; diff --git a/apps/web/ce/store/issue/team-views/issue.store.ts b/apps/web/ce/store/issue/team-views/issue.store.ts new file mode 100644 index 00000000..8bcfbc67 --- /dev/null +++ b/apps/web/ce/store/issue/team-views/issue.store.ts @@ -0,0 +1,14 @@ +import type { IProjectViewIssues } from "@/store/issue/project-views"; +import { ProjectViewIssues } from "@/store/issue/project-views"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { ITeamViewIssuesFilter } from "./filter.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamViewIssues = IProjectViewIssues; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamViewIssues extends ProjectViewIssues implements IProjectViewIssues { + constructor(_rootStore: IIssueRootStore, teamViewFilterStore: ITeamViewIssuesFilter) { + super(_rootStore, teamViewFilterStore); + } +} diff --git a/apps/web/ce/store/issue/team/filter.store.ts b/apps/web/ce/store/issue/team/filter.store.ts new file mode 100644 index 00000000..62e1f2eb --- /dev/null +++ b/apps/web/ce/store/issue/team/filter.store.ts @@ -0,0 +1,13 @@ +import type { IProjectIssuesFilter } from "@/store/issue/project"; +import { ProjectIssuesFilter } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamIssuesFilter = IProjectIssuesFilter; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamIssuesFilter extends ProjectIssuesFilter implements IProjectIssuesFilter { + constructor(_rootStore: IIssueRootStore) { + super(_rootStore); + } +} diff --git a/apps/web/ce/store/issue/team/index.ts b/apps/web/ce/store/issue/team/index.ts new file mode 100644 index 00000000..0fe6c946 --- /dev/null +++ b/apps/web/ce/store/issue/team/index.ts @@ -0,0 +1,2 @@ +export * from "./filter.store"; +export * from "./issue.store"; diff --git a/apps/web/ce/store/issue/team/issue.store.ts b/apps/web/ce/store/issue/team/issue.store.ts new file mode 100644 index 00000000..446332c5 --- /dev/null +++ b/apps/web/ce/store/issue/team/issue.store.ts @@ -0,0 +1,14 @@ +import type { IProjectIssues } from "@/store/issue/project"; +import { ProjectIssues } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { ITeamIssuesFilter } from "./filter.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamIssues = IProjectIssues; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamIssues extends ProjectIssues implements IProjectIssues { + constructor(_rootStore: IIssueRootStore, teamIssueFilterStore: ITeamIssuesFilter) { + super(_rootStore, teamIssueFilterStore); + } +} diff --git a/apps/web/ce/store/issue/workspace/issue.store.ts b/apps/web/ce/store/issue/workspace/issue.store.ts new file mode 100644 index 00000000..7317da96 --- /dev/null +++ b/apps/web/ce/store/issue/workspace/issue.store.ts @@ -0,0 +1 @@ +export * from "@/store/issue/workspace/issue.store"; diff --git a/apps/web/ce/store/member/project-member.store.ts b/apps/web/ce/store/member/project-member.store.ts new file mode 100644 index 00000000..f0e5b306 --- /dev/null +++ b/apps/web/ce/store/member/project-member.store.ts @@ -0,0 +1,44 @@ +import { computedFn } from "mobx-utils"; +import type { EUserProjectRoles } from "@plane/types"; +// plane imports +// plane web imports +import type { RootStore } from "@/plane-web/store/root.store"; +// store +import type { IMemberRootStore } from "@/store/member"; +import type { IBaseProjectMemberStore } from "@/store/member/project/base-project-member.store"; +import { BaseProjectMemberStore } from "@/store/member/project/base-project-member.store"; + +export type IProjectMemberStore = IBaseProjectMemberStore; + +export class ProjectMemberStore extends BaseProjectMemberStore implements IProjectMemberStore { + constructor(_memberRoot: IMemberRootStore, rootStore: RootStore) { + super(_memberRoot, rootStore); + } + + /** + * @description Returns the highest role from the project membership + * @param { string } userId + * @param { string } projectId + * @returns { EUserProjectRoles | undefined } + */ + getUserProjectRole = computedFn((userId: string, projectId: string): EUserProjectRoles | undefined => + this.getRoleFromProjectMembership(userId, projectId) + ); + + /** + * @description Returns the role from the project membership + * @param projectId + * @param userId + * @param role + */ + getProjectMemberRoleForUpdate = (_projectId: string, _userId: string, role: EUserProjectRoles): EUserProjectRoles => + role; + + /** + * @description Processes the removal of a member from a project + * This method handles the cleanup of member data from the project member map + * @param projectId - The ID of the project to remove the member from + * @param userId - The ID of the user to remove from the project + */ + processMemberRemoval = (projectId: string, userId: string) => this.handleMemberRemoval(projectId, userId); +} diff --git a/apps/web/ce/store/pages/extended-base-page.ts b/apps/web/ce/store/pages/extended-base-page.ts new file mode 100644 index 00000000..2c5cd306 --- /dev/null +++ b/apps/web/ce/store/pages/extended-base-page.ts @@ -0,0 +1,16 @@ +import type { TPage, TPageExtended } from "@plane/types"; +import type { RootStore } from "@/plane-web/store/root.store"; +import type { TBasePageServices } from "@/store/pages/base-page"; + +export type TExtendedPageInstance = TPageExtended & { + asJSONExtended: TPageExtended; +}; + +export class ExtendedBasePage implements TExtendedPageInstance { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(store: RootStore, page: TPage, services: TBasePageServices) {} + + get asJSONExtended(): TExtendedPageInstance["asJSONExtended"] { + return {}; + } +} diff --git a/apps/web/ce/store/project-inbox.store.ts b/apps/web/ce/store/project-inbox.store.ts new file mode 100644 index 00000000..327ff23a --- /dev/null +++ b/apps/web/ce/store/project-inbox.store.ts @@ -0,0 +1 @@ +export * from "@/store/inbox/project-inbox.store"; diff --git a/apps/web/ce/store/project-view.store.ts b/apps/web/ce/store/project-view.store.ts new file mode 100644 index 00000000..41d7ba1c --- /dev/null +++ b/apps/web/ce/store/project-view.store.ts @@ -0,0 +1 @@ +export * from "@/store/project-view.store"; diff --git a/apps/web/ce/store/root.store.ts b/apps/web/ce/store/root.store.ts new file mode 100644 index 00000000..ca6caff8 --- /dev/null +++ b/apps/web/ce/store/root.store.ts @@ -0,0 +1,14 @@ +// store +import { CoreRootStore } from "@/store/root.store"; +import type { ITimelineStore } from "./timeline"; +import { TimeLineStore } from "./timeline"; + +export class RootStore extends CoreRootStore { + timelineStore: ITimelineStore; + + constructor() { + super(); + + this.timelineStore = new TimeLineStore(this); + } +} diff --git a/apps/web/ce/store/state.store.ts b/apps/web/ce/store/state.store.ts new file mode 100644 index 00000000..a25412ca --- /dev/null +++ b/apps/web/ce/store/state.store.ts @@ -0,0 +1 @@ +export * from "@/store/state.store"; diff --git a/apps/web/ce/store/timeline/base-timeline.store.ts b/apps/web/ce/store/timeline/base-timeline.store.ts new file mode 100644 index 00000000..37a75d3d --- /dev/null +++ b/apps/web/ce/store/timeline/base-timeline.store.ts @@ -0,0 +1,340 @@ +import { isEqual, set } from "lodash-es"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// components +import type { + ChartDataType, + IBlockUpdateDependencyData, + IGanttBlock, + TGanttViews, + EGanttBlockType, +} from "@plane/types"; +import { renderFormattedPayloadDate } from "@plane/utils"; +import { currentViewDataWithView } from "@/components/gantt-chart/data"; +import { + getDateFromPositionOnGantt, + getItemPositionWidth, + getPositionFromDate, +} from "@/components/gantt-chart/views/helpers"; +// helpers +// store +import type { RootStore } from "@/plane-web/store/root.store"; + +// types +type BlockData = { + id: string; + name: string; + sort_order: number | null; + start_date?: string | undefined | null; + target_date?: string | undefined | null; + project_id?: string | undefined | null; +}; + +export interface IBaseTimelineStore { + // observables + currentView: TGanttViews; + currentViewData: ChartDataType | undefined; + activeBlockId: string | null; + renderView: any; + isDragging: boolean; + isDependencyEnabled: boolean; + // + setBlockIds: (ids: string[]) => void; + getBlockById: (blockId: string) => IGanttBlock; + // computed functions + getIsCurrentDependencyDragging: (blockId: string) => boolean; + isBlockActive: (blockId: string) => boolean; + // actions + updateCurrentView: (view: TGanttViews) => void; + updateCurrentViewData: (data: ChartDataType | undefined) => void; + updateActiveBlockId: (blockId: string | null) => void; + updateRenderView: (data: any) => void; + updateAllBlocksOnChartChangeWhileDragging: (addedWidth: number) => void; + getUpdatedPositionAfterDrag: ( + id: string, + shouldUpdateHalfBlock: boolean, + ignoreDependencies?: boolean + ) => IBlockUpdateDependencyData[]; + updateBlockPosition: (id: string, deltaLeft: number, deltaWidth: number, ignoreDependencies?: boolean) => void; + getNumberOfDaysFromPosition: (position: number | undefined) => number | undefined; + setIsDragging: (isDragging: boolean) => void; + initGantt: () => void; + + getDateFromPositionOnGantt: (position: number, offsetDays: number) => Date | undefined; + getPositionFromDateOnGantt: (date: string | Date, offSetWidth: number) => number | undefined; +} + +export class BaseTimeLineStore implements IBaseTimelineStore { + blocksMap: Record = {}; + blockIds: string[] | undefined = undefined; + + isDragging: boolean = false; + currentView: TGanttViews = "week"; + currentViewData: ChartDataType | undefined = undefined; + activeBlockId: string | null = null; + renderView: any = []; + + rootStore: RootStore; + + isDependencyEnabled = false; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + blocksMap: observable, + blockIds: observable, + isDragging: observable.ref, + currentView: observable.ref, + currentViewData: observable, + activeBlockId: observable.ref, + renderView: observable, + // actions + setIsDragging: action, + setBlockIds: action.bound, + initGantt: action.bound, + updateCurrentView: action.bound, + updateCurrentViewData: action.bound, + updateActiveBlockId: action.bound, + updateRenderView: action.bound, + }); + + this.initGantt(); + + this.rootStore = _rootStore; + } + + /** + * Update Block Ids to derive blocks from + * @param ids + */ + setBlockIds = (ids: string[]) => { + this.blockIds = ids; + }; + + /** + * setIsDragging + * @param isDragging + */ + setIsDragging = (isDragging: boolean) => { + runInAction(() => { + this.isDragging = isDragging; + }); + }; + + /** + * @description check if block is active + * @param {string} blockId + */ + isBlockActive = computedFn((blockId: string): boolean => this.activeBlockId === blockId); + + /** + * @description update current view + * @param {TGanttViews} view + */ + updateCurrentView = (view: TGanttViews) => { + this.currentView = view; + }; + + /** + * @description update current view data + * @param {ChartDataType | undefined} data + */ + updateCurrentViewData = (data: ChartDataType | undefined) => { + runInAction(() => { + this.currentViewData = data; + }); + }; + + /** + * @description update active block + * @param {string | null} block + */ + updateActiveBlockId = (blockId: string | null) => { + this.activeBlockId = blockId; + }; + + /** + * @description update render view + * @param {any[]} data + */ + updateRenderView = (data: any[]) => { + this.renderView = data; + }; + + /** + * @description initialize gantt chart with month view + */ + initGantt = () => { + const newCurrentViewData = currentViewDataWithView(this.currentView); + + runInAction(() => { + this.currentViewData = newCurrentViewData; + this.blocksMap = {}; + this.blockIds = undefined; + }); + }; + + /** Gets Block from Id */ + getBlockById = computedFn((blockId: string) => this.blocksMap[blockId]); + + /** + * updates the BlocksMap from blockIds + * @param getDataById + * @returns + */ + updateBlocks(getDataById: (id: string) => BlockData | undefined | null, type?: EGanttBlockType, index?: number) { + if (!this.blockIds || !Array.isArray(this.blockIds) || this.isDragging) return true; + + const updatedBlockMaps: { path: string[]; value: any }[] = []; + const newBlocks: IGanttBlock[] = []; + + // Loop through blockIds to generate blocks Data + for (const blockId of this.blockIds) { + const blockData = getDataById(blockId); + if (!blockData) continue; + + const block: IGanttBlock = { + data: blockData, + id: blockData?.id, + name: blockData.name, + sort_order: blockData?.sort_order ?? undefined, + start_date: blockData?.start_date ?? undefined, + target_date: blockData?.target_date ?? undefined, + meta: { + type, + index, + project_id: blockData?.project_id, + }, + }; + if (this.currentViewData && (this.currentViewData?.data?.startDate || this.currentViewData?.data?.dayWidth)) { + block.position = getItemPositionWidth(this.currentViewData, block); + } + + // create block updates if the block already exists, or push them to newBlocks + if (this.blocksMap[blockId]) { + for (const key of Object.keys(block)) { + const currValue = this.blocksMap[blockId][key as keyof IGanttBlock]; + const nextValue = block[key as keyof IGanttBlock]; + if (!isEqual(currValue, nextValue)) { + updatedBlockMaps.push({ path: [blockId, key], value: nextValue }); + } + } + } else { + newBlocks.push(block); + } + } + + // update the store with the block updates + runInAction(() => { + for (const updatedBlock of updatedBlockMaps) { + set(this.blocksMap, updatedBlock.path, updatedBlock.value); + } + + for (const newBlock of newBlocks) { + set(this.blocksMap, [newBlock.id], newBlock); + } + }); + } + + /** + * returns number of days that the position pixels span across the timeline chart + * @param position + * @returns + */ + getNumberOfDaysFromPosition = (position: number | undefined) => { + if (!this.currentViewData || !position) return; + + return Math.round(position / this.currentViewData.data.dayWidth); + }; + + /** + * returns position of the date on chart + */ + getPositionFromDateOnGantt = computedFn((date: string | Date, offSetWidth: number) => { + if (!this.currentViewData) return; + + return getPositionFromDate(this.currentViewData, date, offSetWidth); + }); + + /** + * returns the date at which the position corresponds to on the timeline chart + */ + getDateFromPositionOnGantt = computedFn((position: number, offsetDays: number) => { + if (!this.currentViewData) return; + + return getDateFromPositionOnGantt(position, this.currentViewData, offsetDays); + }); + + /** + * Adds width on Chart position change while the blocks are being dragged + * @param addedWidth + */ + updateAllBlocksOnChartChangeWhileDragging = action((addedWidth: number) => { + if (!this.blockIds || !this.isDragging) return; + + runInAction(() => { + this.blockIds?.forEach((blockId) => { + const currBlock = this.blocksMap[blockId]; + + if (!currBlock || !currBlock.position) return; + + currBlock.position.marginLeft += addedWidth; + }); + }); + }); + + /** + * returns updates dates of blocks post drag. + * @param id + * @param shouldUpdateHalfBlock if is a half block then update the incomplete block only if this is true + * @returns + */ + getUpdatedPositionAfterDrag = action((id: string, shouldUpdateHalfBlock: boolean) => { + const currBlock = this.blocksMap[id]; + + if (!currBlock?.position || !this.currentViewData) return []; + + const updatePayload: IBlockUpdateDependencyData = { id, meta: currBlock.meta }; + + // If shouldUpdateHalfBlock or the start date is available then update start date + if (shouldUpdateHalfBlock || currBlock.start_date) { + updatePayload.start_date = renderFormattedPayloadDate( + getDateFromPositionOnGantt(currBlock.position.marginLeft, this.currentViewData) + ); + } + // If shouldUpdateHalfBlock or the target date is available then update target date + if (shouldUpdateHalfBlock || currBlock.target_date) { + updatePayload.target_date = renderFormattedPayloadDate( + getDateFromPositionOnGantt(currBlock.position.marginLeft + currBlock.position.width, this.currentViewData, -1) + ); + } + + return [updatePayload]; + }); + + /** + * updates the block's position such as marginLeft and width while dragging + * @param id + * @param deltaLeft + * @param deltaWidth + * @returns + */ + updateBlockPosition = action((id: string, deltaLeft: number, deltaWidth: number) => { + const currBlock = this.blocksMap[id]; + + if (!currBlock?.position) return; + + const newMarginLeft = currBlock.position.marginLeft + deltaLeft; + const newWidth = currBlock.position.width + deltaWidth; + + runInAction(() => { + set(this.blocksMap, [id, "position"], { + marginLeft: newMarginLeft ?? currBlock.position?.marginLeft, + width: newWidth ?? currBlock.position?.width, + }); + }); + }); + + // Dummy method to return if the current Block's dependency is being dragged + getIsCurrentDependencyDragging = computedFn((blockId: string) => false); +} diff --git a/apps/web/ce/store/timeline/index.ts b/apps/web/ce/store/timeline/index.ts new file mode 100644 index 00000000..a6afa124 --- /dev/null +++ b/apps/web/ce/store/timeline/index.ts @@ -0,0 +1,29 @@ +import type { RootStore } from "@/plane-web/store/root.store"; +import { IssuesTimeLineStore } from "@/store/timeline/issues-timeline.store"; +import type { IIssuesTimeLineStore } from "@/store/timeline/issues-timeline.store"; +import { ModulesTimeLineStore } from "@/store/timeline/modules-timeline.store"; +import type { IModulesTimeLineStore } from "@/store/timeline/modules-timeline.store"; +import { BaseTimeLineStore } from "./base-timeline.store"; +import type { IBaseTimelineStore } from "./base-timeline.store"; + +export interface ITimelineStore { + issuesTimeLineStore: IIssuesTimeLineStore; + modulesTimeLineStore: IModulesTimeLineStore; + projectTimeLineStore: IBaseTimelineStore; + groupedTimeLineStore: IBaseTimelineStore; +} + +export class TimeLineStore implements ITimelineStore { + issuesTimeLineStore: IIssuesTimeLineStore; + modulesTimeLineStore: IModulesTimeLineStore; + projectTimeLineStore: IBaseTimelineStore; + groupedTimeLineStore: IBaseTimelineStore; + + constructor(rootStore: RootStore) { + this.issuesTimeLineStore = new IssuesTimeLineStore(rootStore); + this.modulesTimeLineStore = new ModulesTimeLineStore(rootStore); + // Dummy store + this.projectTimeLineStore = new BaseTimeLineStore(rootStore); + this.groupedTimeLineStore = new BaseTimeLineStore(rootStore); + } +} diff --git a/apps/web/ce/store/user/permission.store.ts b/apps/web/ce/store/user/permission.store.ts new file mode 100644 index 00000000..11ce4547 --- /dev/null +++ b/apps/web/ce/store/user/permission.store.ts @@ -0,0 +1,24 @@ +import { computedFn } from "mobx-utils"; +import type { EUserPermissions } from "@plane/constants"; +import type { RootStore } from "@/plane-web/store/root.store"; +import { BaseUserPermissionStore } from "@/store/user/base-permissions.store"; +import type { IBaseUserPermissionStore } from "@/store/user/base-permissions.store"; + +export type IUserPermissionStore = IBaseUserPermissionStore; + +export class UserPermissionStore extends BaseUserPermissionStore implements IUserPermissionStore { + constructor(store: RootStore) { + super(store); + } + + /** + * @description Returns the project role from the workspace + * @param { string } workspaceSlug + * @param { string } projectId + * @returns { EUserPermissions | undefined } + */ + getProjectRoleByWorkspaceSlugAndProjectId = computedFn( + (workspaceSlug: string, projectId: string): EUserPermissions | undefined => + this.getProjectRole(workspaceSlug, projectId) + ); +} diff --git a/apps/web/ce/types/gantt-chart.ts b/apps/web/ce/types/gantt-chart.ts new file mode 100644 index 00000000..36bb65c6 --- /dev/null +++ b/apps/web/ce/types/gantt-chart.ts @@ -0,0 +1 @@ +export type TIssueRelationTypes = "blocking" | "blocked_by" | "duplicate" | "relates_to"; diff --git a/apps/web/ce/types/index.ts b/apps/web/ce/types/index.ts new file mode 100644 index 00000000..105b7e96 --- /dev/null +++ b/apps/web/ce/types/index.ts @@ -0,0 +1,3 @@ +export * from "./projects"; +export * from "./issue-types"; +export * from "./gantt-chart"; diff --git a/apps/web/ce/types/issue-types/index.ts b/apps/web/ce/types/issue-types/index.ts new file mode 100644 index 00000000..7259fa35 --- /dev/null +++ b/apps/web/ce/types/issue-types/index.ts @@ -0,0 +1 @@ +export * from "./issue-property-values.d"; diff --git a/apps/web/ce/types/issue-types/issue-property-values.d.ts b/apps/web/ce/types/issue-types/issue-property-values.d.ts new file mode 100644 index 00000000..e1d94dbc --- /dev/null +++ b/apps/web/ce/types/issue-types/issue-property-values.d.ts @@ -0,0 +1,2 @@ +export type TIssuePropertyValues = object; +export type TIssuePropertyValueErrors = object; diff --git a/apps/web/ce/types/pages/pane-extensions.ts b/apps/web/ce/types/pages/pane-extensions.ts new file mode 100644 index 00000000..dcfae12e --- /dev/null +++ b/apps/web/ce/types/pages/pane-extensions.ts @@ -0,0 +1,16 @@ +import type { + INavigationPaneExtension as ICoreNavigationPaneExtension, + INavigationPaneExtensionComponent, +} from "@/components/pages/navigation-pane"; + +// EE Union/map of extension data types (keyed by extension id) +export type TNavigationPaneExtensionData = Record; + +// EE Navigation pane extension configuration +export interface INavigationPaneExtension< + T extends keyof TNavigationPaneExtensionData = keyof TNavigationPaneExtensionData, +> extends Omit, "id" | "data" | "component"> { + id: T; + component: INavigationPaneExtensionComponent; + data?: TNavigationPaneExtensionData[T]; +} diff --git a/apps/web/ce/types/projects/index.ts b/apps/web/ce/types/projects/index.ts new file mode 100644 index 00000000..9fb35777 --- /dev/null +++ b/apps/web/ce/types/projects/index.ts @@ -0,0 +1,2 @@ +export * from "./projects"; +export * from "./project-activity"; diff --git a/apps/web/ce/types/projects/project-activity.ts b/apps/web/ce/types/projects/project-activity.ts new file mode 100644 index 00000000..766b0ada --- /dev/null +++ b/apps/web/ce/types/projects/project-activity.ts @@ -0,0 +1,21 @@ +import type { TProjectBaseActivity } from "@plane/types"; + +export type TProjectActivity = TProjectBaseActivity & { + content: string; + userId: string; + projectId: string; + + actor_detail: { + display_name: string; + id: string; + }; + workspace_detail: { + slug: string; + }; + project_detail: { + name: string; + }; + + createdAt: string; + updatedAt: string; +}; diff --git a/apps/web/ce/types/projects/projects.ts b/apps/web/ce/types/projects/projects.ts new file mode 100644 index 00000000..51427282 --- /dev/null +++ b/apps/web/ce/types/projects/projects.ts @@ -0,0 +1,5 @@ +import type { IPartialProject, IProject } from "@plane/types"; + +export type TPartialProject = IPartialProject; + +export type TProject = TPartialProject & IProject; diff --git a/apps/web/core/components/account/auth-forms/auth-banner.tsx b/apps/web/core/components/account/auth-forms/auth-banner.tsx new file mode 100644 index 00000000..95022dcb --- /dev/null +++ b/apps/web/core/components/account/auth-forms/auth-banner.tsx @@ -0,0 +1,39 @@ +import type { FC } from "react"; +import { Info, X } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; + +type TAuthBanner = { + bannerData: TAuthErrorInfo | undefined; + handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void; +}; + +export const AuthBanner: FC = (props) => { + const { bannerData, handleBannerData } = props; + // translation + const { t } = useTranslation(); + + if (!bannerData) return <>; + + return ( +
    +
    + +
    +

    {bannerData?.message}

    + +
    + ); +}; diff --git a/apps/web/core/components/account/auth-forms/auth-header.tsx b/apps/web/core/components/account/auth-forms/auth-header.tsx new file mode 100644 index 00000000..3ca9c38b --- /dev/null +++ b/apps/web/core/components/account/auth-forms/auth-header.tsx @@ -0,0 +1,112 @@ +import type { FC } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { useTranslation } from "@plane/i18n"; +import type { IWorkspaceMemberInvitation } from "@plane/types"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { WorkspaceLogo } from "@/components/workspace/logo"; +// helpers +import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; +import { WorkspaceService } from "@/plane-web/services"; +// services + +type TAuthHeader = { + workspaceSlug: string | undefined; + invitationId: string | undefined; + invitationEmail: string | undefined; + authMode: EAuthModes; + currentAuthStep: EAuthSteps; +}; + +const Titles = { + [EAuthModes.SIGN_IN]: { + [EAuthSteps.EMAIL]: { + header: "Work in all dimensions.", + subHeader: "Welcome back to Plane.", + }, + [EAuthSteps.PASSWORD]: { + header: "Work in all dimensions.", + subHeader: "Welcome back to Plane.", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Work in all dimensions.", + subHeader: "Welcome back to Plane.", + }, + }, + [EAuthModes.SIGN_UP]: { + [EAuthSteps.EMAIL]: { + header: "Work in all dimensions.", + subHeader: "Create your Plane account.", + }, + [EAuthSteps.PASSWORD]: { + header: "Work in all dimensions.", + subHeader: "Create your Plane account.", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Work in all dimensions.", + subHeader: "Create your Plane account.", + }, + }, +}; + +const workSpaceService = new WorkspaceService(); + +export const AuthHeader: FC = observer((props) => { + const { workspaceSlug, invitationId, invitationEmail, authMode, currentAuthStep } = props; + // plane imports + const { t } = useTranslation(); + + const { data: invitation, isLoading } = useSWR( + workspaceSlug && invitationId ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null, + async () => workspaceSlug && invitationId && workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + + const getHeaderSubHeader = ( + step: EAuthSteps, + mode: EAuthModes, + invitation: IWorkspaceMemberInvitation | undefined, + email: string | undefined + ) => { + if (invitation && email && invitation.email === email && invitation.workspace) { + const workspace = invitation.workspace; + return { + header: ( +
    + {t("common.join")}{" "} + {" "} + {workspace.name} +
    + ), + subHeader: + mode == EAuthModes.SIGN_UP + ? "Create an account to start managing work with your team." + : "Log in to start managing work with your team.", + }; + } + + return Titles[mode][step]; + }; + + const { header, subHeader } = getHeaderSubHeader(currentAuthStep, authMode, invitation || undefined, invitationEmail); + + if (isLoading) + return ( +
    + +
    + ); + + return ( +
    + + {typeof header === "string" ? t(header) : header} + + {subHeader} +
    + ); +}); diff --git a/apps/web/core/components/account/auth-forms/auth-root.tsx b/apps/web/core/components/account/auth-forms/auth-root.tsx new file mode 100644 index 00000000..9d01a2d3 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/auth-root.tsx @@ -0,0 +1,178 @@ +import type { FC } from "react"; +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { OAuthOptions } from "@plane/ui"; +// assets +import GithubLightLogo from "/public/logos/github-black.png"; +import GithubDarkLogo from "/public/logos/github-dark.svg"; +import GitlabLogo from "/public/logos/gitlab-logo.svg"; +import GoogleLogo from "/public/logos/google-logo.svg"; +// helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { + EAuthModes, + EAuthSteps, + EAuthenticationErrorCodes, + EErrorAlertType, + authErrorHandler, +} from "@/helpers/authentication.helper"; +// hooks +import { useInstance } from "@/hooks/store/use-instance"; +// local imports +import { TermsAndConditions } from "../terms-and-conditions"; +import { AuthBanner } from "./auth-banner"; +import { AuthHeader } from "./auth-header"; +import { AuthFormRoot } from "./form-root"; + +type TAuthRoot = { + authMode: EAuthModes; +}; + +export const AuthRoot: FC = observer((props) => { + //router + const searchParams = useSearchParams(); + // query params + const emailParam = searchParams.get("email"); + const invitation_id = searchParams.get("invitation_id"); + const workspaceSlug = searchParams.get("slug"); + const error_code = searchParams.get("error_code"); + const next_path = searchParams.get("next_path"); + const { resolvedTheme } = useTheme(); + // props + const { authMode: currentAuthMode } = props; + // states + const [authMode, setAuthMode] = useState(undefined); + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); + const [errorInfo, setErrorInfo] = useState(undefined); + + // hooks + const { config } = useInstance(); + + // derived values + const isOAuthEnabled = + (config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false; + + useEffect(() => { + if (!authMode && currentAuthMode) setAuthMode(currentAuthMode); + }, [currentAuthMode, authMode]); + + useEffect(() => { + if (error_code && authMode) { + const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); + if (errorhandler) { + // password error handler + if ([EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP].includes(errorhandler.code)) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.PASSWORD); + } + if ([EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN].includes(errorhandler.code)) { + setAuthMode(EAuthModes.SIGN_IN); + setAuthStep(EAuthSteps.PASSWORD); + } + // magic_code error handler + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP, + ].includes(errorhandler.code) + ) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.UNIQUE_CODE); + } + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN, + ].includes(errorhandler.code) + ) { + setAuthMode(EAuthModes.SIGN_IN); + setAuthStep(EAuthSteps.UNIQUE_CODE); + } + + setErrorInfo(errorhandler); + } + } + }, [error_code, authMode]); + + if (!authMode) return <>; + + const OauthButtonContent = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in"; + + const OAuthConfig = [ + { + id: "google", + text: `${OauthButtonContent} with Google`, + icon: Google Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/google/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_google_enabled, + }, + { + id: "github", + text: `${OauthButtonContent} with GitHub`, + icon: ( + GitHub Logo + ), + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_github_enabled, + }, + { + id: "gitlab", + text: `${OauthButtonContent} with GitLab`, + icon: GitLab Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_gitlab_enabled, + }, + ]; + + return ( +
    +
    + {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} + + + {isOAuthEnabled && } + + setEmail(email)} + setAuthMode={(authMode) => setAuthMode(authMode)} + setAuthStep={(authStep) => setAuthStep(authStep)} + setErrorInfo={(errorInfo) => setErrorInfo(errorInfo)} + currentAuthMode={currentAuthMode} + /> + +
    +
    + ); +}); diff --git a/apps/web/core/components/account/auth-forms/common/container.tsx b/apps/web/core/components/account/auth-forms/common/container.tsx new file mode 100644 index 00000000..ccdb3e52 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/common/container.tsx @@ -0,0 +1,7 @@ +"use client"; + +export const FormContainer = ({ children }: { children: React.ReactNode }) => ( +
    +
    {children}
    +
    +); diff --git a/apps/web/core/components/account/auth-forms/common/header.tsx b/apps/web/core/components/account/auth-forms/common/header.tsx new file mode 100644 index 00000000..b8da31dd --- /dev/null +++ b/apps/web/core/components/account/auth-forms/common/header.tsx @@ -0,0 +1,8 @@ +"use client"; + +export const AuthFormHeader = ({ title, description }: { title: string; description: string }) => ( +
    + {title} + {description} +
    +); diff --git a/apps/web/core/components/account/auth-forms/email.tsx b/apps/web/core/components/account/auth-forms/email.tsx new file mode 100644 index 00000000..97a01dfb --- /dev/null +++ b/apps/web/core/components/account/auth-forms/email.tsx @@ -0,0 +1,104 @@ +"use client"; + +import type { FC, FormEvent } from "react"; +import { useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +// icons +import { CircleAlert, XCircle } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import type { IEmailCheckData } from "@plane/types"; +import { Input, Spinner } from "@plane/ui"; +import { cn, checkEmailValidity } from "@plane/utils"; +// helpers +type TAuthEmailForm = { + defaultEmail: string; + onSubmit: (data: IEmailCheckData) => Promise; +}; + +export const AuthEmailForm: FC = observer((props) => { + const { onSubmit, defaultEmail } = props; + // states + const [isSubmitting, setIsSubmitting] = useState(false); + const [email, setEmail] = useState(defaultEmail); + // plane hooks + const { t } = useTranslation(); + const emailError = useMemo( + () => (email && !checkEmailValidity(email) ? { email: "auth.common.email.errors.invalid" } : undefined), + [email] + ); + + const handleFormSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + const payload: IEmailCheckData = { + email: email, + }; + await onSubmit(payload); + setIsSubmitting(false); + }; + + const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting; + + const [isFocused, setIsFocused] = useState(true); + const inputRef = useRef(null); + + return ( +
    +
    + +
    { + setIsFocused(true); + }} + onBlur={() => { + setIsFocused(false); + }} + > + setEmail(e.target.value)} + placeholder={t("auth.common.email.placeholder")} + className={`disable-autofill-style h-10 w-full placeholder:text-custom-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`} + autoComplete="on" + autoFocus + ref={inputRef} + /> + {email.length > 0 && ( + + )} +
    + {emailError?.email && !isFocused && ( +

    + + {t(emailError.email)} +

    + )} +
    + +
    + ); +}); diff --git a/apps/web/core/components/account/auth-forms/forgot-password-popover.tsx b/apps/web/core/components/account/auth-forms/forgot-password-popover.tsx new file mode 100644 index 00000000..3b243b89 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/forgot-password-popover.tsx @@ -0,0 +1,61 @@ +import { Fragment, useState } from "react"; +import { usePopper } from "react-popper"; +import { X } from "lucide-react"; +import { Popover } from "@headlessui/react"; +// plane imports +import { useTranslation } from "@plane/i18n"; + +export const ForgotPasswordPopover = () => { + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "right-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + // plane hooks + const { t } = useTranslation(); + + return ( + + + + + + {({ close }) => ( +
    + 🤥 +

    {t("auth.forgot_password.errors.smtp_not_enabled")}

    + +
    + )} +
    +
    + ); +}; diff --git a/apps/web/core/components/account/auth-forms/forgot-password.tsx b/apps/web/core/components/account/auth-forms/forgot-password.tsx new file mode 100644 index 00000000..018ff951 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/forgot-password.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Controller, useForm } from "react-hook-form"; +// icons +import { CircleCheck } from "lucide-react"; +// plane imports +import { AUTH_TRACKER_EVENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Input } from "@plane/ui"; +import { cn, checkEmailValidity } from "@plane/utils"; +// helpers +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +// hooks +import useTimer from "@/hooks/use-timer"; +// services +import { AuthService } from "@/services/auth.service"; +// local components +import { FormContainer } from "./common/container"; +import { AuthFormHeader } from "./common/header"; + +type TForgotPasswordFormValues = { + email: string; +}; + +const defaultValues: TForgotPasswordFormValues = { + email: "", +}; + +// services +const authService = new AuthService(); + +export const ForgotPasswordForm = observer(() => { + // search params + const searchParams = useSearchParams(); + const email = searchParams.get("email"); + // plane hooks + const { t } = useTranslation(); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); + + // form info + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + defaultValues: { + ...defaultValues, + email: email?.toString() ?? "", + }, + }); + + const handleForgotPassword = async (formData: TForgotPasswordFormValues) => { + await authService + .sendResetPasswordLink({ + email: formData.email, + }) + .then(() => { + captureSuccess({ + eventName: AUTH_TRACKER_EVENTS.forgot_password, + payload: { + email: formData.email, + }, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("auth.forgot_password.toast.success.title"), + message: t("auth.forgot_password.toast.success.message"), + }); + setResendCodeTimer(30); + }) + .catch((err) => { + captureError({ + eventName: AUTH_TRACKER_EVENTS.forgot_password, + payload: { + email: formData.email, + }, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("auth.forgot_password.toast.error.title"), + message: err?.error ?? t("auth.forgot_password.toast.error.message"), + }); + }); + }; + + return ( + + +
    +
    + + checkEmailValidity(value) || t("auth.common.email.errors.invalid"), + }} + render={({ field: { value, onChange, ref } }) => ( + 0} + /> + )} + /> + {resendTimerCode > 0 && ( +

    + + {t("auth.forgot_password.email_sent")} +

    + )} +
    + + + {t("auth.common.back_to_sign_in")} + +
    +
    + ); +}); diff --git a/apps/web/core/components/account/auth-forms/form-root.tsx b/apps/web/core/components/account/auth-forms/form-root.tsx new file mode 100644 index 00000000..4edbcc04 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/form-root.tsx @@ -0,0 +1,134 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import { EAuthModes, EAuthSteps } from "@plane/constants"; +import type { IEmailCheckData } from "@plane/types"; +// helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { authErrorHandler } from "@/helpers/authentication.helper"; +// hooks +import { useInstance } from "@/hooks/store/use-instance"; +import { useAppRouter } from "@/hooks/use-app-router"; +// services +import { AuthService } from "@/services/auth.service"; +// local components +import { AuthEmailForm } from "./email"; +import { AuthPasswordForm } from "./password"; +import { AuthUniqueCodeForm } from "./unique-code"; + +type TAuthFormRoot = { + authStep: EAuthSteps; + authMode: EAuthModes; + email: string; + setEmail: (email: string) => void; + setAuthMode: (authMode: EAuthModes) => void; + setAuthStep: (authStep: EAuthSteps) => void; + setErrorInfo: (errorInfo: TAuthErrorInfo | undefined) => void; + currentAuthMode: EAuthModes; +}; + +const authService = new AuthService(); + +export const AuthFormRoot = observer((props: TAuthFormRoot) => { + const { authStep, authMode, email, setEmail, setAuthMode, setAuthStep, setErrorInfo, currentAuthMode } = props; + // router + const router = useAppRouter(); + // query params + const searchParams = useSearchParams(); + const nextPath = searchParams.get("next_path"); + // states + const [isExistingEmail, setIsExistingEmail] = useState(false); + // hooks + const { config } = useInstance(); + + const isSMTPConfigured = config?.is_smtp_configured || false; + + // submit handler- email verification + const handleEmailVerification = async (data: IEmailCheckData) => { + setEmail(data.email); + setErrorInfo(undefined); + await authService + .emailCheck(data) + .then(async (response) => { + if (response.existing) { + if (currentAuthMode === EAuthModes.SIGN_UP) setAuthMode(EAuthModes.SIGN_IN); + if (response.status === "MAGIC_CODE") { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (response.status === "CREDENTIAL") { + setAuthStep(EAuthSteps.PASSWORD); + } + } else { + if (currentAuthMode === EAuthModes.SIGN_IN) setAuthMode(EAuthModes.SIGN_UP); + if (response.status === "MAGIC_CODE") { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (response.status === "CREDENTIAL") { + setAuthStep(EAuthSteps.PASSWORD); + } + } + setIsExistingEmail(response.existing); + }) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined); + if (errorhandler?.type) setErrorInfo(errorhandler); + }); + }; + + const handleEmailClear = () => { + setAuthMode(currentAuthMode); + setErrorInfo(undefined); + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + router.push(currentAuthMode === EAuthModes.SIGN_IN ? `/` : "/sign-up"); + }; + + // generating the unique code + const generateEmailUniqueCode = async (email: string): Promise<{ code: string } | undefined> => { + if (!isSMTPConfigured) return; + const payload = { email: email }; + return await authService + .generateUniqueCode(payload) + .then(() => ({ code: "" })) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code.toString()); + if (errorhandler?.type) setErrorInfo(errorhandler); + throw error; + }); + }; + + if (authStep === EAuthSteps.EMAIL) { + return ; + } + if (authStep === EAuthSteps.UNIQUE_CODE) { + return ( + + ); + } + if (authStep === EAuthSteps.PASSWORD) { + return ( + { + if (step === EAuthSteps.UNIQUE_CODE) generateEmailUniqueCode(email); + setAuthStep(step); + }} + nextPath={nextPath || undefined} + /> + ); + } + + return <>; +}); diff --git a/apps/web/core/components/account/auth-forms/index.ts b/apps/web/core/components/account/auth-forms/index.ts new file mode 100644 index 00000000..aa4ee6fd --- /dev/null +++ b/apps/web/core/components/account/auth-forms/index.ts @@ -0,0 +1 @@ +export * from "./auth-root"; diff --git a/apps/web/core/components/account/auth-forms/password.tsx b/apps/web/core/components/account/auth-forms/password.tsx new file mode 100644 index 00000000..52d32dc5 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/password.tsx @@ -0,0 +1,327 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// icons +import { Eye, EyeOff, Info, X, XCircle } from "lucide-react"; +// plane imports +import { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS, AUTH_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; +import { getPasswordStrength } from "@plane/utils"; +// components +import { ForgotPasswordPopover } from "@/components/account/auth-forms/forgot-password-popover"; +// constants +// helpers +import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; +// hooks +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +// services +import { AuthService } from "@/services/auth.service"; + +type Props = { + email: string; + isSMTPConfigured: boolean; + mode: EAuthModes; + handleEmailClear: () => void; + handleAuthStep: (step: EAuthSteps) => void; + nextPath: string | undefined; +}; + +type TPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TPasswordFormValues = { + email: "", + password: "", +}; + +const authService = new AuthService(); + +export const AuthPasswordForm: React.FC = observer((props: Props) => { + const { email, isSMTPConfigured, handleAuthStep, handleEmailClear, mode, nextPath } = props; + // plane imports + const { t } = useTranslation(); + // ref + const formRef = useRef(null); + // states + const [csrfPromise, setCsrfPromise] = useState | undefined>(undefined); + const [passwordFormData, setPasswordFormData] = useState({ ...defaultValues, email }); + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + const [isBannerMessage, setBannerMessage] = useState(false); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TPasswordFormValues, value: string) => + setPasswordFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfPromise === undefined) { + const promise = authService.requestCSRFToken(); + setCsrfPromise(promise); + } + }, [csrfPromise]); + + const redirectToUniqueCodeSignIn = async () => { + handleAuthStep(EAuthSteps.UNIQUE_CODE); + }; + + const passwordSupport = + mode === EAuthModes.SIGN_IN ? ( +
    + {isSMTPConfigured ? ( + + {t("auth.common.forgot_password")} + + ) : ( + + )} +
    + ) : ( + passwordFormData.password.length > 0 && + getPasswordStrength(passwordFormData.password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( + + ) + ); + + const isButtonDisabled = useMemo( + () => + !isSubmitting && + !!passwordFormData.password && + (mode === EAuthModes.SIGN_UP ? passwordFormData.password === passwordFormData.confirm_password : true) + ? false + : true, + [isSubmitting, mode, passwordFormData.confirm_password, passwordFormData.password] + ); + + const password = passwordFormData?.password ?? ""; + const confirmPassword = passwordFormData?.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + const handleCSRFToken = async () => { + if (!formRef || !formRef.current) return; + const token = await csrfPromise; + if (!token?.csrf_token) return; + const csrfElement = formRef.current.querySelector("input[name=csrfmiddlewaretoken]"); + csrfElement?.setAttribute("value", token?.csrf_token); + }; + + return ( + <> + {isBannerMessage && mode === EAuthModes.SIGN_UP && ( +
    +
    + +
    +
    {t("auth.sign_up.errors.password.strength")}
    +
    setBannerMessage(false)} + > + +
    +
    + )} +
    { + event.preventDefault(); // Prevent form from submitting by default + await handleCSRFToken(); + const isPasswordValid = + mode === EAuthModes.SIGN_UP + ? getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID + : true; + if (isPasswordValid) { + setIsSubmitting(true); + captureSuccess({ + eventName: + mode === EAuthModes.SIGN_IN + ? AUTH_TRACKER_EVENTS.sign_in_with_password + : AUTH_TRACKER_EVENTS.sign_up_with_password, + payload: { + email: passwordFormData.email, + }, + }); + if (formRef.current) formRef.current.submit(); // Manually submit the form if the condition is met + } else { + setBannerMessage(true); + } + }} + onError={() => { + setIsSubmitting(false); + captureError({ + eventName: + mode === EAuthModes.SIGN_IN + ? AUTH_TRACKER_EVENTS.sign_in_with_password + : AUTH_TRACKER_EVENTS.sign_up_with_password, + payload: { + email: passwordFormData.email, + }, + }); + }} + > + + + {nextPath && } +
    + +
    + handleFormChange("email", e.target.value)} + placeholder={t("auth.common.email.placeholder")} + className={`disable-autofill-style h-10 w-full placeholder:text-custom-text-400 border-0`} + disabled + /> + {passwordFormData.email.length > 0 && ( + + )} +
    +
    + +
    + +
    + handleFormChange("password", e.target.value)} + placeholder={t("auth.common.password.placeholder")} + className="disable-autofill-style h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400" + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="on" + autoFocus + /> + +
    + {passwordSupport} +
    + + {mode === EAuthModes.SIGN_UP && ( +
    + +
    + handleFormChange("confirm_password", e.target.value)} + placeholder={t("auth.common.password.confirm_password.placeholder")} + className="disable-autofill-style h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400" + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + +
    + {!!passwordFormData.confirm_password && + passwordFormData.password !== passwordFormData.confirm_password && + renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )} +
    + )} + +
    + {mode === EAuthModes.SIGN_IN ? ( + <> + + {isSMTPConfigured && ( + + )} + + ) : ( + + )} +
    +
    + + ); +}); diff --git a/apps/web/core/components/account/auth-forms/reset-password.tsx b/apps/web/core/components/account/auth-forms/reset-password.tsx new file mode 100644 index 00000000..ffe40d7e --- /dev/null +++ b/apps/web/core/components/account/auth-forms/reset-password.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// ui +import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { Input, PasswordStrengthIndicator } from "@plane/ui"; +// components +import { getPasswordStrength } from "@plane/utils"; +// helpers +import type { EAuthenticationErrorCodes, TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { EErrorAlertType, authErrorHandler } from "@/helpers/authentication.helper"; +// services +import { AuthService } from "@/services/auth.service"; +// local imports +import { AuthBanner } from "./auth-banner"; +import { FormContainer } from "./common/container"; +import { AuthFormHeader } from "./common/header"; + +type TResetPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TResetPasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +export const ResetPasswordForm = observer(() => { + // search params + const searchParams = useSearchParams(); + const uidb64 = searchParams.get("uidb64"); + const token = searchParams.get("token"); + const email = searchParams.get("email"); + const error_code = searchParams.get("error_code"); + // states + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [resetFormData, setResetFormData] = useState({ + ...defaultValues, + email: email ? email.toString() : "", + }); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + const [errorInfo, setErrorInfo] = useState(undefined); + // plane hooks + const { t } = useTranslation(); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) => + setResetFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const isButtonDisabled = useMemo( + () => + !!resetFormData.password && + getPasswordStrength(resetFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID && + resetFormData.password === resetFormData.confirm_password + ? false + : true, + [resetFormData] + ); + + useEffect(() => { + if (error_code) { + const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); + if (errorhandler) { + setErrorInfo(errorhandler); + } + } + }, [error_code]); + + const password = resetFormData?.password ?? ""; + const confirmPassword = resetFormData?.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + return ( + + + + {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} +
    + +
    + +
    + +
    +
    +
    + +
    + handleFormChange("password", e.target.value)} + //hasError={Boolean(errors.password)} + placeholder={t("auth.common.password.placeholder")} + className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400" + minLength={8} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="on" + autoFocus + /> + {showPassword.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
    + +
    +
    + +
    + handleFormChange("confirm_password", e.target.value)} + placeholder={t("auth.common.password.confirm_password.placeholder")} + className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400" + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + {showPassword.retypePassword ? ( + handleShowPassword("retypePassword")} + /> + ) : ( + handleShowPassword("retypePassword")} + /> + )} +
    + {!!resetFormData.confirm_password && + resetFormData.password !== resetFormData.confirm_password && + renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )} +
    + +
    +
    + ); +}); diff --git a/apps/web/core/components/account/auth-forms/set-password.tsx b/apps/web/core/components/account/auth-forms/set-password.tsx new file mode 100644 index 00000000..a84ee913 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/set-password.tsx @@ -0,0 +1,216 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// plane imports +import { AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS, E_PASSWORD_STRENGTH } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Input, PasswordStrengthIndicator } from "@plane/ui"; +// components +import { getPasswordStrength } from "@plane/utils"; +// helpers +import { captureError, captureSuccess, captureView } from "@/helpers/event-tracker.helper"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// services +import { AuthService } from "@/services/auth.service"; +// local components +import { FormContainer } from "./common/container"; +import { AuthFormHeader } from "./common/header"; + +type TResetPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TResetPasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +export const SetPasswordForm = observer(() => { + // router + const router = useAppRouter(); + // search params + const searchParams = useSearchParams(); + const email = searchParams.get("email"); + // states + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [passwordFormData, setPasswordFormData] = useState({ + ...defaultValues, + email: email ? email.toString() : "", + }); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + // plane hooks + const { t } = useTranslation(); + // hooks + const { data: user, handleSetPassword } = useUser(); + + useEffect(() => { + captureView({ + elementName: AUTH_TRACKER_ELEMENTS.SET_PASSWORD_FORM, + }); + }, []); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) => + setPasswordFormData((prev) => ({ ...prev, [key]: value })); + + const isButtonDisabled = useMemo( + () => + !!passwordFormData.password && + getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID && + passwordFormData.password === passwordFormData.confirm_password + ? false + : true, + [passwordFormData] + ); + + const handleSubmit = async (e: FormEvent) => { + try { + e.preventDefault(); + if (!csrfToken) throw new Error("csrf token not found"); + await handleSetPassword(csrfToken, { password: passwordFormData.password }); + captureSuccess({ + eventName: AUTH_TRACKER_EVENTS.password_created, + }); + router.push("/"); + } catch (error: unknown) { + let message = undefined; + if (error instanceof Error) { + const err = error as Error & { error?: string }; + message = err.error; + } + captureError({ + eventName: AUTH_TRACKER_EVENTS.password_created, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("common.errors.default.title"), + message: message ?? t("common.errors.default.message"), + }); + } + }; + + const password = passwordFormData?.password ?? ""; + const confirmPassword = passwordFormData?.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + return ( + + +
    handleSubmit(e)}> +
    + +
    + +
    +
    +
    + +
    + handleFormChange("password", e.target.value)} + //hasError={Boolean(errors.password)} + placeholder={t("auth.common.password.placeholder")} + className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400" + minLength={8} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="on" + autoFocus + /> + {showPassword.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
    + +
    +
    + +
    + handleFormChange("confirm_password", e.target.value)} + placeholder={t("auth.common.password.confirm_password.placeholder")} + className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400" + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + {showPassword.retypePassword ? ( + handleShowPassword("retypePassword")} + /> + ) : ( + handleShowPassword("retypePassword")} + /> + )} +
    + {!!passwordFormData.confirm_password && + passwordFormData.password !== passwordFormData.confirm_password && + renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )} +
    + +
    +
    + ); +}); diff --git a/apps/web/core/components/account/auth-forms/unique-code.tsx b/apps/web/core/components/account/auth-forms/unique-code.tsx new file mode 100644 index 00000000..c009fdf1 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/unique-code.tsx @@ -0,0 +1,208 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { CircleCheck, XCircle } from "lucide-react"; +import { API_BASE_URL, AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { Input, Spinner } from "@plane/ui"; +// constants +// helpers +import { EAuthModes } from "@/helpers/authentication.helper"; +// hooks +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +import useTimer from "@/hooks/use-timer"; +// services +import { AuthService } from "@/services/auth.service"; + +// services +const authService = new AuthService(); + +type TAuthUniqueCodeForm = { + mode: EAuthModes; + email: string; + isExistingEmail: boolean; + handleEmailClear: () => void; + generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>; + nextPath: string | undefined; +}; + +type TUniqueCodeFormValues = { + email: string; + code: string; +}; + +const defaultValues: TUniqueCodeFormValues = { + email: "", + code: "", +}; + +export const AuthUniqueCodeForm: React.FC = (props) => { + const { mode, email, handleEmailClear, generateEmailUniqueCode, isExistingEmail, nextPath } = props; + // derived values + const defaultResetTimerValue = 5; + // states + const [uniqueCodeFormData, setUniqueCodeFormData] = useState({ ...defaultValues, email }); + const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); + // plane hooks + const { t } = useTranslation(); + + const handleFormChange = (key: keyof TUniqueCodeFormValues, value: string) => + setUniqueCodeFormData((prev) => ({ ...prev, [key]: value })); + + const generateNewCode = async (email: string) => { + try { + setIsRequestingNewCode(true); + const uniqueCode = await generateEmailUniqueCode(email); + setResendCodeTimer(defaultResetTimerValue); + handleFormChange("code", uniqueCode?.code || ""); + setIsRequestingNewCode(false); + captureSuccess({ + eventName: AUTH_TRACKER_EVENTS.new_code_requested, + payload: { + email: email, + }, + }); + } catch { + setResendCodeTimer(0); + console.error("Error while requesting new code"); + setIsRequestingNewCode(false); + captureError({ + eventName: AUTH_TRACKER_EVENTS.new_code_requested, + payload: { + email: email, + }, + }); + } + }; + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; + const isButtonDisabled = isRequestingNewCode || !uniqueCodeFormData.code || isSubmitting; + + return ( +
    { + setIsSubmitting(true); + captureSuccess({ + eventName: AUTH_TRACKER_EVENTS.code_verify, + payload: { + state: "SUCCESS", + first_time: !isExistingEmail, + }, + }); + }} + onError={() => { + setIsSubmitting(false); + captureError({ + eventName: AUTH_TRACKER_EVENTS.code_verify, + payload: { + state: "FAILED", + }, + }); + }} + > + + + {nextPath && } +
    + +
    + handleFormChange("email", e.target.value)} + placeholder={t("auth.common.email.placeholder")} + className="disable-autofill-style h-10 w-full placeholder:text-custom-text-400 border-0" + autoComplete="on" + disabled + /> + {uniqueCodeFormData.email.length > 0 && ( + + )} +
    +
    + +
    + + handleFormChange("code", e.target.value)} + placeholder={t("auth.common.unique_code.placeholder")} + className="disable-autofill-style h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400" + autoFocus + /> +
    +

    + + {t("auth.common.unique_code.paste_code")} +

    + +
    +
    + +
    + +
    +
    + ); +}; diff --git a/apps/web/core/components/account/deactivate-account-modal.tsx b/apps/web/core/components/account/deactivate-account-modal.tsx new file mode 100644 index 00000000..548434dd --- /dev/null +++ b/apps/web/core/components/account/deactivate-account-modal.tsx @@ -0,0 +1,128 @@ +"use client"; + +import React, { useState } from "react"; +import { Trash2 } from "lucide-react"; +import { Dialog, Transition } from "@headlessui/react"; +import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +// hooks +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +export const DeactivateAccountModal: React.FC = (props) => { + const router = useAppRouter(); + const { isOpen, onClose } = props; + // hooks + const { t } = useTranslation(); + const { deactivateAccount, signOut } = useUser(); + + // states + const [isDeactivating, setIsDeactivating] = useState(false); + + const handleClose = () => { + setIsDeactivating(false); + onClose(); + }; + + const handleDeleteAccount = async () => { + setIsDeactivating(true); + + await deactivateAccount() + .then(() => { + captureSuccess({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Account deactivated successfully.", + }); + signOut(); + router.push("/"); + handleClose(); + }) + .catch((err: any) => { + captureError({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.error, + }); + }) + .finally(() => setIsDeactivating(false)); + }; + + return ( + + + +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    + + {t("deactivate_your_account")} + +

    + {t("deactivate_your_account_description")} +

    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/web/core/components/account/terms-and-conditions.tsx b/apps/web/core/components/account/terms-and-conditions.tsx new file mode 100644 index 00000000..64bb0fa2 --- /dev/null +++ b/apps/web/core/components/account/terms-and-conditions.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import Link from "next/link"; +import { EAuthModes } from "@plane/constants"; + +interface TermsAndConditionsProps { + authType?: EAuthModes; +} + +// Constants for better maintainability +const LEGAL_LINKS = { + termsOfService: "https://plane.so/legals/terms-and-conditions", + privacyPolicy: "https://plane.so/legals/privacy-policy", +} as const; + +const MESSAGES = { + [EAuthModes.SIGN_UP]: "By creating an account", + [EAuthModes.SIGN_IN]: "By signing in", +} as const; + +// Reusable link component to reduce duplication +const LegalLink: React.FC<{ href: string; children: React.ReactNode }> = ({ href, children }) => ( + + {children} + +); + +export const TermsAndConditions: React.FC = ({ authType = EAuthModes.SIGN_IN }) => ( +
    +

    + {`${MESSAGES[authType]}, you understand and agree to \n our `} + Terms of Service and{" "} + Privacy Policy. +

    +
    +); diff --git a/apps/web/core/components/analytics/analytics-filter-actions.tsx b/apps/web/core/components/analytics/analytics-filter-actions.tsx new file mode 100644 index 00000000..e2ed91f8 --- /dev/null +++ b/apps/web/core/components/analytics/analytics-filter-actions.tsx @@ -0,0 +1,34 @@ +// plane web components +import { observer } from "mobx-react"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +import { useProject } from "@/hooks/store/use-project"; +// components +import DurationDropdown from "./select/duration"; +import { ProjectSelect } from "./select/project"; + +const AnalyticsFilterActions = observer(() => { + const { selectedProjects, selectedDuration, updateSelectedProjects, updateSelectedDuration } = useAnalytics(); + const { joinedProjectIds } = useProject(); + return ( +
    + { + updateSelectedProjects(val ?? []); + }} + projectIds={joinedProjectIds} + /> + {/* { + updateSelectedDuration(val); + }} + dropdownArrow + /> */} +
    + ); +}); + +export default AnalyticsFilterActions; diff --git a/apps/web/core/components/analytics/analytics-section-wrapper.tsx b/apps/web/core/components/analytics/analytics-section-wrapper.tsx new file mode 100644 index 00000000..2a3a17f6 --- /dev/null +++ b/apps/web/core/components/analytics/analytics-section-wrapper.tsx @@ -0,0 +1,30 @@ +import { cn } from "@plane/utils"; + +type Props = { + title?: string; + children: React.ReactNode; + className?: string; + subtitle?: string | null; + actions?: React.ReactNode; + headerClassName?: string; +}; + +const AnalyticsSectionWrapper: React.FC = (props) => { + const { title, children, className, subtitle, actions, headerClassName } = props; + return ( +
    +
    + {title && ( +
    +

    {title}

    + {/* {subtitle &&

    • {subtitle}

    } */} +
    + )} + {actions} +
    + {children} +
    + ); +}; + +export default AnalyticsSectionWrapper; diff --git a/apps/web/core/components/analytics/analytics-wrapper.tsx b/apps/web/core/components/analytics/analytics-wrapper.tsx new file mode 100644 index 00000000..c86edb95 --- /dev/null +++ b/apps/web/core/components/analytics/analytics-wrapper.tsx @@ -0,0 +1,23 @@ +import React from "react"; +// plane package imports +import { useTranslation } from "@plane/i18n"; +import { cn } from "@plane/utils"; + +type Props = { + i18nTitle: string; + children: React.ReactNode; + className?: string; +}; + +const AnalyticsWrapper: React.FC = (props) => { + const { i18nTitle, children, className } = props; + const { t } = useTranslation(); + return ( +
    +

    {t(i18nTitle)}

    + {children} +
    + ); +}; + +export default AnalyticsWrapper; diff --git a/apps/web/core/components/analytics/empty-state.tsx b/apps/web/core/components/analytics/empty-state.tsx new file mode 100644 index 00000000..3704f3e8 --- /dev/null +++ b/apps/web/core/components/analytics/empty-state.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import Image from "next/image"; +// plane package imports +import { cn } from "@plane/utils"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +type Props = { + title: string; + description?: string; + assetPath?: string; + className?: string; +}; + +const AnalyticsEmptyState = ({ title, description, assetPath, className }: Props) => { + const backgroundReolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-grid-background" }); + + return ( +
    +
    + {assetPath && ( +
    + {title} +
    + {title} +
    +
    + )} +
    +

    {title}

    + {description &&

    {description}

    } +
    +
    +
    + ); +}; +export default AnalyticsEmptyState; diff --git a/apps/web/core/components/analytics/export.ts b/apps/web/core/components/analytics/export.ts new file mode 100644 index 00000000..8a830785 --- /dev/null +++ b/apps/web/core/components/analytics/export.ts @@ -0,0 +1,26 @@ +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { download, generateCsv, mkConfig } from "export-to-csv"; + +export const csvConfig = (workspaceSlug: string) => + mkConfig({ + fieldSeparator: ",", + filename: `${workspaceSlug}-analytics`, + decimalSeparator: ".", + useKeysAsHeaders: true, + }); + +export const exportCSV = (rows: Row[], columns: ColumnDef[], workspaceSlug: string) => { + const rowData = rows.map((row) => { + const exportColumns = columns.map((col) => col.meta?.export); + const cells = exportColumns.reduce((acc: Record, col) => { + if (col) { + const cell = col?.value(row) ?? "-"; + acc[col.label ?? col.key] = cell; + } + return acc; + }, {}); + return cells; + }); + const csv = generateCsv(csvConfig(workspaceSlug))(rowData); + download(csvConfig(workspaceSlug))(csv); +}; diff --git a/apps/web/core/components/analytics/insight-card.tsx b/apps/web/core/components/analytics/insight-card.tsx new file mode 100644 index 00000000..115cdd2c --- /dev/null +++ b/apps/web/core/components/analytics/insight-card.tsx @@ -0,0 +1,30 @@ +// plane package imports +import React from "react"; +import type { IAnalyticsResponseFields } from "@plane/types"; +import { Loader } from "@plane/ui"; + +export type InsightCardProps = { + data?: IAnalyticsResponseFields; + label: string; + isLoading?: boolean; +}; + +const InsightCard = (props: InsightCardProps) => { + const { data, label, isLoading = false } = props; + const count = data?.count ?? 0; + + return ( +
    +
    {label}
    + {!isLoading ? ( +
    +
    {count}
    +
    + ) : ( + + )} +
    + ); +}; + +export default InsightCard; diff --git a/apps/web/core/components/analytics/insight-table/data-table.tsx b/apps/web/core/components/analytics/insight-table/data-table.tsx new file mode 100644 index 00000000..be802457 --- /dev/null +++ b/apps/web/core/components/analytics/insight-table/data-table.tsx @@ -0,0 +1,175 @@ +"use client"; + +import * as React from "react"; +import type { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + Table as TanstackTable, +} from "@tanstack/react-table"; +import { + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { Search, X } from "lucide-react"; +// plane package imports +import { useTranslation } from "@plane/i18n"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table"; +import { cn } from "@plane/utils"; +// plane web components +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import AnalyticsEmptyState from "../empty-state"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + searchPlaceholder: string; + actions?: (table: TanstackTable) => React.ReactNode; +} + +export function DataTable({ columns, data, searchPlaceholder, actions }: DataTableProps) { + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [columnFilters, setColumnFilters] = React.useState([]); + const [sorting, setSorting] = React.useState([]); + const { t } = useTranslation(); + const inputRef = React.useRef(null); + const [isSearchOpen, setIsSearchOpen] = React.useState(false); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-table" }); + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + }, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }); + + return ( +
    +
    +
    + {table.getHeaderGroups()?.[0]?.headers?.[0]?.id && ( +
    + {searchPlaceholder} +
    + )} + {!isSearchOpen && ( + + )} +
    + + { + const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id; + if (columnId) table.getColumn(columnId)?.setFilterValue(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + setIsSearchOpen(true); + } + }} + /> + {isSearchOpen && ( + + )} +
    +
    + {actions &&
    {actions(table)}
    } +
    + +
    + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : (flexRender(header.column.columnDef.header, header.getContext()) as any)} + + ))} + + ))} + + + {table.getRowModel().rows?.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext()) as any} + + ))} + + )) + ) : ( + + +
    + +
    +
    +
    + )} +
    +
    +
    +
    + ); +} diff --git a/apps/web/core/components/analytics/insight-table/index.ts b/apps/web/core/components/analytics/insight-table/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/core/components/analytics/insight-table/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/analytics/insight-table/loader.tsx b/apps/web/core/components/analytics/insight-table/loader.tsx new file mode 100644 index 00000000..0ccff9a9 --- /dev/null +++ b/apps/web/core/components/analytics/insight-table/loader.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table"; +import { Loader } from "@plane/ui"; + +interface TableSkeletonProps { + columns: ColumnDef[]; + rows: number; +} + +export const TableLoader: React.FC = ({ columns, rows }) => ( + + + + {columns.map((column, index) => ( + + {typeof column.header === "string" ? column.header : ""} + + ))} + + + + {Array.from({ length: rows }).map((_, rowIndex) => ( + + {columns.map((_, colIndex) => ( + + + + ))} + + ))} + +
    +); diff --git a/apps/web/core/components/analytics/insight-table/root.tsx b/apps/web/core/components/analytics/insight-table/root.tsx new file mode 100644 index 00000000..46535349 --- /dev/null +++ b/apps/web/core/components/analytics/insight-table/root.tsx @@ -0,0 +1,45 @@ +import type { ColumnDef, Row, Table } from "@tanstack/react-table"; +import { Download } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import type { AnalyticsTableDataMap, TAnalyticsTabsBase } from "@plane/types"; +import { DataTable } from "./data-table"; +import { TableLoader } from "./loader"; +interface InsightTableProps> { + analyticsType: T; + data?: AnalyticsTableDataMap[T][]; + isLoading?: boolean; + columns: ColumnDef[]; + columnsLabels?: Record; + headerText: string; + onExport?: (rows: Row[]) => void; +} + +export const InsightTable = >( + props: InsightTableProps +): React.ReactElement => { + const { data, isLoading, columns, headerText, onExport } = props; + const { t } = useTranslation(); + if (isLoading) { + return ; + } + + return ( +
    + ) => ( + + )} + /> +
    + ); +}; diff --git a/apps/web/core/components/analytics/loaders.tsx b/apps/web/core/components/analytics/loaders.tsx new file mode 100644 index 00000000..e35d235c --- /dev/null +++ b/apps/web/core/components/analytics/loaders.tsx @@ -0,0 +1,23 @@ +import { Loader } from "@plane/ui"; + +export const ProjectInsightsLoader = () => ( +
    + + + +
    + + + + + + +
    +
    +); + +export const ChartLoader = () => ( + + + +); diff --git a/apps/web/core/components/analytics/overview/active-project-item.tsx b/apps/web/core/components/analytics/overview/active-project-item.tsx new file mode 100644 index 00000000..b551f494 --- /dev/null +++ b/apps/web/core/components/analytics/overview/active-project-item.tsx @@ -0,0 +1,57 @@ +import { ProjectIcon } from "@plane/propel/icons"; +// plane package imports +import { cn } from "@plane/utils"; +import { Logo } from "@/components/common/logo"; +// plane web hooks +import { useProject } from "@/hooks/store/use-project"; + +type Props = { + project: { + id: string; + completed_issues?: number; + total_issues?: number; + }; + isLoading?: boolean; +}; +const CompletionPercentage = ({ percentage }: { percentage: number }) => { + const percentageColor = percentage > 50 ? "bg-green-500/30 text-green-500" : "bg-red-500/30 text-red-500"; + return ( +
    + {percentage}% +
    + ); +}; + +const ActiveProjectItem = (props: Props) => { + const { project } = props; + const { getProjectById } = useProject(); + const { id, completed_issues, total_issues } = project; + + const projectDetails = getProjectById(id); + + if (!projectDetails) return null; + + return ( +
    +
    +
    + + {projectDetails?.logo_props ? ( + + ) : ( + + + + )} + +
    +

    {projectDetails?.name}

    +
    + +
    + ); +}; + +export default ActiveProjectItem; diff --git a/apps/web/core/components/analytics/overview/active-projects.tsx b/apps/web/core/components/analytics/overview/active-projects.tsx new file mode 100644 index 00000000..3c7016d1 --- /dev/null +++ b/apps/web/core/components/analytics/overview/active-projects.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane package imports +import { useTranslation } from "@plane/i18n"; +import { Loader } from "@plane/ui"; +// plane web hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +import { useProject } from "@/hooks/store/use-project"; +// plane web components +import AnalyticsSectionWrapper from "../analytics-section-wrapper"; +import ActiveProjectItem from "./active-project-item"; + +const ActiveProjects = observer(() => { + const { t } = useTranslation(); + const { fetchProjectAnalyticsCount } = useProject(); + const { workspaceSlug } = useParams(); + const { selectedDurationLabel } = useAnalytics(); + const { data: projectAnalyticsCount, isLoading: isProjectAnalyticsCountLoading } = useSWR( + workspaceSlug ? ["projectAnalyticsCount", workspaceSlug] : null, + workspaceSlug + ? () => + fetchProjectAnalyticsCount(workspaceSlug.toString(), { + fields: "total_work_items,total_completed_work_items", + }) + : null + ); + return ( + +
    + {isProjectAnalyticsCountLoading && + Array.from({ length: 5 }).map((_, index) => )} + {!isProjectAnalyticsCountLoading && + projectAnalyticsCount?.map((project) => )} +
    +
    + ); +}); + +export default ActiveProjects; diff --git a/apps/web/core/components/analytics/overview/index.ts b/apps/web/core/components/analytics/overview/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/core/components/analytics/overview/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/analytics/overview/project-insights.tsx b/apps/web/core/components/analytics/overview/project-insights.tsx new file mode 100644 index 00000000..a72c79b8 --- /dev/null +++ b/apps/web/core/components/analytics/overview/project-insights.tsx @@ -0,0 +1,117 @@ +import { observer } from "mobx-react"; +import dynamic from "next/dynamic"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane package imports +import { useTranslation } from "@plane/i18n"; +import type { TChartData } from "@plane/types"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +// services +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import { AnalyticsService } from "@/services/analytics.service"; +// plane web components +import AnalyticsSectionWrapper from "../analytics-section-wrapper"; +import AnalyticsEmptyState from "../empty-state"; +import { ProjectInsightsLoader } from "../loaders"; + +const RadarChart = dynamic(() => + import("@plane/propel/charts/radar-chart").then((mod) => ({ + default: mod.RadarChart, + })) +); + +const analyticsService = new AnalyticsService(); + +const ProjectInsights = observer(() => { + const params = useParams(); + const { t } = useTranslation(); + const workspaceSlug = params.workspaceSlug.toString(); + const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } = + useAnalytics(); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-radar" }); + + const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR( + `radar-chart-project-insights-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`, + () => + analyticsService.getAdvanceAnalyticsCharts[]>( + workspaceSlug, + "projects", + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + }, + isPeekView + ) + ); + + return ( + + {isLoadingProjectInsight ? ( + + ) : projectInsightsData && projectInsightsData?.length == 0 ? ( + + ) : ( +
    + {projectInsightsData && ( + + )} +
    +
    {t("workspace_analytics.summary_of_projects")}
    +
    {t("workspace_analytics.all_projects")}
    +
    +
    +
    {t("workspace_analytics.trend_on_charts")}
    +
    {t("common.work_items")}
    +
    + {projectInsightsData?.map((item) => ( +
    +
    {item.name}
    +
    + {/* */} +
    {item.count}
    +
    +
    + ))} +
    +
    +
    + )} +
    + ); +}); + +export default ProjectInsights; diff --git a/apps/web/core/components/analytics/overview/root.tsx b/apps/web/core/components/analytics/overview/root.tsx new file mode 100644 index 00000000..b10bf32d --- /dev/null +++ b/apps/web/core/components/analytics/overview/root.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import AnalyticsWrapper from "../analytics-wrapper"; +import TotalInsights from "../total-insights"; +import ActiveProjects from "./active-projects"; +import ProjectInsights from "./project-insights"; + +const Overview: React.FC = () => ( + +
    + +
    + + +
    +
    +
    +); + +export { Overview }; diff --git a/apps/web/core/components/analytics/select/analytics-params.tsx b/apps/web/core/components/analytics/select/analytics-params.tsx new file mode 100644 index 00000000..5e9535da --- /dev/null +++ b/apps/web/core/components/analytics/select/analytics-params.tsx @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import { observer } from "mobx-react"; +import type { Control, UseFormSetValue } from "react-hook-form"; +import { Controller } from "react-hook-form"; +import { Calendar, SlidersHorizontal } from "lucide-react"; +// plane package imports +import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@plane/constants"; +import type { IAnalyticsParams } from "@plane/types"; +import { ChartYAxisMetric } from "@plane/types"; +import { cn } from "@plane/utils"; +// plane web components +import { SelectXAxis } from "./select-x-axis"; +import { SelectYAxis } from "./select-y-axis"; + +type Props = { + control: Control; + setValue: UseFormSetValue; + params: IAnalyticsParams; + workspaceSlug: string; + classNames?: string; + isEpic?: boolean; +}; + +export const AnalyticsSelectParams: React.FC = observer((props) => { + const { control, params, classNames, isEpic } = props; + const xAxisOptions = useMemo( + () => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.group_by), + [params.group_by] + ); + const groupByOptions = useMemo( + () => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.x_axis), + [params.x_axis] + ); + + return ( +
    +
    + ( + { + onChange(val); + }} + options={ANALYTICS_Y_AXIS_VALUES} + hiddenOptions={[ + ChartYAxisMetric.ESTIMATE_POINT_COUNT, + isEpic ? ChartYAxisMetric.WORK_ITEM_COUNT : ChartYAxisMetric.EPIC_WORK_ITEM_COUNT, + ]} + /> + )} + /> + ( + { + onChange(val); + }} + label={ +
    + + + {xAxisOptions.find((v) => v.value === value)?.label || "Add Property"} + +
    + } + options={xAxisOptions} + /> + )} + /> + ( + { + onChange(val); + }} + label={ +
    + + + {groupByOptions.find((v) => v.value === value)?.label || "Add Property"} + +
    + } + options={groupByOptions} + placeholder="Group By" + allowNoValue + /> + )} + /> +
    +
    + ); +}); diff --git a/apps/web/core/components/analytics/select/duration.tsx b/apps/web/core/components/analytics/select/duration.tsx new file mode 100644 index 00000000..f668f3c4 --- /dev/null +++ b/apps/web/core/components/analytics/select/duration.tsx @@ -0,0 +1,51 @@ +// plane package imports +import type { ReactNode } from "react"; +import React from "react"; +import { Calendar } from "lucide-react"; +// plane package imports +import { ANALYTICS_DURATION_FILTER_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { CustomSearchSelect } from "@plane/ui"; +// types +import type { TDropdownProps } from "@/components/dropdowns/types"; + +type Props = TDropdownProps & { + value: string | null; + onChange: (val: (typeof ANALYTICS_DURATION_FILTER_OPTIONS)[number]["value"]) => void; + //optional + button?: ReactNode; + dropdownArrow?: boolean; + dropdownArrowClassName?: string; + onClose?: () => void; + renderByDefault?: boolean; + tabIndex?: number; +}; + +function DurationDropdown({ placeholder = "Duration", onChange, value }: Props) { + useTranslation(); + + const options = ANALYTICS_DURATION_FILTER_OPTIONS.map((option) => ({ + value: option.value, + query: option.name, + content: ( +
    + {option.name} +
    + ), + })); + return ( + + + {value ? ANALYTICS_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name : placeholder} +
    + } + /> + ); +} + +export default DurationDropdown; diff --git a/apps/web/core/components/analytics/select/project.tsx b/apps/web/core/components/analytics/select/project.tsx new file mode 100644 index 00000000..aee30902 --- /dev/null +++ b/apps/web/core/components/analytics/select/project.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { observer } from "mobx-react"; +import { ProjectIcon } from "@plane/propel/icons"; +// plane package imports +import { CustomSearchSelect } from "@plane/ui"; +// components +import { Logo } from "@/components/common/logo"; +// hooks +import { useProject } from "@/hooks/store/use-project"; + +type Props = { + value: string[] | undefined; + onChange: (val: string[] | null) => void; + projectIds: string[] | undefined; +}; + +export const ProjectSelect: React.FC = observer((props) => { + const { value, onChange, projectIds } = props; + const { getProjectById } = useProject(); + + const options = projectIds?.map((projectId) => { + const projectDetails = getProjectById(projectId); + + return { + value: projectDetails?.id, + query: `${projectDetails?.name} ${projectDetails?.identifier}`, + content: ( +
    + {projectDetails?.logo_props ? ( + + ) : ( + + )} + {projectDetails?.name} +
    + ), + }; + }); + + return ( + onChange(val)} + options={options} + label={ +
    + + {value && value.length > 3 + ? `3+ projects` + : value && value.length > 0 + ? projectIds + ?.filter((p) => value.includes(p)) + .map((p) => getProjectById(p)?.name) + .join(", ") + : "All projects"} +
    + } + multiple + /> + ); +}); diff --git a/apps/web/core/components/analytics/select/select-x-axis.tsx b/apps/web/core/components/analytics/select/select-x-axis.tsx new file mode 100644 index 00000000..041fc8fe --- /dev/null +++ b/apps/web/core/components/analytics/select/select-x-axis.tsx @@ -0,0 +1,31 @@ +"use client"; +// plane package imports +import type { ChartXAxisProperty } from "@plane/types"; +import { CustomSelect } from "@plane/ui"; + +type Props = { + value?: ChartXAxisProperty; + onChange: (val: ChartXAxisProperty | null) => void; + options: { value: ChartXAxisProperty; label: string }[]; + placeholder?: string; + hiddenOptions?: ChartXAxisProperty[]; + allowNoValue?: boolean; + label?: string | React.ReactNode; +}; + +export const SelectXAxis: React.FC = (props) => { + const { value, onChange, options, hiddenOptions, allowNoValue, label } = props; + return ( + + {allowNoValue && No value} + {options.map((item) => { + if (hiddenOptions?.includes(item.value)) return null; + return ( + + {item.label} + + ); + })} + + ); +}; diff --git a/apps/web/core/components/analytics/select/select-y-axis.tsx b/apps/web/core/components/analytics/select/select-y-axis.tsx new file mode 100644 index 00000000..2295f063 --- /dev/null +++ b/apps/web/core/components/analytics/select/select-y-axis.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { EEstimateSystem } from "@plane/constants"; +import { ProjectIcon } from "@plane/propel/icons"; +import type { ChartYAxisMetric } from "@plane/types"; +// plane package imports +import { CustomSelect } from "@plane/ui"; +// hooks +import { useProjectEstimates } from "@/hooks/store/estimates"; +// plane web constants +type Props = { + value: ChartYAxisMetric; + onChange: (val: ChartYAxisMetric | null) => void; + hiddenOptions?: ChartYAxisMetric[]; + options: { value: ChartYAxisMetric; label: string }[]; +}; + +export const SelectYAxis: React.FC = observer(({ value, onChange, hiddenOptions, options }) => { + // hooks + const { projectId } = useParams(); + const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); + + const isEstimateEnabled = (analyticsOption: string) => { + if (analyticsOption === "estimate") { + if ( + projectId && + currentActiveEstimateId && + areEstimateEnabledByProjectId(projectId.toString()) && + estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS + ) { + return true; + } else { + return false; + } + } + + return true; + }; + + return ( + + + {options.find((v) => v.value === value)?.label ?? "Add Metric"} +
    + } + onChange={onChange} + maxHeight="lg" + > + {options.map((item) => { + if (hiddenOptions?.includes(item.value)) return null; + return ( + isEstimateEnabled(item.value) && ( + + {item.label} + + ) + ); + })} + + ); +}); diff --git a/apps/web/core/components/analytics/total-insights.tsx b/apps/web/core/components/analytics/total-insights.tsx new file mode 100644 index 00000000..5dfc6bb9 --- /dev/null +++ b/apps/web/core/components/analytics/total-insights.tsx @@ -0,0 +1,94 @@ +// plane package imports +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +import type { IInsightField } from "@plane/constants"; +import { ANALYTICS_INSIGHTS_FIELDS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { IAnalyticsResponse, TAnalyticsTabsBase } from "@plane/types"; +import { cn } from "@plane/utils"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +// services +import { AnalyticsService } from "@/services/analytics.service"; +// local imports +import InsightCard from "./insight-card"; + +const analyticsService = new AnalyticsService(); + +const getInsightLabel = ( + analyticsType: TAnalyticsTabsBase, + item: IInsightField, + isEpic: boolean | undefined, + t: (key: string, params?: Record) => string +) => { + if (analyticsType === "work-items") { + return isEpic + ? t(item.i18nKey, { entity: t("common.epics") }) + : t(item.i18nKey, { entity: t("common.work_items") }); + } + + // Get the base translation with entity + const baseTranslation = t(item.i18nKey, { + ...item.i18nProps, + entity: item.i18nProps?.entity && t(item.i18nProps?.entity), + }); + + // Add prefix if available + const prefix = item.i18nProps?.prefix ? `${t(item.i18nProps.prefix)} ` : ""; + + // Add suffix if available + const suffix = item.i18nProps?.suffix ? ` ${t(item.i18nProps.suffix)}` : ""; + + // Combine prefix, base translation, and suffix + return `${prefix}${baseTranslation}${suffix}`; +}; + +const TotalInsights: React.FC<{ + analyticsType: TAnalyticsTabsBase; + peekView?: boolean; +}> = observer(({ analyticsType, peekView }) => { + const params = useParams(); + const workspaceSlug = params.workspaceSlug.toString(); + const { t } = useTranslation(); + const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics(); + const { data: totalInsightsData, isLoading } = useSWR( + `total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`, + () => + analyticsService.getAdvanceAnalytics( + workspaceSlug, + analyticsType, + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isEpic ? { epic: true } : {}), + }, + isPeekView + ) + ); + return ( +
    + {ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.map((item) => ( + + ))} +
    + ); +}); + +export default TotalInsights; diff --git a/apps/web/core/components/analytics/trend-piece.tsx b/apps/web/core/components/analytics/trend-piece.tsx new file mode 100644 index 00000000..60062222 --- /dev/null +++ b/apps/web/core/components/analytics/trend-piece.tsx @@ -0,0 +1,80 @@ +// plane package imports +import React from "react"; +import { TrendingDown, TrendingUp } from "lucide-react"; +import { cn } from "@plane/utils"; +// plane web components + +type Props = { + percentage: number; + className?: string; + size?: "xs" | "sm" | "md" | "lg"; + trendIconVisible?: boolean; + variant?: "simple" | "outlined" | "tinted"; +}; + +const sizeConfig = { + xs: { + text: "text-xs", + icon: "w-3 h-3", + }, + sm: { + text: "text-sm", + icon: "w-4 h-4", + }, + md: { + text: "text-base", + icon: "w-5 h-5", + }, + lg: { + text: "text-lg", + icon: "w-6 h-6", + }, +} as const; + +const variants: Record, Record<"ontrack" | "offtrack" | "atrisk", string>> = { + simple: { + ontrack: "text-green-500", + offtrack: "text-yellow-500", + atrisk: "text-red-500", + }, + outlined: { + ontrack: "text-green-500 border border-green-500", + offtrack: "text-yellow-500 border border-yellow-500", + atrisk: "text-red-500 border border-red-500", + }, + tinted: { + ontrack: "text-green-500 bg-green-500/10", + offtrack: "text-yellow-500 bg-yellow-500/10", + atrisk: "text-red-500 bg-red-500/10", + }, +} as const; + +const TrendPiece = (props: Props) => { + const { percentage, className, trendIconVisible = true, size = "sm", variant = "simple" } = props; + const isOnTrack = percentage >= 66; + const isOffTrack = percentage >= 33 && percentage < 66; + const config = sizeConfig[size]; + + return ( +
    + {trendIconVisible && + (isOnTrack ? ( + + ) : isOffTrack ? ( + + ) : ( + + ))} + {Math.round(Math.abs(percentage))}% +
    + ); +}; + +export default TrendPiece; diff --git a/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx b/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx new file mode 100644 index 00000000..56c7bbd8 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx @@ -0,0 +1,135 @@ +import { useMemo } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane package imports +import { useTranslation } from "@plane/i18n"; +import { AreaChart } from "@plane/propel/charts/area-chart"; +import type { IChartResponse, TChartData } from "@plane/types"; +import { renderFormattedDate } from "@plane/utils"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +// services +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import { AnalyticsService } from "@/services/analytics.service"; +// plane web components +import AnalyticsSectionWrapper from "../analytics-section-wrapper"; +import AnalyticsEmptyState from "../empty-state"; +import { ChartLoader } from "../loaders"; + +const analyticsService = new AnalyticsService(); +const CreatedVsResolved = observer(() => { + const { + selectedDuration, + selectedDurationLabel, + selectedProjects, + selectedCycle, + selectedModule, + isPeekView, + isEpic, + } = useAnalytics(); + const params = useParams(); + const { t } = useTranslation(); + const workspaceSlug = params.workspaceSlug.toString(); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-area" }); + const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR( + `created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}-${isEpic}`, + () => + analyticsService.getAdvanceAnalyticsCharts( + workspaceSlug, + "work-items", + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isEpic ? { epic: true } : {}), + }, + isPeekView + ) + ); + const parsedData: TChartData[] = useMemo(() => { + if (!createdVsResolvedData?.data) return []; + return createdVsResolvedData.data.map((datum) => ({ + ...datum, + [datum.key]: datum.count, + name: renderFormattedDate(datum.key) ?? datum.key, + })); + }, [createdVsResolvedData]); + + const areas = useMemo( + () => [ + { + key: "completed_issues", + label: "Resolved", + fill: "#19803833", + fillOpacity: 1, + stackId: "bar-one", + showDot: false, + smoothCurves: true, + strokeColor: "#198038", + strokeOpacity: 1, + }, + { + key: "created_issues", + label: "Created", + fill: "#1192E833", + fillOpacity: 1, + stackId: "bar-one", + showDot: false, + smoothCurves: true, + strokeColor: "#1192E8", + strokeOpacity: 1, + }, + ], + [] + ); + + return ( + + {isCreatedVsResolvedLoading ? ( + + ) : parsedData && parsedData.length > 0 ? ( + + ) : ( + + )} + + ); +}); + +export default CreatedVsResolved; diff --git a/apps/web/core/components/analytics/work-items/customized-insights.tsx b/apps/web/core/components/analytics/work-items/customized-insights.tsx new file mode 100644 index 00000000..22c698b0 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/customized-insights.tsx @@ -0,0 +1,50 @@ +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { useForm } from "react-hook-form"; +// plane package imports +import { useTranslation } from "@plane/i18n"; +import type { IAnalyticsParams } from "@plane/types"; +import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; +import { cn } from "@plane/utils"; +// plane web components +import AnalyticsSectionWrapper from "../analytics-section-wrapper"; +import { AnalyticsSelectParams } from "../select/analytics-params"; +import PriorityChart from "./priority-chart"; + +const CustomizedInsights = observer(({ peekView, isEpic }: { peekView?: boolean; isEpic?: boolean }) => { + const { t } = useTranslation(); + const { workspaceSlug } = useParams(); + const { control, watch, setValue } = useForm({ + defaultValues: { + x_axis: ChartXAxisProperty.PRIORITY, + y_axis: isEpic ? ChartYAxisMetric.EPIC_WORK_ITEM_COUNT : ChartYAxisMetric.WORK_ITEM_COUNT, + }, + }); + + const params = { + x_axis: watch("x_axis"), + y_axis: watch("y_axis"), + group_by: watch("group_by"), + }; + + return ( + + } + > + + + ); +}); + +export default CustomizedInsights; diff --git a/apps/web/core/components/analytics/work-items/index.ts b/apps/web/core/components/analytics/work-items/index.ts new file mode 100644 index 00000000..1efe34c5 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/analytics/work-items/modal/content.tsx b/apps/web/core/components/analytics/work-items/modal/content.tsx new file mode 100644 index 00000000..d13de2a7 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/modal/content.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { Tab } from "@headlessui/react"; +// plane package imports +import type { ICycle, IModule, IProject } from "@plane/types"; +import { Spinner } from "@plane/ui"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +// plane web components +import TotalInsights from "../../total-insights"; +import CreatedVsResolved from "../created-vs-resolved"; +import CustomizedInsights from "../customized-insights"; +import WorkItemsInsightTable from "../workitems-insight-table"; + +type Props = { + fullScreen: boolean; + projectDetails: IProject | undefined; + cycleDetails: ICycle | undefined; + moduleDetails: IModule | undefined; + isEpic?: boolean; +}; + +export const WorkItemsModalMainContent: React.FC = observer((props) => { + const { projectDetails, cycleDetails, moduleDetails, fullScreen, isEpic } = props; + const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalytics(); + const [isModalConfigured, setIsModalConfigured] = useState(false); + + useEffect(() => { + updateIsPeekView(true); + + // Handle project selection + if (projectDetails?.id) { + updateSelectedProjects([projectDetails.id]); + } + + // Handle cycle selection + if (cycleDetails?.id) { + updateSelectedCycle(cycleDetails.id); + } + + // Handle module selection + if (moduleDetails?.id) { + updateSelectedModule(moduleDetails.id); + } + setIsModalConfigured(true); + + // Cleanup fields + return () => { + updateSelectedProjects([]); + updateSelectedCycle(""); + updateSelectedModule(""); + updateIsPeekView(false); + }; + }, [ + projectDetails?.id, + cycleDetails?.id, + moduleDetails?.id, + updateSelectedProjects, + updateSelectedCycle, + updateSelectedModule, + updateIsPeekView, + ]); + + if (!isModalConfigured) + return ( +
    + +
    + ); + + return ( + +
    + + + + +
    +
    + ); +}); diff --git a/apps/web/core/components/analytics/work-items/modal/header.tsx b/apps/web/core/components/analytics/work-items/modal/header.tsx new file mode 100644 index 00000000..734eebbf --- /dev/null +++ b/apps/web/core/components/analytics/work-items/modal/header.tsx @@ -0,0 +1,42 @@ +import { observer } from "mobx-react"; +// plane package imports +import { Expand, Shrink, X } from "lucide-react"; +import type { ICycle, IModule } from "@plane/types"; +// icons + +type Props = { + fullScreen: boolean; + handleClose: () => void; + setFullScreen: React.Dispatch>; + title: string; + cycle?: ICycle; + module?: IModule; +}; + +export const WorkItemsModalHeader: React.FC = observer((props) => { + const { fullScreen, handleClose, setFullScreen, title, cycle, module } = props; + + return ( +
    +

    + Analytics for {title} {cycle && `in ${cycle.name}`} {module && `in ${module.name}`} +

    +
    + + +
    +
    + ); +}); diff --git a/apps/web/core/components/analytics/work-items/modal/index.tsx b/apps/web/core/components/analytics/work-items/modal/index.tsx new file mode 100644 index 00000000..129dd5f5 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/modal/index.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +// plane package imports +import { ModalPortal, EPortalWidth, EPortalPosition } from "@plane/propel/portal"; +import type { ICycle, IModule, IProject } from "@plane/types"; +import { useAnalytics } from "@/hooks/store/use-analytics"; +// plane web components +import { WorkItemsModalMainContent } from "./content"; +import { WorkItemsModalHeader } from "./header"; + +type Props = { + isOpen: boolean; + onClose: () => void; + projectDetails?: IProject | undefined; + cycleDetails?: ICycle | undefined; + moduleDetails?: IModule | undefined; + isEpic?: boolean; +}; + +export const WorkItemsModal: React.FC = observer((props) => { + const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails, isEpic } = props; + const { updateIsEpic, isPeekView } = useAnalytics(); + const [fullScreen, setFullScreen] = useState(false); + + const handleClose = () => { + setFullScreen(false); + onClose(); + }; + + useEffect(() => { + updateIsEpic(isPeekView ? (isEpic ?? false) : false); + }, [isEpic, updateIsEpic, isPeekView]); + + return ( + +
    + + +
    +
    + ); +}); diff --git a/apps/web/core/components/analytics/work-items/priority-chart.tsx b/apps/web/core/components/analytics/work-items/priority-chart.tsx new file mode 100644 index 00000000..f77248d4 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/priority-chart.tsx @@ -0,0 +1,246 @@ +import { useMemo } from "react"; +import type { ColumnDef, Row, RowData, Table } from "@tanstack/react-table"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +// plane package imports +import { Download } from "lucide-react"; +import type { ChartXAxisDateGrouping } from "@plane/constants"; +import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, CHART_COLOR_PALETTES, EChartModels } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { BarChart } from "@plane/propel/charts/bar-chart"; +import type { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; +// plane web components +import { generateExtendedColors, parseChartData } from "@/components/chart/utils"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +import { useProjectState } from "@/hooks/store/use-project-state"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import { AnalyticsService } from "@/services/analytics.service"; +import AnalyticsEmptyState from "../empty-state"; +import { exportCSV } from "../export"; +import { DataTable } from "../insight-table/data-table"; +import { ChartLoader } from "../loaders"; +import { generateBarColor } from "./utils"; + +declare module "@tanstack/react-table" { + interface ColumnMeta { + export: { + key: string; + value: (row: Row) => string | number; + label?: string; + }; + } +} + +interface Props { + x_axis: ChartXAxisProperty; + y_axis: ChartYAxisMetric; + group_by?: ChartXAxisProperty; + x_axis_date_grouping?: ChartXAxisDateGrouping; +} + +const analyticsService = new AnalyticsService(); +const PriorityChart = observer((props: Props) => { + const { x_axis, y_axis, group_by } = props; + const { t } = useTranslation(); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-bar" }); + // store hooks + const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics(); + const { workspaceStates } = useProjectState(); + const { resolvedTheme } = useTheme(); + // router + const params = useParams(); + const workspaceSlug = params.workspaceSlug.toString(); + + const { data: priorityChartData, isLoading: priorityChartLoading } = useSWR( + `customized-insights-chart-${workspaceSlug}-${selectedDuration}- + ${selectedProjects}-${selectedCycle}-${selectedModule}-${props.x_axis}-${props.y_axis}-${props.group_by}-${isPeekView}-${isEpic}`, + () => + analyticsService.getAdvanceAnalyticsCharts( + workspaceSlug, + "custom-work-items", + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isEpic ? { epic: true } : {}), + ...props, + }, + isPeekView + ) + ); + const parsedData = useMemo( + () => + priorityChartData && parseChartData(priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping), + [priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping] + ); + const chart_model = props.group_by ? EChartModels.STACKED : EChartModels.BASIC; + + const bars: TBarItem[] = useMemo(() => { + if (!parsedData) return []; + let parsedBars: TBarItem[]; + const schemaKeys = Object.keys(parsedData.schema); + const baseColors = CHART_COLOR_PALETTES[0]?.[resolvedTheme === "dark" ? "dark" : "light"]; + const extendedColors = generateExtendedColors(baseColors ?? [], schemaKeys.length); + if (chart_model === EChartModels.BASIC) { + parsedBars = [ + { + key: "count", + label: "Count", + stackId: "bar-one", + fill: (payload) => generateBarColor(payload.key, { x_axis, y_axis, group_by }, baseColors, workspaceStates), + textClassName: "", + showPercentage: false, + showTopBorderRadius: () => true, + showBottomBorderRadius: () => true, + }, + ]; + } else if (chart_model === EChartModels.STACKED && parsedData.schema) { + const parsedExtremes: { + [key: string]: { + top: string | null; + bottom: string | null; + }; + } = {}; + parsedData.data.forEach((datum) => { + let top = null; + let bottom = null; + for (let i = 0; i < schemaKeys.length; i++) { + const key = schemaKeys[i]; + if (datum[key] === 0) continue; + if (!bottom) bottom = key; + top = key; + } + parsedExtremes[datum.key] = { top, bottom }; + }); + + parsedBars = schemaKeys.map((key, index) => ({ + key: key, + label: parsedData.schema[key], + stackId: "bar-one", + fill: extendedColors[index], + textClassName: "", + showPercentage: false, + showTopBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].top === value, + showBottomBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].bottom === value, + })); + } else { + parsedBars = []; + } + return parsedBars; + }, [chart_model, group_by, parsedData, resolvedTheme, workspaceStates, x_axis, y_axis]); + + const yAxisLabel = useMemo( + () => ANALYTICS_Y_AXIS_VALUES.find((item) => item.value === props.y_axis)?.label ?? props.y_axis, + [props.y_axis] + ); + const xAxisLabel = useMemo( + () => ANALYTICS_X_AXIS_VALUES.find((item) => item.value === props.x_axis)?.label ?? props.x_axis, + [props.x_axis] + ); + + const defaultColumns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "name", + header: () => xAxisLabel, + meta: { + export: { + key: xAxisLabel, + value: (row) => row.original.name, + label: xAxisLabel, + }, + }, + }, + { + accessorKey: "count", + header: () =>
    Count
    , + cell: ({ row }) =>
    {row.original.count}
    , + meta: { + export: { + key: "Count", + value: (row) => row.original.count, + label: "Count", + }, + }, + }, + ], + [xAxisLabel] + ); + + const columns: ColumnDef[] = useMemo( + () => + parsedData + ? Object.keys(parsedData?.schema ?? {}).map((key) => ({ + accessorKey: key, + header: () =>
    {parsedData.schema[key]}
    , + cell: ({ row }) =>
    {row.original[key]}
    , + meta: { + export: { + key, + value: (row) => row.original[key], + label: parsedData.schema[key], + }, + }, + })) + : [], + [parsedData] + ); + + return ( +
    + {priorityChartLoading ? ( + + ) : parsedData?.data && parsedData.data.length > 0 ? ( + <> + + ) => ( + + )} + /> + + ) : ( + + )} +
    + ); +}); + +export default PriorityChart; diff --git a/apps/web/core/components/analytics/work-items/root.tsx b/apps/web/core/components/analytics/work-items/root.tsx new file mode 100644 index 00000000..c30a36d5 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/root.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import AnalyticsWrapper from "../analytics-wrapper"; +import TotalInsights from "../total-insights"; +import CreatedVsResolved from "./created-vs-resolved"; +import CustomizedInsights from "./customized-insights"; +import WorkItemsInsightTable from "./workitems-insight-table"; + +const WorkItems: React.FC = () => ( + +
    + + + + +
    +
    +); + +export { WorkItems }; diff --git a/apps/web/core/components/analytics/work-items/utils.ts b/apps/web/core/components/analytics/work-items/utils.ts new file mode 100644 index 00000000..613fa6b6 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/utils.ts @@ -0,0 +1,47 @@ +// plane package imports +import type { ChartYAxisMetric, IState } from "@plane/types"; +import { ChartXAxisProperty } from "@plane/types"; + +interface ParamsProps { + x_axis: ChartXAxisProperty; + y_axis: ChartYAxisMetric; + group_by?: ChartXAxisProperty; +} + +export const generateBarColor = ( + value: string | null | undefined, + params: ParamsProps, + baseColors: string[], + workspaceStates?: IState[] +): string => { + if (!value) return baseColors[0]; + let color = baseColors[0]; + // Priority + if (params.x_axis === ChartXAxisProperty.PRIORITY) { + color = + value === "urgent" + ? "#ef4444" + : value === "high" + ? "#f97316" + : value === "medium" + ? "#eab308" + : value === "low" + ? "#22c55e" + : "#ced4da"; + } + + // State + if (params.x_axis === ChartXAxisProperty.STATES) { + if (workspaceStates && workspaceStates.length > 0) { + const state = workspaceStates.find((s) => s.id === value); + if (state) { + color = state.color; + } else { + const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % baseColors.length; + color = baseColors[index]; + } + } + } + + return color; +}; diff --git a/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx b/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx new file mode 100644 index 00000000..0d0d69cb --- /dev/null +++ b/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx @@ -0,0 +1,205 @@ +import { useMemo } from "react"; +import type { ColumnDef, Row, RowData } from "@tanstack/react-table"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +import { UserRound } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +import { ProjectIcon } from "@plane/propel/icons"; +// plane package imports +import type { AnalyticsTableDataMap, WorkItemInsightColumns } from "@plane/types"; +// plane web components +import { Avatar } from "@plane/ui"; +import { getFileURL } from "@plane/utils"; +import { Logo } from "@/components/common/logo"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +import { useProject } from "@/hooks/store/use-project"; +import { AnalyticsService } from "@/services/analytics.service"; +// plane web components +import { exportCSV } from "../export"; +import { InsightTable } from "../insight-table"; + +const analyticsService = new AnalyticsService(); + +declare module "@tanstack/react-table" { + interface ColumnMeta { + export: { + key: string; + value: (row: Row) => string | number; + label?: string; + }; + } +} + +const WorkItemsInsightTable = observer(() => { + // router + const params = useParams(); + const workspaceSlug = params.workspaceSlug.toString(); + const { t } = useTranslation(); + // store hooks + const { getProjectById } = useProject(); + const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics(); + const { data: workItemsData, isLoading } = useSWR( + `insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}-${isEpic}`, + () => + analyticsService.getAdvanceAnalyticsStats( + workspaceSlug, + "work-items", + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isEpic ? { epic: true } : {}), + }, + isPeekView + ) + ); + // derived values + const columnsLabels: Record, string> = + useMemo( + () => ({ + backlog_work_items: t("workspace_projects.state.backlog"), + started_work_items: t("workspace_projects.state.started"), + un_started_work_items: t("workspace_projects.state.unstarted"), + completed_work_items: t("workspace_projects.state.completed"), + cancelled_work_items: t("workspace_projects.state.cancelled"), + project__name: t("common.project"), + display_name: t("common.assignee"), + }), + [t] + ); + const columns: ColumnDef[] = useMemo( + () => [ + !isPeekView + ? { + accessorKey: "project__name", + header: () =>
    {columnsLabels["project__name"]}
    , + cell: ({ row }) => { + const project = getProjectById(row.original.project_id); + return ( +
    + {project?.logo_props ? ( + + ) : ( + + )} + {project?.name} +
    + ); + }, + meta: { + export: { + key: columnsLabels["project__name"], + value: (row) => row.original.project__name?.toString() ?? "", + }, + }, + } + : { + accessorKey: "display_name", + header: () =>
    {columnsLabels["display_name"]}
    , + cell: ({ row }: { row: Row }) => ( +
    +
    + {row.original.avatar_url && row.original.avatar_url !== "" ? ( + + ) : ( +
    + {row.original.display_name ? ( + row.original.display_name?.[0] + ) : ( + + )} +
    + )} + + {row.original.display_name ?? t(`Unassigned`)} + +
    +
    + ), + meta: { + export: { + key: columnsLabels["display_name"], + value: (row) => row.original.display_name?.toString() ?? "", + }, + }, + }, + { + accessorKey: "backlog_work_items", + header: () =>
    {columnsLabels["backlog_work_items"]}
    , + cell: ({ row }) =>
    {row.original.backlog_work_items}
    , + meta: { + export: { + key: columnsLabels["backlog_work_items"], + value: (row) => row.original.backlog_work_items.toString(), + }, + }, + }, + { + accessorKey: "started_work_items", + header: () =>
    {columnsLabels["started_work_items"]}
    , + cell: ({ row }) =>
    {row.original.started_work_items}
    , + meta: { + export: { + key: columnsLabels["started_work_items"], + value: (row) => row.original.started_work_items.toString(), + }, + }, + }, + { + accessorKey: "un_started_work_items", + header: () =>
    {columnsLabels["un_started_work_items"]}
    , + cell: ({ row }) =>
    {row.original.un_started_work_items}
    , + meta: { + export: { + key: columnsLabels["un_started_work_items"], + value: (row) => row.original.un_started_work_items.toString(), + }, + }, + }, + { + accessorKey: "completed_work_items", + header: () =>
    {columnsLabels["completed_work_items"]}
    , + cell: ({ row }) =>
    {row.original.completed_work_items}
    , + meta: { + export: { + key: columnsLabels["completed_work_items"], + value: (row) => row.original.completed_work_items.toString(), + }, + }, + }, + { + accessorKey: "cancelled_work_items", + header: () =>
    {columnsLabels["cancelled_work_items"]}
    , + cell: ({ row }) =>
    {row.original.cancelled_work_items}
    , + meta: { + export: { + key: columnsLabels["cancelled_work_items"], + value: (row) => row.original.cancelled_work_items.toString(), + }, + }, + }, + ], + [columnsLabels, getProjectById, isPeekView, t] + ); + return ( + + analyticsType="work-items" + data={workItemsData} + isLoading={isLoading} + columns={columns} + columnsLabels={columnsLabels} + headerText={isPeekView ? t("common.assignee") : t("common.projects")} + onExport={(rows) => workItemsData && exportCSV(rows, columns, workspaceSlug)} + /> + ); +}); + +export default WorkItemsInsightTable; diff --git a/apps/web/core/components/api-token/delete-token-modal.tsx b/apps/web/core/components/api-token/delete-token-modal.tsx new file mode 100644 index 00000000..22b5f5dd --- /dev/null +++ b/apps/web/core/components/api-token/delete-token-modal.tsx @@ -0,0 +1,93 @@ +"use client"; + +import type { FC } from "react"; +import { useState } from "react"; +import { mutate } from "swr"; +// types +import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { APITokenService } from "@plane/services"; +import type { IApiToken } from "@plane/types"; +// ui +import { AlertModalCore } from "@plane/ui"; +// fetch-keys +import { API_TOKENS_LIST } from "@/constants/fetch-keys"; +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; + +type Props = { + isOpen: boolean; + onClose: () => void; + tokenId: string; +}; + +const apiTokenService = new APITokenService(); + +export const DeleteApiTokenModal: FC = (props) => { + const { isOpen, onClose, tokenId } = props; + // states + const [deleteLoading, setDeleteLoading] = useState(false); + // router params + const { t } = useTranslation(); + + const handleClose = () => { + onClose(); + setDeleteLoading(false); + }; + + const handleDeletion = async () => { + setDeleteLoading(true); + + await apiTokenService + .destroy(tokenId) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("workspace_settings.settings.api_tokens.delete.success.title"), + message: t("workspace_settings.settings.api_tokens.delete.success.message"), + }); + + mutate( + API_TOKENS_LIST, + (prevData) => (prevData ?? []).filter((token) => token.id !== tokenId), + false + ); + captureSuccess({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted, + payload: { + token: tokenId, + }, + }); + + handleClose(); + }) + .catch((err) => + setToast({ + type: TOAST_TYPE.ERROR, + title: t("workspace_settings.settings.api_tokens.delete.error.title"), + message: err?.message ?? t("workspace_settings.settings.api_tokens.delete.error.message"), + }) + ) + .catch((err) => { + captureError({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted, + payload: { + token: tokenId, + }, + error: err as Error, + }); + }) + .finally(() => setDeleteLoading(false)); + }; + + return ( + {t("workspace_settings.settings.api_tokens.delete.description")} } + /> + ); +}; diff --git a/apps/web/core/components/api-token/empty-state.tsx b/apps/web/core/components/api-token/empty-state.tsx new file mode 100644 index 00000000..194966df --- /dev/null +++ b/apps/web/core/components/api-token/empty-state.tsx @@ -0,0 +1,33 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +// ui +import { Button } from "@plane/propel/button"; +// assets +import emptyApiTokens from "@/public/empty-state/api-token.svg"; + +type Props = { + onClick: () => void; +}; + +export const ApiTokenEmptyState: React.FC = (props) => { + const { onClick } = props; + + return ( +
    +
    + empty +
    No API tokens
    +

    + Create API tokens for safe and easy data sharing with external apps, maintaining control and security. +

    + +
    +
    + ); +}; diff --git a/apps/web/core/components/api-token/modal/create-token-modal.tsx b/apps/web/core/components/api-token/modal/create-token-modal.tsx new file mode 100644 index 00000000..ac27873e --- /dev/null +++ b/apps/web/core/components/api-token/modal/create-token-modal.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React, { useState } from "react"; +import { mutate } from "swr"; +// plane imports +import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { APITokenService } from "@plane/services"; +import type { IApiToken } from "@plane/types"; +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; +import { renderFormattedDate, csvDownload } from "@plane/utils"; +// constants +import { API_TOKENS_LIST } from "@/constants/fetch-keys"; +// helpers +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +// local imports +import { CreateApiTokenForm } from "./form"; +import { GeneratedTokenDetails } from "./generated-token-details"; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +// services +const apiTokenService = new APITokenService(); + +export const CreateApiTokenModal: React.FC = (props) => { + const { isOpen, onClose } = props; + // states + const [neverExpires, setNeverExpires] = useState(false); + const [generatedToken, setGeneratedToken] = useState(null); + + const handleClose = () => { + onClose(); + + setTimeout(() => { + setNeverExpires(false); + setGeneratedToken(null); + }, 350); + }; + + const downloadSecretKey = (data: IApiToken) => { + const csvData = { + Title: data.label, + Description: data.description, + Expiry: data.expired_at ? (renderFormattedDate(data.expired_at)?.replace(",", " ") ?? "") : "Never expires", + "Secret key": data.token ?? "", + }; + + csvDownload(csvData, `secret-key-${Date.now()}`); + }; + + const handleCreateToken = async (data: Partial) => { + // make the request to generate the token + await apiTokenService + .create(data) + .then((res) => { + setGeneratedToken(res); + downloadSecretKey(res); + + mutate( + API_TOKENS_LIST, + (prevData) => { + if (!prevData) return; + + return [res, ...prevData]; + }, + false + ); + captureSuccess({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created, + payload: { + token: res.id, + }, + }); + }) + .catch((err) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err.message || err.detail, + }); + + captureError({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created, + }); + + throw err; + }); + }; + + return ( + {}} position={EModalPosition.TOP} width={EModalWidth.XXL}> + {generatedToken ? ( + + ) : ( + setNeverExpires((prevData) => !prevData)} + onSubmit={handleCreateToken} + /> + )} + + ); +}; diff --git a/apps/web/core/components/api-token/modal/form.tsx b/apps/web/core/components/api-token/modal/form.tsx new file mode 100644 index 00000000..a39636e9 --- /dev/null +++ b/apps/web/core/components/api-token/modal/form.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useState } from "react"; +import { add } from "date-fns"; +import { Controller, useForm } from "react-hook-form"; +import { Calendar } from "lucide-react"; +// types +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IApiToken } from "@plane/types"; +// ui +import { CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui"; +import { cn, renderFormattedDate, renderFormattedTime } from "@plane/utils"; +// components +import { DateDropdown } from "@/components/dropdowns/date"; +// helpers +type Props = { + handleClose: () => void; + neverExpires: boolean; + toggleNeverExpires: () => void; + onSubmit: (data: Partial) => Promise; +}; + +const EXPIRY_DATE_OPTIONS = [ + { + key: "1_week", + label: "1 week", + value: { weeks: 1 }, + }, + { + key: "1_month", + label: "1 month", + value: { months: 1 }, + }, + { + key: "3_months", + label: "3 months", + value: { months: 3 }, + }, + { + key: "1_year", + label: "1 year", + value: { years: 1 }, + }, +]; + +const defaultValues: Partial = { + label: "", + description: "", + expired_at: null, +}; + +const getExpiryDate = (val: string): Date | null | undefined => { + const today = new Date(); + const dateToAdd = EXPIRY_DATE_OPTIONS.find((option) => option.key === val)?.value; + if (dateToAdd) return add(today, dateToAdd); + return null; +}; + +const getFormattedDate = (date: Date): Date => { + const now = new Date(); + const hours = now.getHours(); + const minutes = now.getMinutes(); + const seconds = now.getSeconds(); + return add(date, { hours, minutes, seconds }); +}; + +export const CreateApiTokenForm: React.FC = (props) => { + const { handleClose, neverExpires, toggleNeverExpires, onSubmit } = props; + // states + const [customDate, setCustomDate] = useState(null); + // form + const { + control, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + watch, + } = useForm({ defaultValues }); + // hooks + const { t } = useTranslation(); + + const handleFormSubmit = async (data: IApiToken) => { + // if never expires is toggled off, and the user has not selected a custom date or a predefined date, show an error + if (!neverExpires && (!data.expired_at || (data.expired_at === "custom" && !customDate))) + return setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Please select an expiration date.", + }); + + const payload: Partial = { + label: data.label, + description: data.description, + }; + + // if never expires is toggled on, set expired_at to null + if (neverExpires) payload.expired_at = null; + // if never expires is toggled off, and the user has selected a custom date, set expired_at to the custom date + else if (data.expired_at === "custom") { + payload.expired_at = customDate && getFormattedDate(customDate).toISOString(); + } + // if never expires is toggled off, and the user has selected a predefined date, set expired_at to the predefined date + else { + const expiryDate = getExpiryDate(data.expired_at ?? ""); + if (expiryDate) payload.expired_at = expiryDate.toISOString(); + } + + await onSubmit(payload).then(() => { + reset(defaultValues); + setCustomDate(null); + }); + }; + + const today = new Date(); + const tomorrow = add(today, { days: 1 }); + const expiredAt = watch("expired_at"); + const expiryDate = getExpiryDate(expiredAt ?? ""); + const customDateFormatted = customDate && getFormattedDate(customDate); + + return ( +
    +
    +

    + {t("workspace_settings.settings.api_tokens.create_token")} +

    +
    +
    + val.trim() !== "" || t("title_is_required"), + }} + render={({ field: { value, onChange } }) => ( + + )} + /> + {errors.label && {errors.label.message}} +
    + ( +