diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index 2b40af672..000000000 --- a/.deepsource.toml +++ /dev/null @@ -1,23 +0,0 @@ -version = 1 - -exclude_patterns = [ - "bin/**", - "**/node_modules/", - "**/*.min.js" -] - -[[analyzers]] -name = "shell" - -[[analyzers]] -name = "javascript" - - [analyzers.meta] - plugins = ["react"] - environment = ["nodejs"] - -[[analyzers]] -name = "python" - - [analyzers.meta] - runtime_version = "3.x.x" \ No newline at end of file diff --git a/.env.example b/.env.example index 90070de19..71a9074a6 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,12 @@ # Database Settings -PGUSER="plane" -PGPASSWORD="plane" -PGHOST="plane-db" -PGDATABASE="plane" -DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_DB="plane" +PGDATA="/var/lib/postgresql/data" # Redis Settings REDIS_HOST="plane-redis" REDIS_PORT="6379" -REDIS_URL="redis://${REDIS_HOST}:6379/" # AWS Settings AWS_REGION="" diff --git a/.eslintrc-staged.js b/.eslintrc-staged.js new file mode 100644 index 000000000..be20772a7 --- /dev/null +++ b/.eslintrc-staged.js @@ -0,0 +1,59 @@ +/** + * Adds three new lint plugins over the existing configuration: + * This is used to lint staged files only. + * We should remove this file once the entire codebase follows these rules. + */ +module.exports = { + root: true, + extends: [ + "custom", + ], + parser: "@typescript-eslint/parser", + settings: { + "import/resolver": { + typescript: {}, + node: { + moduleDirectory: ["node_modules", "."], + }, + }, + }, + rules: { + "import/order": [ + "error", + { + groups: ["builtin", "external", "internal", "parent", "sibling"], + pathGroups: [ + { + pattern: "react", + group: "external", + position: "before", + }, + { + pattern: "lucide-react", + group: "external", + position: "after", + }, + { + pattern: "@headlessui/**", + group: "external", + position: "after", + }, + { + pattern: "@plane/**", + group: "external", + position: "after", + }, + { + pattern: "@/**", + group: "internal", + }, + ], + pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + }, + ], + }, +}; diff --git a/.eslintrc.js b/.eslintrc.js index c229c0952..b1a019e35 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,7 +4,7 @@ module.exports = { extends: ["custom"], settings: { next: { - rootDir: ["web/", "space/"], + rootDir: ["web/", "space/", "admin/"], }, }, }; diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index 4240c10c5..d1d7fa009 100644 --- a/.github/ISSUE_TEMPLATE/--bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -1,7 +1,8 @@ name: Bug report description: Create a bug report to help us improve Plane title: "[bug]: " -labels: [bug, need testing] +labels: [🐛bug] +assignees: [srinivaspendem, pushya22] body: - type: markdown attributes: @@ -44,7 +45,7 @@ body: - Deploy preview validations: required: true - type: dropdown +- type: dropdown id: browser attributes: label: Browser @@ -54,12 +55,19 @@ body: - Safari - Other - type: dropdown - id: version + id: variant attributes: - label: Version + 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 index b7ba11679..ff9cdd238 100644 --- a/.github/ISSUE_TEMPLATE/--feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -1,7 +1,8 @@ name: Feature request description: Suggest a feature to improve Plane title: "[feature]: " -labels: [feature] +labels: [✨feature] +assignees: [srinivaspendem, pushya22] body: - type: markdown attributes: diff --git a/.github/workflows/build-aio-base.yml b/.github/workflows/build-aio-base.yml new file mode 100644 index 000000000..3d42f2ecd --- /dev/null +++ b/.github/workflows/build-aio-base.yml @@ -0,0 +1,91 @@ +name: Build AIO Base Image + +on: + workflow_dispatch: + +env: + TARGET_BRANCH: ${{ github.ref_name }} + +jobs: + base_build_setup: + name: Build Preparation + runs-on: ubuntu-latest + outputs: + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} + gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} + gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} + gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} + build_base: ${{ steps.changed_files.outputs.base_any_changed }} + + steps: + - id: set_env_variables + name: Set Environment Variables + run: | + echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT + echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT + + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + + - name: Get changed files + id: changed_files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + base: + - aio/Dockerfile.base + + base_build_push: + if: ${{ needs.base_build_setup.outputs.build_base == 'true' || github.event_name == 'workflow_dispatch' || needs.base_build_setup.outputs.gh_branch_name == 'master' }} + runs-on: ubuntu-latest + needs: [base_build_setup] + env: + BASE_IMG_TAG: makeplane/plane-aio-base:${{ needs.base_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.base_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.base_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Set Docker Tag + run: | + if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=makeplane/plane-aio-base:latest + else + TAG=${{ env.BASE_IMG_TAG }} + fi + echo "BASE_IMG_TAG=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Build and Push to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: ./aio + file: ./aio/Dockerfile.base + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.BASE_IMG_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index db65fbc2c..0ccccda5f 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -1,99 +1,130 @@ name: Branch Build on: - pull_request: - types: - - closed + workflow_dispatch: + push: branches: - master - preview - - qa - - develop - - release-* release: types: [released, prereleased] env: - TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }} + TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }} jobs: branch_build_setup: - if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }} - name: Build-Push Web/Space/API/Proxy Docker Image - runs-on: ubuntu-20.04 + name: Build Setup + runs-on: ubuntu-latest + outputs: + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} + gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} + gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} + gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} + build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }} + build_apiserver: ${{ steps.changed_files.outputs.apiserver_any_changed }} + build_admin: ${{ steps.changed_files.outputs.admin_any_changed }} + build_space: ${{ steps.changed_files.outputs.space_any_changed }} + build_web: ${{ steps.changed_files.outputs.web_any_changed }} steps: - - name: Check out the repo - uses: actions/checkout@v3.3.0 + - id: set_env_variables + name: Set Environment Variables + run: | + if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then + echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT + 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 + echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT - - name: Uploading Proxy Source - uses: actions/upload-artifact@v3 - with: - name: proxy-src-code - path: ./nginx - - name: Uploading Backend Source - uses: actions/upload-artifact@v3 - with: - name: backend-src-code - path: ./apiserver - - name: Uploading Web Source - uses: actions/upload-artifact@v3 - with: - name: web-src-code - path: | - ./ - !./apiserver - !./nginx - !./deploy - !./space - - name: Uploading Space Source - uses: actions/upload-artifact@v3 - with: - name: space-src-code - path: | - ./ - !./apiserver - !./nginx - !./deploy - !./web - outputs: - gh_branch_name: ${{ env.TARGET_BRANCH }} + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 - branch_build_push_frontend: + - name: Get changed files + id: changed_files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + apiserver: + - apiserver/** + proxy: + - nginx/** + admin: + - admin/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' + space: + - space/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' + web: + - web/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' + + branch_build_push_web: + if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - - name: Set Frontend Docker Tag + - name: Set Frontend Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + if [ "${{ github.event_name }}" == "release" ]; then + TAG=makeplane/plane-frontend:stable,makeplane/plane-frontend:${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=makeplane/plane-frontend:latest else TAG=${{ env.FRONTEND_TAG }} fi echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Web Source Code - uses: actions/download-artifact@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 with: - name: web-src-code + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 - name: Build and Push Frontend to Docker Container Registry - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: context: . file: ./web/Dockerfile.web - platforms: linux/amd64 + platforms: ${{ env.BUILDX_PLATFORMS }} tags: ${{ env.FRONTEND_TAG }} push: true env: @@ -101,41 +132,103 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - branch_build_push_space: + branch_build_push_admin: + if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - - name: Set Space Docker Tag + - name: Set Admin Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + if [ "${{ github.event_name }}" == "release" ]; then + TAG=makeplane/plane-admin:stable,makeplane/plane-admin:${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=makeplane/plane-admin:latest + else + TAG=${{ env.ADMIN_TAG }} + fi + echo "ADMIN_TAG=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push Frontend to Docker Container Registry + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./admin/Dockerfile.admin + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.ADMIN_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + branch_build_push_space: + if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Set Space Docker Tag + run: | + if [ "${{ github.event_name }}" == "release" ]; then + TAG=makeplane/plane-space:stable,makeplane/plane-space:${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=makeplane/plane-space:latest else TAG=${{ env.SPACE_TAG }} fi echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Space Source Code - uses: actions/download-artifact@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 with: - name: space-src-code + 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 Space to Docker Hub - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: context: . file: ./space/Dockerfile.space - platforms: linux/amd64 + platforms: ${{ env.BUILDX_PLATFORMS }} tags: ${{ env.SPACE_TAG }} push: true env: @@ -143,41 +236,51 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - branch_build_push_backend: + branch_build_push_apiserver: + if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - - name: Set Backend Docker Tag + - name: Set Backend Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + if [ "${{ github.event_name }}" == "release" ]; then + TAG=makeplane/plane-backend:stable,makeplane/plane-backend:${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=makeplane/plane-backend:latest else TAG=${{ env.BACKEND_TAG }} fi echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Backend Source Code - uses: actions/download-artifact@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 with: - name: backend-src-code + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 - name: Build and Push Backend to Docker Hub - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: - context: . - file: ./Dockerfile.api - platforms: linux/amd64 + context: ./apiserver + file: ./apiserver/Dockerfile.api + platforms: ${{ env.BUILDX_PLATFORMS }} push: true tags: ${{ env.BACKEND_TAG }} env: @@ -186,41 +289,50 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} branch_build_push_proxy: + if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - - name: Set Proxy Docker Tag + - name: Set Proxy Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + if [ "${{ github.event_name }}" == "release" ]; then + TAG=makeplane/plane-proxy:stable,makeplane/plane-proxy:${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=makeplane/plane-proxy:latest else TAG=${{ env.PROXY_TAG }} fi echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Proxy Source Code - uses: actions/download-artifact@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 with: - name: proxy-src-code + 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 Plane-Proxy to Docker Hub - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: - context: . - file: ./Dockerfile - platforms: linux/amd64 + context: ./nginx + file: ./nginx/Dockerfile + platforms: ${{ env.BUILDX_PLATFORMS }} tags: ${{ env.PROXY_TAG }} push: true env: diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml index fd5d5ad03..2e6f9c642 100644 --- a/.github/workflows/build-test-pull-request.yml +++ b/.github/workflows/build-test-pull-request.yml @@ -1,48 +1,138 @@ -name: Build Pull Request Contents +name: Build and Lint on Pull Request on: + workflow_dispatch: pull_request: - types: ["opened", "synchronize"] + types: ["opened", "synchronize", "ready_for_review"] jobs: - build-pull-request-contents: - name: Build Pull Request Contents - runs-on: ubuntu-20.04 - permissions: - pull-requests: read - + get-changed-files: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + outputs: + apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }} + admin_changed: ${{ steps.changed-files.outputs.admin_any_changed }} + space_changed: ${{ steps.changed-files.outputs.space_any_changed }} + web_changed: ${{ steps.changed-files.outputs.web_any_changed }} steps: - - name: Checkout Repository to Actions - uses: actions/checkout@v3.3.0 - with: - token: ${{ secrets.ACCESS_TOKEN }} - - - name: Setup Node.js 18.x - uses: actions/setup-node@v2 - with: - node-version: 18.x - cache: "yarn" - + - uses: actions/checkout@v4 - name: Get changed files id: changed-files - uses: tj-actions/changed-files@v38 + uses: tj-actions/changed-files@v44 with: files_yaml: | apiserver: - apiserver/** + admin: + - admin/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' + space: + - space/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' web: - web/** - deploy: - - space/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' - - name: Build Plane's Main App - if: steps.changed-files.outputs.web_any_changed == 'true' - run: | - yarn - yarn build --filter=web + lint-apiserver: + needs: get-changed-files + runs-on: ubuntu-latest + if: needs.get-changed-files.outputs.apiserver_changed == 'true' + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" # Specify the Python version you need + - name: Install Pylint + run: python -m pip install ruff + - name: Install Apiserver Dependencies + run: cd apiserver && pip install -r requirements.txt + - name: Lint apiserver + run: ruff check --fix apiserver - - name: Build Plane's Deploy App - if: steps.changed-files.outputs.deploy_any_changed == 'true' - run: | - yarn - yarn build --filter=space + lint-admin: + needs: get-changed-files + if: needs.get-changed-files.outputs.admin_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18.x + - run: yarn install + - run: yarn lint --filter=admin + + lint-space: + needs: get-changed-files + if: needs.get-changed-files.outputs.space_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18.x + - run: yarn install + - run: yarn lint --filter=space + + lint-web: + needs: get-changed-files + if: needs.get-changed-files.outputs.web_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18.x + - run: yarn install + - run: yarn lint --filter=web + + build-admin: + needs: lint-admin + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18.x + - run: yarn install + - run: yarn build --filter=admin + + build-space: + needs: lint-space + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18.x + - run: yarn install + - run: yarn build --filter=space + + build-web: + needs: lint-web + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18.x + - run: yarn install + - run: yarn build --filter=web diff --git a/.github/workflows/check-version.yml b/.github/workflows/check-version.yml new file mode 100644 index 000000000..ca8b6f8b3 --- /dev/null +++ b/.github/workflows/check-version.yml @@ -0,0 +1,45 @@ +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 + with: + node-version: '18' + + - 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 index 29fbde453..dbfd81168 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,13 +1,13 @@ name: "CodeQL" on: + workflow_dispatch: push: - branches: [ 'develop', 'hot-fix', 'stage-release' ] + branches: ["preview", "master"] pull_request: - # The branches below must be a subset of the branches above - branches: [ 'develop' ] + branches: ["develop", "preview", "master"] schedule: - - cron: '53 19 * * 5' + - cron: "53 19 * * 5" jobs: analyze: @@ -21,45 +21,44 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'python', 'javascript' ] + language: ["python", "javascript"] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Use only 'java' to analyze code written in Java, Kotlin or both # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v3 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # ℹ️ 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. - # 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 - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index 5b5f958d3..c195f8423 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -1,30 +1,53 @@ -name: Create Sync Action +name: Create PR on Sync on: - pull_request: + workflow_dispatch: + push: branches: - - preview - types: - - closed -env: - SOURCE_BRANCH_NAME: ${{github.event.pull_request.base.ref}} + - "sync/**" + +env: + CURRENT_BRANCH: ${{ github.ref_name }} + SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce" + TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop + GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows + REVIEWER: ${{ vars.SYNC_PR_REVIEWER }} + ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }} + ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }} jobs: - create_pr: - # Only run the job when a PR is merged - if: github.event.pull_request.merged == true + Check_Branch: + runs-on: ubuntu-latest + outputs: + BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }} + steps: + - name: Check if current branch matches the secret + id: check-branch + run: | + if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then + echo "MATCH=true" >> $GITHUB_OUTPUT + else + echo "MATCH=false" >> $GITHUB_OUTPUT + fi + Auto_Merge: + if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }} + needs: [Check_Branch] runs-on: ubuntu-latest permissions: pull-requests: write - contents: read + contents: write steps: - - name: Checkout Code - uses: actions/checkout@v2 + - name: Checkout code + uses: actions/checkout@v4.1.1 with: - persist-credentials: false - fetch-depth: 0 + fetch-depth: 0 # Fetch all history for all branches and tags - - name: Setup GH CLI + - 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 @@ -33,14 +56,14 @@ jobs: sudo apt update sudo apt install gh -y - - name: Push Changes to Target Repo - env: - GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + - name: Create PR to Target Branch run: | - TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}" - TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}" - SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" - - git checkout $SOURCE_BRANCH - git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git" - git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH \ No newline at end of file + # get all pull requests and check if there is already a PR + PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_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 $SOURCE_BRANCH --title "sync: community changes" --body "") + echo "Pull Request created: $PR_URL" + fi diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml new file mode 100644 index 000000000..e848dc36d --- /dev/null +++ b/.github/workflows/feature-deployment.yml @@ -0,0 +1,266 @@ +name: Feature Preview + +on: + workflow_dispatch: + inputs: + web-build: + required: false + description: "Build Web" + type: boolean + default: true + space-build: + required: false + description: "Build Space" + type: boolean + default: false + admin-build: + required: false + description: "Build Admin" + type: boolean + default: false + +env: + BUILD_WEB: ${{ github.event.inputs.web-build }} + BUILD_SPACE: ${{ github.event.inputs.space-build }} + BUILD_ADMIN: ${{ github.event.inputs.admin-build }} + +jobs: + setup-feature-build: + name: Feature Build Setup + runs-on: ubuntu-latest + steps: + - name: Checkout + run: | + echo "BUILD_WEB=$BUILD_WEB" + echo "BUILD_SPACE=$BUILD_SPACE" + echo "BUILD_ADMIN=$BUILD_ADMIN" + outputs: + web-build: ${{ env.BUILD_WEB}} + space-build: ${{env.BUILD_SPACE}} + admin-build: ${{env.BUILD_ADMIN}} + + feature-build-web: + if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }} + needs: setup-feature-build + name: Feature Build Web + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} + NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + - name: Install AWS cli + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + pip3 install awscli + - name: Checkout + uses: actions/checkout@v4 + with: + path: plane + - name: Install Dependencies + run: | + cd $GITHUB_WORKSPACE/plane + yarn install + - name: Build Web + id: build-web + run: | + cd $GITHUB_WORKSPACE/plane + yarn build --filter=web + cd $GITHUB_WORKSPACE + + TAR_NAME="web.tar.gz" + tar -czf $TAR_NAME ./plane + + FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") + aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY + + feature-build-space: + if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }} + needs: setup-feature-build + name: Feature Build Space + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} + NEXT_PUBLIC_SPACE_BASE_PATH: "/spaces" + NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} + outputs: + do-build: ${{ needs.setup-feature-build.outputs.space-build }} + s3-url: ${{ steps.build-space.outputs.S3_PRESIGNED_URL }} + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + - name: Install AWS cli + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + pip3 install awscli + - name: Checkout + uses: actions/checkout@v4 + with: + path: plane + - name: Install Dependencies + run: | + cd $GITHUB_WORKSPACE/plane + yarn install + - name: Build Space + id: build-space + run: | + cd $GITHUB_WORKSPACE/plane + yarn build --filter=space + cd $GITHUB_WORKSPACE + + TAR_NAME="space.tar.gz" + tar -czf $TAR_NAME ./plane + + FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") + aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY + + feature-build-admin: + if: ${{ needs.setup-feature-build.outputs.admin-build == 'true' }} + needs: setup-feature-build + name: Feature Build Admin + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} + NEXT_PUBLIC_ADMIN_BASE_PATH: "/god-mode" + NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} + outputs: + do-build: ${{ needs.setup-feature-build.outputs.admin-build }} + s3-url: ${{ steps.build-admin.outputs.S3_PRESIGNED_URL }} + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + - name: Install AWS cli + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + pip3 install awscli + - name: Checkout + uses: actions/checkout@v4 + with: + path: plane + - name: Install Dependencies + run: | + cd $GITHUB_WORKSPACE/plane + yarn install + - name: Build Admin + id: build-admin + run: | + cd $GITHUB_WORKSPACE/plane + yarn build --filter=admin + cd $GITHUB_WORKSPACE + + TAR_NAME="admin.tar.gz" + tar -czf $TAR_NAME ./plane + + FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") + aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY + + feature-deploy: + if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true' || needs.setup-feature-build.outputs.admin-build == 'true') }} + needs: + [ + setup-feature-build, + feature-build-web, + feature-build-space, + feature-build-admin, + ] + name: Feature Deploy + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} + KUBE_CONFIG_FILE: ${{ secrets.FEATURE_PREVIEW_KUBE_CONFIG }} + 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: | + WEB_S3_URL="" + if [ ${{ env.BUILD_WEB }} == true ]; then + WEB_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/web.tar.gz --expires-in 3600) + fi + + SPACE_S3_URL="" + if [ ${{ env.BUILD_SPACE }} == true ]; then + SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600) + fi + + ADMIN_S3_URL="" + if [ ${{ env.BUILD_ADMIN }} == true ]; then + ADMIN_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/admin.tar.gz --expires-in 3600) + fi + + if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ] || [ ${{ env.BUILD_ADMIN }} == true ]; then + + helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }} + + APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}" + DEPLOY_SCRIPT_URL="${{ vars.FEATURE_PREVIEW_DEPLOY_SCRIPT_URL }}" + + METADATA=$(helm --kube-insecure-skip-tls-verify install feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} \ + --generate-name \ + --namespace $APP_NAMESPACE \ + --set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \ + --set web.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ + --set web.enabled=${{ env.BUILD_WEB || false }} \ + --set web.artifact_url=$WEB_S3_URL \ + --set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ + --set space.enabled=${{ env.BUILD_SPACE || false }} \ + --set space.artifact_url=$SPACE_S3_URL \ + --set admin.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ + --set admin.enabled=${{ env.BUILD_ADMIN || false }} \ + --set admin.artifact_url=$ADMIN_S3_URL \ + --set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \ + --set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \ + --output json \ + --timeout 1000s) + + APP_NAME=$(echo $METADATA | jq -r '.name') + + INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \ + -o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \ + jq -r '.spec.rules[0].host') + + echo "****************************************" + echo "APP NAME ::: $APP_NAME" + echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME" + echo "****************************************" + fi diff --git a/.github/workflows/repo-sync.yml b/.github/workflows/repo-sync.yml new file mode 100644 index 000000000..9ac4771ef --- /dev/null +++ b/.github/workflows/repo-sync.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-20.04 + permissions: + pull-requests: write + contents: read + steps: + - name: Checkout Code + uses: actions/checkout@v4.1.1 + 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 index 0b655bd0e..80607b92f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ staticfiles mediafiles .env .DS_Store +logs/ node_modules/ assets/dist/ @@ -80,3 +81,7 @@ tmp/ ## packages dist .temp/ +deploy/selfhost/plane-app/ +## Storybook +*storybook.log +output.css diff --git a/apiserver/plane/utils/importers/__init__.py b/.husky/pre-commit similarity index 100% rename from apiserver/plane/utils/importers/__init__.py rename to .husky/pre-commit diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 000000000..22825d771 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,3 @@ +{ + "*.{ts,tsx,js,jsx}": ["eslint -c ./.eslintrc-staged.js", "prettier --check"] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 148568d76..f40c1a244 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,6 @@ chmod +x setup.sh docker compose -f docker-compose-local.yml up ``` - ## 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. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0f4ecfd36..000000000 --- a/Dockerfile +++ /dev/null @@ -1,129 +0,0 @@ -FROM node:18-alpine AS builder -RUN apk add --no-cache libc6-compat -# Set working directory -WORKDIR /app -ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER - -RUN yarn global add turbo -RUN apk add tree -COPY . . - -RUN turbo prune --scope=app --scope=plane-deploy --docker -CMD tree -I node_modules/ - -# Add lockfile and package.json's of isolated subworkspace -FROM node:18-alpine AS installer - -RUN apk add --no-cache libc6-compat -WORKDIR /app -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 -# First install the dependencies (as they change less often) -COPY .gitignore .gitignore -COPY --from=builder /app/out/json/ . -COPY --from=builder /app/out/yarn.lock ./yarn.lock -RUN yarn install - -# # Build the project -COPY --from=builder /app/out/full/ . -COPY turbo.json turbo.json -COPY replace-env-vars.sh /usr/local/bin/ -USER root -RUN chmod +x /usr/local/bin/replace-env-vars.sh - -RUN yarn turbo run build - -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL - -RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} - -FROM python:3.11.1-alpine3.17 AS backend - -# set environment variables -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 -ENV PIP_DISABLE_PIP_VERSION_CHECK=1 - -WORKDIR /code - -RUN apk --no-cache add \ - "libpq~=15" \ - "libxslt~=1.1" \ - "nodejs-current~=19" \ - "xmlsec~=1.2" \ - "nginx" \ - "nodejs" \ - "npm" \ - "supervisor" - -COPY apiserver/requirements.txt ./ -COPY apiserver/requirements ./requirements -RUN apk add --no-cache libffi-dev -RUN apk add --no-cache --virtual .build-deps \ - "bash~=5.2" \ - "g++~=12.2" \ - "gcc~=12.2" \ - "cargo~=1.64" \ - "git~=2" \ - "make~=4.3" \ - "postgresql13-dev~=13" \ - "libc-dev" \ - "linux-headers" \ - && \ - pip install -r requirements.txt --compile --no-cache-dir \ - && \ - apk del .build-deps - -# Add in Django deps and generate Django's static files -COPY apiserver/manage.py manage.py -COPY apiserver/plane plane/ -COPY apiserver/templates templates/ - -RUN apk --no-cache add "bash~=5.2" -COPY apiserver/bin ./bin/ - -RUN chmod +x ./bin/takeoff ./bin/worker -RUN chmod -R 777 /code - -# Expose container port and run entry point script - -WORKDIR /app - -# Don't run production as root -RUN addgroup --system --gid 1001 plane -RUN adduser --system --uid 1001 captain - -COPY --from=installer /app/apps/app/next.config.js . -COPY --from=installer /app/apps/app/package.json . -COPY --from=installer /app/apps/space/next.config.js . -COPY --from=installer /app/apps/space/package.json . - -COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./ - -COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static - -COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./ -COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next - -ENV NEXT_TELEMETRY_DISABLED 1 - -# RUN rm /etc/nginx/conf.d/default.conf -####################################################################### -COPY nginx/nginx-single-docker-image.conf /etc/nginx/http.d/default.conf -####################################################################### - -COPY nginx/supervisor.conf /code/supervisor.conf - -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL - -USER root -COPY replace-env-vars.sh /usr/local/bin/ -COPY start.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/replace-env-vars.sh -RUN chmod +x /usr/local/bin/start.sh - -EXPOSE 80 - -CMD ["supervisord","-c","/code/supervisor.conf"] diff --git a/ENV_SETUP.md b/ENV_SETUP.md index bfc300196..df05683ef 100644 --- a/ENV_SETUP.md +++ b/ENV_SETUP.md @@ -53,7 +53,6 @@ NGINX_PORT=80 NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces" ``` - ## {PROJECT_FOLDER}/apiserver/.env ​ diff --git a/README.md b/README.md index 5b96dbf6c..38ead5f99 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Plane

-

Flexible, extensible open-source project management

+

Open-source project management that unlocks customer value

@@ -16,6 +16,13 @@ Commit activity per month

+

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

+

-Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️. +Meet [Plane](https://dub.sh/plane-website-readme), 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 still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases. +> 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. -The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting/docker-compose). +## ⚡ Installation -## ⚡️ Contributors Quick Start +The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. -### Prerequisite +If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose). -Development system must have docker engine installed and running. +| Installation methods | Docs link | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.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://docs.plane.so/kubernetes) | -### Steps - -Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute - -1. Clone the code locally using `git clone https://github.com/makeplane/plane.git` -1. Switch to the code folder `cd plane` -1. Create your feature or fix branch you plan to work on using `git checkout -b ` -1. Open terminal and run `./setup.sh` -1. Open the code on VSCode or similar equivalent IDE -1. Review the `.env` files available in various folders. Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system -1. Run the docker command to initiate various services `docker compose -f docker-compose-local.yml up -d` - -You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload) - -Thats it! - -## 🍙 Self Hosting - -For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/self-hosting/docker-compose) documentation page +`Instance admins` can configure instance settings with [God-mode](https://docs.plane.so/instance-admin). ## 🚀 Features -- **Issue Planning and Tracking**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to issues for better organization and tracking. -- **Issue Attachments**: Collaborate effectively by attaching files to issues, making it easy for your team to find and share important project-related documents. -- **Layouts**: Customize your project view with your preferred layout - choose from List, Kanban, or Calendar to visualize your project in a way that makes sense to you. -- **Cycles**: Plan sprints with Cycles to keep your team on track and productive. Gain insights into your project's progress with burn-down charts and other useful features. -- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to easily track and plan your project's progress. +- **Issues**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking. + +- **Cycles**: + Keep up your team's momentum with Cycles. Gain insights into your project's progress with burn-down charts and other valuable features. + +- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily. + - **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks. -- **Pages**: Plane pages function as an AI-powered notepad, allowing you to easily document issues, cycle plans, and module details, and then synchronize them with your issues. -- **Command K**: Enjoy a better user experience with the new Command + K menu. Easily manage and navigate through your projects from one convenient location. -- **GitHub Sync**: Streamline your planning process by syncing your GitHub issues with Plane. Keep all your issues in one place for better tracking and collaboration. + +- **Pages**: Plane pages, equipped with AI and a rich text editor, let you jot down your thoughts on the fly. Format your text, upload images, hyperlink, or sync your existing ideas into an actionable item or issue. + +- **Analytics**: Get insights into all your Plane data in real-time. Visualize issue data to spot trends, remove blockers, and progress your work. + +- **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. + +## 🛠️ Quick start for contributors + +> Development system must have docker engine installed and running. + +Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute - + +1. Clone the code locally using: + ``` + git clone https://github.com/makeplane/plane.git + ``` +2. Switch to the code folder: + ``` + cd plane + ``` +3. Create your feature or fix branch you plan to work on using: + ``` + git checkout -b + ``` +4. Open terminal and run: + ``` + ./setup.sh + ``` +5. Open the code on VSCode or similar equivalent IDE. +6. Review the `.env` files available in various folders. + Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system. +7. Run the docker command to initiate services: + ``` + docker compose -f docker-compose-local.yml up -d + ``` + +You are ready to make changes to the code. Do not forget to refresh the browser (in case it does not auto-reload). + +Thats it! + +## ❤️ Community + +The Plane community can be found on [GitHub Discussions](https://github.com/orgs/makeplane/discussions), and our [Discord server](https://discord.com/invite/A92xrEGCge). Our [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community chanels. + +Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects. + +### Repo Activity + +![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image") ## 📸 Screenshots

Plane Views @@ -91,8 +132,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Issue Details @@ -100,7 +140,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Cycles and Modules @@ -109,7 +149,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Analytics @@ -118,7 +158,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Pages @@ -128,7 +168,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Command Menu @@ -136,20 +176,23 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

-## 📚Documentation - -For full documentation, visit [docs.plane.so](https://docs.plane.so/) - -To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md). - -## ❤️ Community - -The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects. - -To chat with other community members you can join the [Plane Discord](https://discord.com/invite/A92xrEGCge). - -Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels. - ## ⛓️ Security -If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email engineering@plane.so to disclose any security vulnerabilities. +If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. + +Email squawk@plane.so to disclose any security vulnerabilities. + +## ❤️ Contribute + +There are many ways to contribute to Plane, including: + +- Submitting [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+) and [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+) for various components. +- Reviewing [the documentation](https://docs.plane.so/) and submitting [pull requests](https://github.com/makeplane/plane), from fixing typos to adding new features. +- Speaking or writing about Plane or any other ecosystem integration and [letting us know](https://discord.com/invite/A92xrEGCge)! +- Upvoting [popular feature requests](https://github.com/makeplane/plane/issues) to show your support. + +### We couldn't have done this without you. + +
+ + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..36cdb982c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security Policy + +This document outlines security procedures and vulnerabilities reporting for the Plane project. + +At Plane, we safeguarding the security of our systems with top priority. Despite our efforts, vulnerabilities may still exist. We greatly appreciate your assistance in identifying and reporting any such vulnerabilities to help us maintain the integrity of our systems and protect our clients. + +To report a security vulnerability, please email us directly at security@plane.so with a detailed description of the vulnerability and steps to reproduce it. Please refrain from disclosing the vulnerability publicly until we have had an opportunity to review and address it. + +## Out of Scope Vulnerabilities + +We appreciate your help in identifying vulnerabilities. However, please note that the following types of vulnerabilities are considered out of scope: + +- Attacks requiring MITM or physical access to a user's device. +- Content spoofing and text injection issues without demonstrating an attack vector or ability to modify HTML/CSS. +- Email spoofing. +- Missing DNSSEC, CAA, CSP headers. +- Lack of Secure or HTTP only flag on non-sensitive cookies. + +## Reporting Process + +If you discover a vulnerability, please adhere to the following reporting process: + +1. Email your findings to security@plane.so. +2. Refrain from running automated scanners on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary. +3. Do not exploit the vulnerability for malicious purposes, such as downloading excessive data or altering user data. +4. Maintain confidentiality and refrain from disclosing the vulnerability until it has been resolved. +5. Avoid using physical security attacks, social engineering, distributed denial of service, spam, or third-party applications. + +When reporting a vulnerability, please provide sufficient information to allow us to reproduce and address the issue promptly. Include the IP address or URL of the affected system, along with a detailed description of the vulnerability. + +## Our Commitment + +We are committed to promptly addressing reported vulnerabilities and maintaining open communication throughout the resolution process. Here's what you can expect from us: + +- **Response Time:** We will acknowledge receipt of your report within three business days and provide an expected resolution date. +- **Legal Protection:** We will not pursue legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines. +- **Confidentiality:** Your report will be treated with strict confidentiality. We will not disclose your personal information to third parties without your consent. +- **Progress Updates:** We will keep you informed of our progress in resolving the reported vulnerability. +- **Recognition:** With your permission, we will publicly acknowledge you as the discoverer of the vulnerability. +- **Timely Resolution:** We strive to resolve all reported vulnerabilities promptly and will actively participate in the publication process once the issue is resolved. + +We appreciate your cooperation in helping us maintain the security of our systems and protecting our clients. Thank you for your contributions to our security efforts. + +reference: https://supabase.com/.well-known/security.txt diff --git a/admin/.env.example b/admin/.env.example new file mode 100644 index 000000000..fdeb05c4d --- /dev/null +++ b/admin/.env.example @@ -0,0 +1,3 @@ +NEXT_PUBLIC_API_BASE_URL="" +NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +NEXT_PUBLIC_WEB_BASE_URL="" \ No newline at end of file diff --git a/admin/.eslintrc.js b/admin/.eslintrc.js new file mode 100644 index 000000000..a82c768a0 --- /dev/null +++ b/admin/.eslintrc.js @@ -0,0 +1,52 @@ +module.exports = { + root: true, + extends: ["custom"], + parser: "@typescript-eslint/parser", + settings: { + "import/resolver": { + typescript: {}, + node: { + moduleDirectory: ["node_modules", "."], + }, + }, + }, + rules: { + "import/order": [ + "error", + { + groups: ["builtin", "external", "internal", "parent", "sibling",], + pathGroups: [ + { + pattern: "react", + group: "external", + position: "before", + }, + { + pattern: "lucide-react", + group: "external", + position: "after", + }, + { + pattern: "@headlessui/**", + group: "external", + position: "after", + }, + { + pattern: "@plane/**", + group: "external", + position: "after", + }, + { + pattern: "@/**", + group: "internal", + } + ], + pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + }, + ], + }, +} \ No newline at end of file diff --git a/admin/.prettierignore b/admin/.prettierignore new file mode 100644 index 000000000..43e8a7b8f --- /dev/null +++ b/admin/.prettierignore @@ -0,0 +1,6 @@ +.next +.vercel +.tubro +out/ +dis/ +build/ \ No newline at end of file diff --git a/admin/.prettierrc b/admin/.prettierrc new file mode 100644 index 000000000..87d988f1b --- /dev/null +++ b/admin/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/admin/Dockerfile.admin b/admin/Dockerfile.admin new file mode 100644 index 000000000..ad9469110 --- /dev/null +++ b/admin/Dockerfile.admin @@ -0,0 +1,86 @@ +# ***************************************************************************** +# STAGE 1: Build the project +# ***************************************************************************** +FROM node:18-alpine AS builder +RUN apk add --no-cache libc6-compat +WORKDIR /app + +RUN yarn global add turbo +COPY . . + +RUN turbo prune --scope=admin --docker + +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** +FROM node:18-alpine 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/yarn.lock ./yarn.lock +RUN yarn install --network-timeout 500000 + +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +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 yarn turbo run build --filter=admin + +# ***************************************************************************** +# STAGE 3: Copy the project and start it +# ***************************************************************************** +FROM node:18-alpine AS runner +WORKDIR /app + +COPY --from=installer /app/admin/next.config.js . +COPY --from=installer /app/admin/package.json . + +COPY --from=installer /app/admin/.next/standalone ./ +COPY --from=installer /app/admin/.next/static ./admin/.next/static +COPY --from=installer /app/admin/public ./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 \ No newline at end of file diff --git a/admin/Dockerfile.dev b/admin/Dockerfile.dev new file mode 100644 index 000000000..1ed84e78e --- /dev/null +++ b/admin/Dockerfile.dev @@ -0,0 +1,17 @@ +FROM node:18-alpine +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +COPY . . + +RUN yarn global add turbo +RUN yarn install + +ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" + +EXPOSE 3000 + +VOLUME [ "/app/node_modules", "/app/admin/node_modules" ] + +CMD ["yarn", "dev", "--filter=admin"] diff --git a/admin/app/ai/form.tsx b/admin/app/ai/form.tsx new file mode 100644 index 000000000..cec5c0748 --- /dev/null +++ b/admin/app/ai/form.tsx @@ -0,0 +1,128 @@ +import { FC } from "react"; +import { useForm } from "react-hook-form"; +import { Lightbulb } from "lucide-react"; +import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { ControllerInput, TControllerInputFormField } from "@/components/common"; +// 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: { + OPENAI_API_KEY: config["OPENAI_API_KEY"], + GPT_ENGINE: config["GPT_ENGINE"], + }, + }); + + const aiFormFields: TControllerInputFormField[] = [ + { + key: "GPT_ENGINE", + type: "text", + label: "GPT_ENGINE", + description: ( + <> + Choose an OpenAI engine.{" "} + + Learn more + + + ), + placeholder: "gpt-3.5-turbo", + error: Boolean(errors.GPT_ENGINE), + required: false, + }, + { + key: "OPENAI_API_KEY", + type: "password", + label: "API key", + description: ( + <> + You will find your API key{" "} + + here. + + + ), + placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd", + error: Boolean(errors.OPENAI_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/admin/app/ai/layout.tsx b/admin/app/ai/layout.tsx new file mode 100644 index 000000000..0a0bacac1 --- /dev/null +++ b/admin/app/ai/layout.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; +import { AdminLayout } from "@/layouts/admin-layout"; + +export const metadata: Metadata = { + title: "AI Settings - God Mode", +}; + +export default function AILayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/admin/app/ai/page.tsx b/admin/app/ai/page.tsx new file mode 100644 index 000000000..a54ce6d8c --- /dev/null +++ b/admin/app/ai/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +import { Loader } from "@plane/ui"; +// components +import { PageHeader } from "@/components/core"; +// 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/admin/app/authentication/components/authentication-method-card.tsx b/admin/app/authentication/components/authentication-method-card.tsx new file mode 100644 index 000000000..1346a730e --- /dev/null +++ b/admin/app/authentication/components/authentication-method-card.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { FC } from "react"; +// helpers +import { cn } from "helpers/common.helper"; + +type Props = { + name: string; + description: string; + icon: JSX.Element; + config: JSX.Element; + disabled?: boolean; + withBorder?: boolean; +}; + +export const AuthenticationMethodCard: FC = (props) => { + const { name, description, icon, config, disabled = false, withBorder = true } = props; + + return ( +
+
+
+
{icon}
+
+
+
+ {name} +
+
+ {description} +
+
+
+
{config}
+
+ ); +}; diff --git a/admin/app/authentication/components/email-config-switch.tsx b/admin/app/authentication/components/email-config-switch.tsx new file mode 100644 index 000000000..0f09cf82c --- /dev/null +++ b/admin/app/authentication/components/email-config-switch.tsx @@ -0,0 +1,36 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { 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 ( + { + Boolean(parseInt(enableMagicLogin)) === true + ? updateConfig("ENABLE_MAGIC_LINK_LOGIN", "0") + : updateConfig("ENABLE_MAGIC_LINK_LOGIN", "1"); + }} + size="sm" + disabled={disabled} + /> + ); +}); diff --git a/admin/app/authentication/components/github-config.tsx b/admin/app/authentication/components/github-config.tsx new file mode 100644 index 000000000..27264d460 --- /dev/null +++ b/admin/app/authentication/components/github-config.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +// icons +import { Settings2 } from "lucide-react"; +// types +import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +// ui +import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +// 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 + + { + Boolean(parseInt(enableGithubConfig)) === true + ? updateConfig("IS_GITHUB_ENABLED", "0") + : updateConfig("IS_GITHUB_ENABLED", "1"); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/admin/app/authentication/components/google-config.tsx b/admin/app/authentication/components/google-config.tsx new file mode 100644 index 000000000..9fde70dac --- /dev/null +++ b/admin/app/authentication/components/google-config.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +// icons +import { Settings2 } from "lucide-react"; +// types +import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +// ui +import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +// 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 + + { + Boolean(parseInt(enableGoogleConfig)) === true + ? updateConfig("IS_GOOGLE_ENABLED", "0") + : updateConfig("IS_GOOGLE_ENABLED", "1"); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/admin/app/authentication/components/index.ts b/admin/app/authentication/components/index.ts new file mode 100644 index 000000000..d76d61f57 --- /dev/null +++ b/admin/app/authentication/components/index.ts @@ -0,0 +1,5 @@ +export * from "./email-config-switch"; +export * from "./password-config-switch"; +export * from "./authentication-method-card"; +export * from "./github-config"; +export * from "./google-config"; diff --git a/admin/app/authentication/components/password-config-switch.tsx b/admin/app/authentication/components/password-config-switch.tsx new file mode 100644 index 000000000..901cce862 --- /dev/null +++ b/admin/app/authentication/components/password-config-switch.tsx @@ -0,0 +1,36 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { 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 ( + { + Boolean(parseInt(enableEmailPassword)) === true + ? updateConfig("ENABLE_EMAIL_PASSWORD", "0") + : updateConfig("ENABLE_EMAIL_PASSWORD", "1"); + }} + size="sm" + disabled={disabled} + /> + ); +}); diff --git a/admin/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx new file mode 100644 index 000000000..75c76e7a5 --- /dev/null +++ b/admin/app/authentication/github/form.tsx @@ -0,0 +1,213 @@ +import { FC, useState } from "react"; +import isEmpty from "lodash/isEmpty"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// types +import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; +// ui +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +// components +import { + ConfirmDiscardModal, + ControllerInput, + CopyField, + TControllerInputFormField, + TCopyField, +} from "@/components/common"; +// helpers +import { API_BASE_URL, cn } from "@/helpers/common.helper"; +// 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"], + }, + }); + + 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, + }, + ]; + + const GITHUB_SERVICE_FIELD: TCopyField[] = [ + { + key: "Origin_URL", + label: "Origin URL", + url: originURL, + description: ( + <> + We will auto-generate this. Paste this into the Authorized origin URL field{" "} + + here. + + + ), + }, + { + 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: "Success", + message: "Github Configuration Settings updated successfully", + }); + 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, + }); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Configuration
+ {GITHUB_FORM_FIELDS.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Service provider details
+ {GITHUB_SERVICE_FIELD.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/admin/app/authentication/github/page.tsx b/admin/app/authentication/github/page.tsx new file mode 100644 index 000000000..8532910f7 --- /dev/null +++ b/admin/app/authentication/github/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; +// components +import { PageHeader } from "@/components/core"; +// helpers +import { resolveGeneralTheme } from "@/helpers/common.helper"; +// 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 { AuthenticationMethodCard } from "../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); + }); + }; + return ( + <> + +
+
+ + } + config={ + { + Boolean(parseInt(enableGithubConfig)) === true + ? updateConfig("IS_GITHUB_ENABLED", "0") + : updateConfig("IS_GITHUB_ENABLED", "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGithubAuthenticationPage; diff --git a/admin/app/authentication/google/form.tsx b/admin/app/authentication/google/form.tsx new file mode 100644 index 000000000..fd2e7c73c --- /dev/null +++ b/admin/app/authentication/google/form.tsx @@ -0,0 +1,211 @@ +import { FC, useState } from "react"; +import isEmpty from "lodash/isEmpty"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// types +import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; +// ui +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +// components +import { + ConfirmDiscardModal, + ControllerInput, + CopyField, + TControllerInputFormField, + TCopyField, +} from "@/components/common"; +// helpers +import { API_BASE_URL, cn } from "@/helpers/common.helper"; +// 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_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. + +

+ ), + }, + { + 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: "Success", + message: "Google Configuration Settings updated successfully", + }); + 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)} + /> +
+
+
+
Configuration
+ {GOOGLE_FORM_FIELDS.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Service provider details
+ {GOOGLE_SERVICE_DETAILS.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/admin/app/authentication/google/page.tsx b/admin/app/authentication/google/page.tsx new file mode 100644 index 000000000..fcdcd47ad --- /dev/null +++ b/admin/app/authentication/google/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import useSWR from "swr"; +import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; +// components +import { PageHeader } from "@/components/core"; +// hooks +import { useInstance } from "@/hooks/store"; +// icons +import GoogleLogo from "@/public/logos/google-logo.svg"; +// local components +import { AuthenticationMethodCard } from "../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={ + { + Boolean(parseInt(enableGoogleConfig)) === true + ? updateConfig("IS_GOOGLE_ENABLED", "0") + : updateConfig("IS_GOOGLE_ENABLED", "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGoogleAuthenticationPage; diff --git a/admin/app/authentication/layout.tsx b/admin/app/authentication/layout.tsx new file mode 100644 index 000000000..64506ddb4 --- /dev/null +++ b/admin/app/authentication/layout.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; +import { AdminLayout } from "@/layouts/admin-layout"; + +export const metadata: Metadata = { + title: "Authentication Settings - God Mode", +}; + +export default function AuthenticationLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx new file mode 100644 index 000000000..c44b74b49 --- /dev/null +++ b/admin/app/authentication/page.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +import { Mails, KeyRound } from "lucide-react"; +import { TInstanceConfigurationKeys } from "@plane/types"; +import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; +// components +import { PageHeader } from "@/components/core"; +// hooks +// helpers +import { cn, resolveGeneralTheme } from "@/helpers/common.helper"; +import { useInstance } from "@/hooks/store"; +// images +import githubLightModeImage from "@/public/logos/github-black.png"; +import githubDarkModeImage from "@/public/logos/github-white.png"; +import GoogleLogo from "@/public/logos/google-logo.svg"; +// local components +import { + AuthenticationMethodCard, + EmailCodesConfiguration, + PasswordLoginConfiguration, + GithubConfiguration, + GoogleConfiguration, +} from "./components"; + +type TInstanceAuthenticationMethodCard = { + key: string; + name: string; + description: string; + icon: JSX.Element; + config: JSX.Element; +}; + +const InstanceAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // theme + const { resolvedTheme } = useTheme(); + // 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); + }); + }; + + // Authentication methods + const authenticationMethodsCard: TInstanceAuthenticationMethodCard[] = [ + { + key: "email-codes", + name: "Email codes", + description: "Login or sign up using codes sent via emails. You need to have email setup here and enabled.", + icon: , + config: , + }, + { + key: "password-login", + name: "Password based login", + description: "Allow members to create accounts with passwords for emails to sign in.", + icon: , + config: , + }, + { + key: "google", + name: "Google", + description: "Allow members to login or sign up to plane with their Google accounts.", + icon: Google Logo, + config: , + }, + { + key: "github", + name: "Github", + description: "Allow members to login or sign up to plane with their Github accounts.", + icon: ( + GitHub Logo + ), + config: , + }, + ]; + + return ( + <> + +
+
+
Manage authentication for your instance
+
+ Configure authentication modes for your team and restrict sign ups to be invite only. +
+
+
+ {formattedConfig ? ( +
+
Sign-up configuration
+
+
+
+
+ Allow anyone to sign up without invite +
+
+ Toggling this off will disable self sign ups. +
+
+
+
+
+ { + Boolean(parseInt(enableSignUpConfig)) === true + ? updateConfig("ENABLE_SIGNUP", "0") + : updateConfig("ENABLE_SIGNUP", "1"); + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+
Authentication modes
+ {authenticationMethodsCard.map((method) => ( + + ))} +
+ ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceAuthenticationPage; diff --git a/admin/app/email/email-config-form.tsx b/admin/app/email/email-config-form.tsx new file mode 100644 index 000000000..8a18b481d --- /dev/null +++ b/admin/app/email/email-config-form.tsx @@ -0,0 +1,222 @@ +import React, { FC, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +// types +import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types"; +// ui +import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { ControllerInput, TControllerInputFormField } from "@/components/common"; +// 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"], + }, + }); + + 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 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 }; + + 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 (optional)
+
+ We recommend setting up a username password for your SMTP server +
+
+
+
+
+ {OptionalEmailFormFields.map((field) => ( + + ))} +
+
+
+
+ + +
+
+ ); +}; diff --git a/admin/app/email/layout.tsx b/admin/app/email/layout.tsx new file mode 100644 index 000000000..64f019ec9 --- /dev/null +++ b/admin/app/email/layout.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; +import { AdminLayout } from "@/layouts/admin-layout"; + +interface EmailLayoutProps { + children: ReactNode; +} + +export const metadata: Metadata = { + title: "Email Settings - God Mode", +}; + +const EmailLayout = ({ children }: EmailLayoutProps) => {children}; + +export default EmailLayout; diff --git a/admin/app/email/page.tsx b/admin/app/email/page.tsx new file mode 100644 index 000000000..198020d4d --- /dev/null +++ b/admin/app/email/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +import { Loader } from "@plane/ui"; +// components +import { PageHeader } from "@/components/core"; +// hooks +import { useInstance } from "@/hooks/store"; +// components +import { InstanceEmailForm } from "./email-config-form"; + +const InstanceEmailPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig } = useInstance(); + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + 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. +
+
+
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceEmailPage; diff --git a/admin/app/email/test-email-modal.tsx b/admin/app/email/test-email-modal.tsx new file mode 100644 index 000000000..6d5cb8032 --- /dev/null +++ b/admin/app/email/test-email-modal.tsx @@ -0,0 +1,135 @@ +import React, { FC, useEffect, useState } from "react"; +import { Dialog, Transition } from "@headlessui/react"; +// ui +import { Button, Input } from "@plane/ui"; +// services +import { InstanceService } from "@/services/instance.service"; + +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/admin/app/error.tsx b/admin/app/error.tsx new file mode 100644 index 000000000..76794e04a --- /dev/null +++ b/admin/app/error.tsx @@ -0,0 +1,9 @@ +"use client"; + +export default function RootErrorPage() { + return ( +
+

Something went wrong.

+
+ ); +} diff --git a/admin/app/general/form.tsx b/admin/app/general/form.tsx new file mode 100644 index 000000000..5646084e2 --- /dev/null +++ b/admin/app/general/form.tsx @@ -0,0 +1,140 @@ +"use client"; +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +import { Controller, useForm } from "react-hook-form"; +import { Telescope } from "lucide-react"; +// types +import { IInstance, IInstanceAdmin } from "@plane/types"; +// ui +import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +// components +import { ControllerInput } from "@/components/common"; +// hooks +import { useInstance } from "@/hooks/store"; + +export interface IGeneralConfigurationForm { + instance: IInstance; + instanceAdmins: IInstanceAdmin[]; +} + +export const GeneralConfigurationForm: FC = observer((props) => { + const { instance, instanceAdmins } = props; + // hooks + const { updateInstanceInfo } = useInstance(); + // form data + const { + handleSubmit, + control, + 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 }; + + console.log("payload", payload); + + 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

+ +
+
+
+ +
+
Telemetry
+
+
+
+
+ +
+
+
+
+ Allow Plane to collect anonymous usage events +
+
+ We collect usage events without any PII to analyse and improve Plane.{" "} + + Know more. + +
+
+
+
+ ( + + )} + /> +
+
+
+ +
+ +
+
+ ); +}); diff --git a/admin/app/general/layout.tsx b/admin/app/general/layout.tsx new file mode 100644 index 000000000..fabbe3640 --- /dev/null +++ b/admin/app/general/layout.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; +import { AdminLayout } from "@/layouts/admin-layout"; + +export const metadata: Metadata = { + title: "General Settings - God Mode", +}; + +export default function GeneralLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/admin/app/general/page.tsx b/admin/app/general/page.tsx new file mode 100644 index 000000000..5aaea9f8e --- /dev/null +++ b/admin/app/general/page.tsx @@ -0,0 +1,31 @@ +"use client"; +import { observer } from "mobx-react-lite"; +// hooks +import { useInstance } from "@/hooks/store"; +// components +import { GeneralConfigurationForm } from "./form"; + +function GeneralPage() { + const { instance, instanceAdmins } = useInstance(); + console.log("instance", instance); + 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/admin/app/globals.css b/admin/app/globals.css new file mode 100644 index 000000000..0a2218c21 --- /dev/null +++ b/admin/app/globals.css @@ -0,0 +1,432 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0&display=swap"); + +@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: 236, 241, 255; + --color-primary-20: 217, 228, 255; + --color-primary-30: 197, 214, 255; + --color-primary-40: 178, 200, 255; + --color-primary-50: 159, 187, 255; + --color-primary-60: 140, 173, 255; + --color-primary-70: 121, 159, 255; + --color-primary-80: 101, 145, 255; + --color-primary-90: 82, 132, 255; + --color-primary-100: 63, 118, 255; + --color-primary-200: 57, 106, 230; + --color-primary-300: 50, 94, 204; + --color-primary-400: 44, 83, 179; + --color-primary-500: 38, 71, 153; + --color-primary-600: 32, 59, 128; + --color-primary-700: 25, 47, 102; + --color-primary-800: 19, 35, 76; + --color-primary-900: 13, 24, 51; + + --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); + } + + [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-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); + + /* 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="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-primary-10: 236, 241, 255; + --color-primary-20: 217, 228, 255; + --color-primary-30: 197, 214, 255; + --color-primary-40: 178, 200, 255; + --color-primary-50: 159, 187, 255; + --color-primary-60: 140, 173, 255; + --color-primary-70: 121, 159, 255; + --color-primary-80: 101, 145, 255; + --color-primary-90: 82, 132, 255; + --color-primary-100: 63, 118, 255; + --color-primary-200: 57, 106, 230; + --color-primary-300: 50, 94, 204; + --color-primary-400: 44, 83, 179; + --color-primary-500: 38, 71, 153; + --color-primary-600: 32, 59, 128; + --color-primary-700: 25, 47, 102; + --color-primary-800: 19, 35, 76; + --color-primary-900: 13, 24, 51; + + --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/admin/app/image/form.tsx b/admin/app/image/form.tsx new file mode 100644 index 000000000..a6fe2945b --- /dev/null +++ b/admin/app/image/form.tsx @@ -0,0 +1,79 @@ +import { FC } from "react"; +import { useForm } from "react-hook-form"; +import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { ControllerInput } from "@/components/common"; +// 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/admin/app/image/layout.tsx b/admin/app/image/layout.tsx new file mode 100644 index 000000000..18e9343b5 --- /dev/null +++ b/admin/app/image/layout.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; +import { AdminLayout } from "@/layouts/admin-layout"; + +interface ImageLayoutProps { + children: ReactNode; +} + +export const metadata: Metadata = { + title: "Images Settings - God Mode", +}; + +const ImageLayout = ({ children }: ImageLayoutProps) => {children}; + +export default ImageLayout; diff --git a/admin/app/image/page.tsx b/admin/app/image/page.tsx new file mode 100644 index 000000000..ceaad61f2 --- /dev/null +++ b/admin/app/image/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +import { Loader } from "@plane/ui"; +// components +import { PageHeader } from "@/components/core"; +// 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/admin/app/layout.tsx b/admin/app/layout.tsx new file mode 100644 index 000000000..e79d0bac8 --- /dev/null +++ b/admin/app/layout.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { ReactNode } from "react"; +import { ThemeProvider, useTheme } from "next-themes"; +import { SWRConfig } from "swr"; +// ui +import { Toast } from "@plane/ui"; +// constants +import { SWR_CONFIG } from "@/constants/swr-config"; +// helpers +import { ASSET_PREFIX, resolveGeneralTheme } from "@/helpers/common.helper"; +// lib +import { InstanceProvider } from "@/lib/instance-provider"; +import { StoreProvider } from "@/lib/store-provider"; +import { UserProvider } from "@/lib/user-provider"; +// styles +import "./globals.css"; + +function RootLayout({ children }: { children: ReactNode }) { + // themes + const { resolvedTheme } = useTheme(); + + return ( + + + + + + + + + + + + + + + {children} + + + + + + + ); +} + +export default RootLayout; diff --git a/admin/app/page.tsx b/admin/app/page.tsx new file mode 100644 index 000000000..b402fc44d --- /dev/null +++ b/admin/app/page.tsx @@ -0,0 +1,30 @@ +import { Metadata } from "next"; +// components +import { InstanceSignInForm } from "@/components/login"; +// layouts +import { DefaultLayout } from "@/layouts/default-layout"; + +export const metadata: Metadata = { + title: "Plane | Simple, extensible, open-source project management tool.", + description: + "Open-source project management tool to manage issues, 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 issues, 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, issue tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + }, +}; + +export default async function LoginPage() { + return ( + + + + ); +} diff --git a/web/components/instance/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx similarity index 52% rename from web/components/instance/help-section.tsx rename to admin/components/admin-sidebar/help-section.tsx index 635dc8264..d2b3cc492 100644 --- a/web/components/instance/help-section.tsx +++ b/admin/components/admin-sidebar/help-section.tsx @@ -1,11 +1,16 @@ +"use client"; + import { FC, useState, useRef } from "react"; -import { Transition } from "@headlessui/react"; +import { observer } from "mobx-react-lite"; import Link from "next/link"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// icons -import { FileText, HelpCircle, MessagesSquare, MoveLeft } from "lucide-react"; -import { DiscordIcon, GithubIcon } from "@plane/ui"; +import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; +import { Transition } from "@headlessui/react"; +// ui +import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; +// helpers +import { WEB_BASE_URL, cn } from "@/helpers/common.helper"; +// hooks +import { useTheme } from "@/hooks/store"; // assets import packageJson from "package.json"; @@ -25,56 +30,59 @@ const helpOptions = [ href: "https://github.com/makeplane/plane/issues/new/choose", Icon: GithubIcon, }, - { - name: "Chat with us", - href: null, - onClick: () => (window as any).$crisp.push(["do", "chat:show"]), - Icon: MessagesSquare, - }, ]; -export const InstanceHelpSection: FC = () => { +export const HelpSection: FC = observer(() => { // states const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); // store - const { - theme: { sidebarCollapsed, toggleSidebar }, - } = useMobxStore(); + const { isSidebarCollapsed, toggleSidebar } = useTheme(); // refs const helpOptionsRef = useRef(null); + const redirectionLink = encodeURI(WEB_BASE_URL + "/"); + return (
-
- - - +
+ + + + {!isSidebarCollapsed && "Redirect to plane"} + + + + + + + +
@@ -89,12 +97,12 @@ export const InstanceHelpSection: FC = () => { >
- {helpOptions.map(({ name, Icon, href, onClick }) => { + {helpOptions.map(({ name, Icon, href }) => { if (href) return ( @@ -111,7 +119,6 @@ export const InstanceHelpSection: FC = () => {
); -}; +}); diff --git a/admin/components/admin-sidebar/index.ts b/admin/components/admin-sidebar/index.ts new file mode 100644 index 000000000..e800fe3c5 --- /dev/null +++ b/admin/components/admin-sidebar/index.ts @@ -0,0 +1,5 @@ +export * from "./root"; +export * from "./help-section"; +export * from "./sidebar-menu"; +export * from "./sidebar-dropdown"; +export * from "./sidebar-menu-hamburger-toogle"; diff --git a/admin/components/admin-sidebar/root.tsx b/admin/components/admin-sidebar/root.tsx new file mode 100644 index 000000000..ff94bf228 --- /dev/null +++ b/admin/components/admin-sidebar/root.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { FC, useEffect, useRef } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar"; +import { useTheme } from "@/hooks/store"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components + +export interface IInstanceSidebar {} + +export const InstanceSidebar: 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/admin/components/admin-sidebar/sidebar-dropdown.tsx b/admin/components/admin-sidebar/sidebar-dropdown.tsx new file mode 100644 index 000000000..84583e24b --- /dev/null +++ b/admin/components/admin-sidebar/sidebar-dropdown.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Fragment, useEffect, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { useTheme as useNextTheme } from "next-themes"; +import { LogOut, UserCog2, Palette } from "lucide-react"; +import { Menu, Transition } from "@headlessui/react"; +import { Avatar } from "@plane/ui"; +// hooks +import { API_BASE_URL, cn } from "@/helpers/common.helper"; +import { useTheme, useUser } from "@/hooks/store"; +// helpers +// services +import { AuthService } from "@/services/auth.service"; + +// service initialization +const authService = new AuthService(); + +export const SidebarDropdown = 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/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx b/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx new file mode 100644 index 000000000..2e8539488 --- /dev/null +++ b/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { Menu } from "lucide-react"; +import { useTheme } from "@/hooks/store"; +// icons + +export const SidebarHamburgerToggle: FC = observer(() => { + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + return ( +
toggleSidebar(!isSidebarCollapsed)} + > + +
+ ); +}); diff --git a/admin/components/admin-sidebar/sidebar-menu.tsx b/admin/components/admin-sidebar/sidebar-menu.tsx new file mode 100644 index 000000000..a821243b8 --- /dev/null +++ b/admin/components/admin-sidebar/sidebar-menu.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; +import { Tooltip } from "@plane/ui"; +// hooks +import { cn } from "@/helpers/common.helper"; +import { useTheme } from "@/hooks/store"; +// helpers + +const INSTANCE_ADMIN_LINKS = [ + { + Icon: Cog, + name: "General", + description: "Identify your instances and get key details", + href: `/general/`, + }, + { + Icon: Mail, + name: "Email", + description: "Set up emails to your users", + 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 SidebarMenu = 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/admin/components/auth-header.tsx b/admin/components/auth-header.tsx new file mode 100644 index 000000000..4becf928f --- /dev/null +++ b/admin/components/auth-header.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +import { usePathname } from "next/navigation"; +// mobx +// ui +import { Settings } from "lucide-react"; +// icons +import { Breadcrumbs } from "@plane/ui"; +// components +import { SidebarHamburgerToggle } from "@/components/admin-sidebar"; +import { BreadcrumbLink } from "components/common"; + +export const InstanceHeader: 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"; + 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/admin/components/common/banner.tsx b/admin/components/common/banner.tsx new file mode 100644 index 000000000..932a0c629 --- /dev/null +++ b/admin/components/common/banner.tsx @@ -0,0 +1,32 @@ +import { 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/admin/components/common/breadcrumb-link.tsx b/admin/components/common/breadcrumb-link.tsx new file mode 100644 index 000000000..dfa437231 --- /dev/null +++ b/admin/components/common/breadcrumb-link.tsx @@ -0,0 +1,36 @@ +import Link from "next/link"; +import { Tooltip } from "@plane/ui"; + +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/admin/components/common/confirm-discard-modal.tsx b/admin/components/common/confirm-discard-modal.tsx new file mode 100644 index 000000000..64e4d7a08 --- /dev/null +++ b/admin/components/common/confirm-discard-modal.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import Link from "next/link"; +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// ui +import { Button, getButtonStyling } from "@plane/ui"; + +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/admin/components/common/controller-input.tsx b/admin/components/common/controller-input.tsx new file mode 100644 index 000000000..0eb215095 --- /dev/null +++ b/admin/components/common/controller-input.tsx @@ -0,0 +1,86 @@ +"use client"; + +import React, { useState } from "react"; +import { Controller, Control } from "react-hook-form"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// ui +import { Input } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type Props = { + control: Control; + type: "text" | "password"; + name: string; + label: string; + description?: string | JSX.Element; + placeholder: string; + error: boolean; + required: boolean; +}; + +export type TControllerInputFormField = { + key: string; + type: "text" | "password"; + label: string; + description?: string | JSX.Element; + 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} {!required && "(optional)"} +

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

    {description}

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

    {label}

    + +
    {description}
    +
    + ); +}; diff --git a/admin/components/common/empty-state.tsx b/admin/components/common/empty-state.tsx new file mode 100644 index 000000000..fbbe0bc0f --- /dev/null +++ b/admin/components/common/empty-state.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import Image from "next/image"; +import { Button } from "@plane/ui"; + +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/admin/components/common/index.ts b/admin/components/common/index.ts new file mode 100644 index 000000000..c810cac69 --- /dev/null +++ b/admin/components/common/index.ts @@ -0,0 +1,9 @@ +export * from "./breadcrumb-link"; +export * from "./confirm-discard-modal"; +export * from "./controller-input"; +export * from "./copy-field"; +export * from "./password-strength-meter"; +export * from "./banner"; +export * from "./empty-state"; +export * from "./logo-spinner"; +export * from "./toast"; diff --git a/admin/components/common/logo-spinner.tsx b/admin/components/common/logo-spinner.tsx new file mode 100644 index 000000000..621b685b8 --- /dev/null +++ b/admin/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" ? LogoSpinnerDark : LogoSpinnerLight; + + return ( +
    + logo +
    + ); +}; diff --git a/admin/components/common/password-strength-meter.tsx b/admin/components/common/password-strength-meter.tsx new file mode 100644 index 000000000..004a927b2 --- /dev/null +++ b/admin/components/common/password-strength-meter.tsx @@ -0,0 +1,69 @@ +"use client"; + +// helpers +import { CircleCheck } from "lucide-react"; +import { cn } from "@/helpers/common.helper"; +import { getPasswordStrength } from "@/helpers/password.helper"; +// icons + +type Props = { + password: string; +}; + +export const PasswordStrengthMeter: React.FC = (props: Props) => { + const { password } = props; + + const strength = getPasswordStrength(password); + let bars = []; + let text = ""; + let textColor = ""; + + if (password.length === 0) { + bars = [`bg-[#F0F0F3]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; + text = "Password requirements"; + } else if (password.length < 8) { + bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; + text = "Password is too short"; + textColor = `text-[#DC3E42]`; + } else if (strength < 3) { + bars = [`bg-[#FFBA18]`, `bg-[#FFBA18]`, `bg-[#F0F0F3]`]; + text = "Password is weak"; + textColor = `text-[#FFBA18]`; + } else { + bars = [`bg-[#3E9B4F]`, `bg-[#3E9B4F]`, `bg-[#3E9B4F]`]; + text = "Password is strong"; + textColor = `text-[#3E9B4F]`; + } + + const criteria = [ + { label: "Min 8 characters", isValid: password.length >= 8 }, + { label: "Min 1 upper-case letter", isValid: /[A-Z]/.test(password) }, + { label: "Min 1 number", isValid: /\d/.test(password) }, + { label: "Min 1 special character", isValid: /[!@#$%^&*]/.test(password) }, + ]; + + return ( +
    +
    + {bars.map((color, index) => ( +
    + ))} +
    +

    {text}

    +
    + {criteria.map((criterion, index) => ( +
    + + {criterion.label} +
    + ))} +
    +
    + ); +}; diff --git a/admin/components/common/toast.tsx b/admin/components/common/toast.tsx new file mode 100644 index 000000000..fe4983db6 --- /dev/null +++ b/admin/components/common/toast.tsx @@ -0,0 +1,11 @@ +import { useTheme } from "next-themes"; +// ui +import { Toast as ToastComponent } from "@plane/ui"; +// helpers +import { resolveGeneralTheme } from "@/helpers/common.helper"; + +export const Toast = () => { + const { theme } = useTheme(); + + return ; +}; diff --git a/admin/components/core/index.ts b/admin/components/core/index.ts new file mode 100644 index 000000000..d32aafe96 --- /dev/null +++ b/admin/components/core/index.ts @@ -0,0 +1 @@ +export * from "./page-header"; diff --git a/admin/components/core/page-header.tsx b/admin/components/core/page-header.tsx new file mode 100644 index 000000000..a4b27b92f --- /dev/null +++ b/admin/components/core/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/admin/components/instance/index.ts b/admin/components/instance/index.ts new file mode 100644 index 000000000..56d1933f4 --- /dev/null +++ b/admin/components/instance/index.ts @@ -0,0 +1,3 @@ +export * from "./instance-not-ready"; +export * from "./instance-failure-view"; +export * from "./setup-form"; diff --git a/admin/components/instance/instance-failure-view.tsx b/admin/components/instance/instance-failure-view.tsx new file mode 100644 index 000000000..8722929b5 --- /dev/null +++ b/admin/components/instance/instance-failure-view.tsx @@ -0,0 +1,42 @@ +"use client"; +import { FC } from "react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import { Button } from "@plane/ui"; +// assets +import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg"; +import InstanceFailureImage from "@/public/instance/instance-failure.svg"; + +type InstanceFailureViewProps = { + // mutate: () => void; +}; + +export const InstanceFailureView: FC = () => { + 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/admin/components/instance/instance-not-ready.tsx b/admin/components/instance/instance-not-ready.tsx new file mode 100644 index 000000000..874013f52 --- /dev/null +++ b/admin/components/instance/instance-not-ready.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { FC } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Button } from "@plane/ui"; +// 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/admin/components/instance/setup-form.tsx b/admin/components/instance/setup-form.tsx new file mode 100644 index 000000000..77bf8e562 --- /dev/null +++ b/admin/components/instance/setup-form.tsx @@ -0,0 +1,353 @@ +"use client"; + +import { FC, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// ui +import { Button, Checkbox, Input, Spinner } from "@plane/ui"; +// components +import { Banner, PasswordStrengthMeter } from "@/components/common"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +import { getPasswordStrength } from "@/helpers/password.helper"; +// services +import { AuthService } from "@/services/auth.service"; + +// 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) >= 3 && + 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 ( +
    +
    +
    +

    + Setup your Plane Instance +

    +

    + Post setup you will be able to manage this Plane instance. +

    +
    + + {errorData.type && + errorData?.message && + ![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && ( + + )} + +
    setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + + + +
    +
    + + handleFormChange("first_name", e.target.value)} + autoFocus + /> +
    +
    + + handleFormChange("last_name", e.target.value)} + /> +
    +
    + +
    + + handleFormChange("email", e.target.value)} + hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false} + /> + {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)} + /> + {showPassword.password ? ( + + ) : ( + + )} +
    + {errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && ( +

    {errorData.message}

    + )} + {isPasswordInputFocused && } +
    + +
    + +
    + handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-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} + /> +
    + + + See More + +
    + +
    + +
    +
    +
    +
    + ); +}; diff --git a/web/components/instance/setup-form/index.ts b/admin/components/login/index.ts similarity index 57% rename from web/components/instance/setup-form/index.ts rename to admin/components/login/index.ts index e9a965d6d..bdeb387f3 100644 --- a/web/components/instance/setup-form/index.ts +++ b/admin/components/login/index.ts @@ -1,2 +1 @@ -export * from "./root"; export * from "./sign-in-form"; diff --git a/admin/components/login/sign-in-form.tsx b/admin/components/login/sign-in-form.tsx new file mode 100644 index 000000000..45d448d12 --- /dev/null +++ b/admin/components/login/sign-in-form.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { FC, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +// services +import { Eye, EyeOff } from "lucide-react"; +import { Button, Input, Spinner } from "@plane/ui"; +// components +import { Banner } from "@/components/common"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +import { AuthService } from "@/services/auth.service"; +// ui +// icons + +// 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 = (props) => { + const {} = props; + // 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 handleFormChange = (key: keyof TFormData, value: string | boolean) => + setFormData((prev) => ({ ...prev, [key]: value })); + + console.log("csrfToken", csrfToken); + + 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.INVALID_EMAIL, 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] + ); + + return ( +
    +
    +
    +

    + Manage your Plane instance +

    +

    + Configure instance-wide settings to secure your instance +

    +
    + + {errorData.type && errorData?.message && } + +
    setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + + +
    + + handleFormChange("email", e.target.value)} + autoFocus + /> +
    + +
    + +
    + handleFormChange("password", e.target.value)} + /> + {showPassword ? ( + + ) : ( + + )} +
    +
    +
    + +
    +
    +
    +
    + ); +}; diff --git a/admin/components/new-user-popup.tsx b/admin/components/new-user-popup.tsx new file mode 100644 index 000000000..840de0c3a --- /dev/null +++ b/admin/components/new-user-popup.tsx @@ -0,0 +1,55 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import { useTheme as nextUseTheme } from "next-themes"; +// ui +import { Button, getButtonStyling } from "@plane/ui"; +// helpers +import { WEB_BASE_URL, resolveGeneralTheme } from "helpers/common.helper"; +// hooks +import { useTheme } from "@/hooks/store"; +// icons +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(); + + const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace"); + + if (!isNewUserPopup) return <>; + return ( +
    +
    +
    +
    Create workspace
    +
    + Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first + workspace, you will need to login again. +
    +
    + + Create workspace + + +
    +
    +
    + Plane icon +
    +
    +
    + ); +}); diff --git a/admin/constants/seo.ts b/admin/constants/seo.ts new file mode 100644 index 000000000..aafd5f7a3 --- /dev/null +++ b/admin/constants/seo.ts @@ -0,0 +1,8 @@ +export const SITE_NAME = "Plane | Simple, extensible, open-source project management tool."; +export const SITE_TITLE = "Plane | Simple, extensible, open-source project management tool."; +export const SITE_DESCRIPTION = + "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind."; +export const SITE_KEYWORDS = + "software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration"; +export const SITE_URL = "https://app.plane.so/"; +export const TWITTER_USER_NAME = "Plane | Simple, extensible, open-source project management tool."; diff --git a/admin/constants/swr-config.ts b/admin/constants/swr-config.ts new file mode 100644 index 000000000..38478fcea --- /dev/null +++ b/admin/constants/swr-config.ts @@ -0,0 +1,8 @@ +export const SWR_CONFIG = { + refreshWhenHidden: false, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnMount: true, + refreshInterval: 600000, + errorRetryCount: 3, +}; diff --git a/admin/helpers/authentication.helper.tsx b/admin/helpers/authentication.helper.tsx new file mode 100644 index 000000000..cc9058611 --- /dev/null +++ b/admin/helpers/authentication.helper.tsx @@ -0,0 +1,136 @@ +import { ReactNode } from "react"; +import Link from "next/link"; +// helpers +import { SUPPORT_EMAIL } from "./common.helper"; + +export enum EPageTypes { + PUBLIC = "PUBLIC", + NON_AUTHENTICATED = "NON_AUTHENTICATED", + SET_PASSWORD = "SET_PASSWORD", + ONBOARDING = "ONBOARDING", + AUTHENTICATED = "AUTHENTICATED", +} + +export enum EAuthModes { + SIGN_IN = "SIGN_IN", + SIGN_UP = "SIGN_UP", +} + +export enum EAuthSteps { + EMAIL = "EMAIL", + PASSWORD = "PASSWORD", + UNIQUE_CODE = "UNIQUE_CODE", +} + +export enum EErrorAlertType { + BANNER_ALERT = "BANNER_ALERT", + INLINE_FIRST_NAME = "INLINE_FIRST_NAME", + INLINE_EMAIL = "INLINE_EMAIL", + INLINE_PASSWORD = "INLINE_PASSWORD", + INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", +} + +export enum EAuthenticationErrorCodes { + // 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", +} + +export type TAuthErrorInfo = { + type: EErrorAlertType; + code: EAuthenticationErrorCodes; + title: string; + message: ReactNode; +}; + +const errorCodeMessages: { + [key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; +} = { + // 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. +
    + ), + }, + [EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED]: { + title: `User account deactivated`, + message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`, + }, +}; + +export const authErrorHandler = ( + errorCode: EAuthenticationErrorCodes, + email?: string | undefined +): TAuthErrorInfo | undefined => { + const bannerAlertErrorCodes = [ + 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.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; +}; diff --git a/admin/helpers/common.helper.ts b/admin/helpers/common.helper.ts new file mode 100644 index 000000000..e282e5792 --- /dev/null +++ b/admin/helpers/common.helper.ts @@ -0,0 +1,20 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; + +export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || ""; + +export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || ""; +export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || ""; + +export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || ""; + +export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || ""; + +export const ASSET_PREFIX = ADMIN_BASE_PATH; + +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/admin/helpers/index.ts b/admin/helpers/index.ts new file mode 100644 index 000000000..ae6aab829 --- /dev/null +++ b/admin/helpers/index.ts @@ -0,0 +1,2 @@ +export * from "./instance.helper"; +export * from "./user.helper"; diff --git a/admin/helpers/instance.helper.ts b/admin/helpers/instance.helper.ts new file mode 100644 index 000000000..f929b2211 --- /dev/null +++ b/admin/helpers/instance.helper.ts @@ -0,0 +1,9 @@ +export enum EInstanceStatus { + ERROR = "ERROR", + NOT_YET_READY = "NOT_YET_READY", +} + +export type TInstanceStatus = { + status: EInstanceStatus | undefined; + data?: object; +}; diff --git a/admin/helpers/password.helper.ts b/admin/helpers/password.helper.ts new file mode 100644 index 000000000..8d80b3402 --- /dev/null +++ b/admin/helpers/password.helper.ts @@ -0,0 +1,16 @@ +import zxcvbn from "zxcvbn"; + +export const isPasswordCriteriaMet = (password: string) => { + const criteria = [password.length >= 8, /[A-Z]/.test(password), /\d/.test(password), /[!@#$%^&*]/.test(password)]; + + return criteria.every((criterion) => criterion); +}; + +export const getPasswordStrength = (password: string) => { + if (password.length === 0) return 0; + if (password.length < 8) return 1; + if (!isPasswordCriteriaMet(password)) return 2; + + const result = zxcvbn(password); + return result.score; +}; diff --git a/admin/helpers/user.helper.ts b/admin/helpers/user.helper.ts new file mode 100644 index 000000000..5c6a89a17 --- /dev/null +++ b/admin/helpers/user.helper.ts @@ -0,0 +1,21 @@ +export enum EAuthenticationPageType { + STATIC = "STATIC", + NOT_AUTHENTICATED = "NOT_AUTHENTICATED", + AUTHENTICATED = "AUTHENTICATED", +} + +export enum EInstancePageType { + PRE_SETUP = "PRE_SETUP", + POST_SETUP = "POST_SETUP", +} + +export enum EUserStatus { + ERROR = "ERROR", + AUTHENTICATION_NOT_DONE = "AUTHENTICATION_NOT_DONE", + NOT_YET_READY = "NOT_YET_READY", +} + +export type TUserStatus = { + status: EUserStatus | undefined; + message?: string; +}; diff --git a/admin/hooks/store/index.ts b/admin/hooks/store/index.ts new file mode 100644 index 000000000..7447064da --- /dev/null +++ b/admin/hooks/store/index.ts @@ -0,0 +1,3 @@ +export * from "./use-theme"; +export * from "./use-instance"; +export * from "./use-user"; diff --git a/admin/hooks/store/use-instance.tsx b/admin/hooks/store/use-instance.tsx new file mode 100644 index 000000000..cf2edc39f --- /dev/null +++ b/admin/hooks/store/use-instance.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/lib/store-provider"; +import { 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/admin/hooks/store/use-theme.tsx b/admin/hooks/store/use-theme.tsx new file mode 100644 index 000000000..bad89cfee --- /dev/null +++ b/admin/hooks/store/use-theme.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/lib/store-provider"; +import { 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/admin/hooks/store/use-user.tsx b/admin/hooks/store/use-user.tsx new file mode 100644 index 000000000..823003144 --- /dev/null +++ b/admin/hooks/store/use-user.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/lib/store-provider"; +import { 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/admin/hooks/use-outside-click-detector.tsx b/admin/hooks/use-outside-click-detector.tsx new file mode 100644 index 000000000..b7b48c857 --- /dev/null +++ b/admin/hooks/use-outside-click-detector.tsx @@ -0,0 +1,21 @@ +"use client"; + +import React, { useEffect } from "react"; + +const useOutsideClickDetector = (ref: React.RefObject, callback: () => void) => { + const handleClick = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(); + } + }; + + useEffect(() => { + document.addEventListener("mousedown", handleClick); + + return () => { + document.removeEventListener("mousedown", handleClick); + }; + }); +}; + +export default useOutsideClickDetector; diff --git a/admin/layouts/admin-layout.tsx b/admin/layouts/admin-layout.tsx new file mode 100644 index 000000000..bcc103217 --- /dev/null +++ b/admin/layouts/admin-layout.tsx @@ -0,0 +1,47 @@ +"use client"; +import { FC, ReactNode, useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/navigation"; +// components +import { InstanceSidebar } from "@/components/admin-sidebar"; +import { InstanceHeader } from "@/components/auth-header"; +import { LogoSpinner } from "@/components/common"; +import { NewUserPopup } from "@/components/new-user-popup"; +// hooks +import { useUser } from "@/hooks/store"; + +type TAdminLayout = { + children: ReactNode; +}; + +export const AdminLayout: FC = observer((props) => { + const { children } = props; + // router + const router = useRouter(); + const { isUserLoggedIn } = useUser(); + + useEffect(() => { + if (isUserLoggedIn === false) { + router.push("/"); + } + }, [router, isUserLoggedIn]); + + if (isUserLoggedIn === undefined) { + return ( +
    + +
    + ); + } + + return ( +
    + +
    + +
    {children}
    +
    + +
    + ); +}); diff --git a/admin/layouts/default-layout.tsx b/admin/layouts/default-layout.tsx new file mode 100644 index 000000000..1be40ea12 --- /dev/null +++ b/admin/layouts/default-layout.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +// logo/ images +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"; + +type TDefaultLayout = { + children: ReactNode; + withoutBackground?: boolean; +}; + +export const DefaultLayout: FC = (props) => { + const { children, withoutBackground = false } = props; + // hooks + const { resolvedTheme } = useTheme(); + const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( +
    +
    +
    +
    + + Plane logo + +
    +
    + {!withoutBackground && ( +
    + Plane background pattern +
    + )} +
    {children}
    +
    +
    + ); +}; diff --git a/admin/lib/instance-provider.tsx b/admin/lib/instance-provider.tsx new file mode 100644 index 000000000..fbcf27d82 --- /dev/null +++ b/admin/lib/instance-provider.tsx @@ -0,0 +1,55 @@ +import { FC, ReactNode } from "react"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +// components +import { LogoSpinner } from "@/components/common"; +import { InstanceSetupForm, InstanceFailureView } from "@/components/instance"; +// hooks +import { useInstance } from "@/hooks/store"; +// layout +import { DefaultLayout } from "@/layouts/default-layout"; + +type InstanceProviderProps = { + children: ReactNode; +}; + +export const InstanceProvider: FC = observer((props) => { + const { children } = props; + // store hooks + const { instance, error, fetchInstanceInfo } = useInstance(); + // fetching instance details + useSWR("INSTANCE_DETAILS", () => fetchInstanceInfo(), { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + }); + + if (!instance && !error) + return ( +
    + +
    + ); + + if (error) { + return ( + +
    + +
    +
    + ); + } + + if (!instance?.is_setup_done) { + return ( + +
    + +
    +
    + ); + } + + return <>{children}; +}); diff --git a/admin/lib/store-provider.tsx b/admin/lib/store-provider.tsx new file mode 100644 index 000000000..842513860 --- /dev/null +++ b/admin/lib/store-provider.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { ReactNode, createContext } from "react"; +// store +import { RootStore } from "@/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/admin/lib/user-provider.tsx b/admin/lib/user-provider.tsx new file mode 100644 index 000000000..d8448d13e --- /dev/null +++ b/admin/lib/user-provider.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { FC, ReactNode, useEffect } from "react"; +import { observer } from "mobx-react-lite"; +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/admin/next-env.d.ts b/admin/next-env.d.ts new file mode 100644 index 000000000..4f11a03dc --- /dev/null +++ b/admin/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/admin/next.config.js b/admin/next.config.js new file mode 100644 index 000000000..2109cec69 --- /dev/null +++ b/admin/next.config.js @@ -0,0 +1,14 @@ +/** @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 || "", +}; + +module.exports = nextConfig; diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 000000000..9c4567070 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,50 @@ +{ + "name": "admin", + "version": "0.21.0", + "private": true, + "scripts": { + "dev": "turbo run develop", + "develop": "next dev --port 3001", + "build": "next build", + "preview": "next build && next start", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@headlessui/react": "^1.7.19", + "@plane/types": "*", + "@plane/ui": "*", + "@plane/constants": "*", + "@tailwindcss/typography": "^0.5.9", + "@types/lodash": "^4.17.0", + "autoprefixer": "10.4.14", + "axios": "^1.6.7", + "js-cookie": "^3.0.5", + "lodash": "^4.17.21", + "lucide-react": "^0.356.0", + "mobx": "^6.12.0", + "mobx-react-lite": "^4.0.5", + "next": "^14.2.3", + "next-themes": "^0.2.1", + "postcss": "^8.4.38", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.51.0", + "swr": "^2.2.4", + "tailwindcss": "3.3.2", + "uuid": "^9.0.1", + "zxcvbn": "^4.4.2" + }, + "devDependencies": { + "@types/js-cookie": "^3.0.6", + "@types/node": "18.16.1", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@types/uuid": "^9.0.8", + "@types/zxcvbn": "^4.4.4", + "eslint-config-custom": "*", + "tailwind-config-custom": "*", + "tsconfig": "*", + "typescript": "^5.4.2" + } +} diff --git a/admin/postcss.config.js b/admin/postcss.config.js new file mode 100644 index 000000000..6887c8262 --- /dev/null +++ b/admin/postcss.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + "postcss-import": {}, + "tailwindcss/nesting": {}, + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/admin/public/auth/background-pattern-dark.svg b/admin/public/auth/background-pattern-dark.svg new file mode 100644 index 000000000..c258cbabf --- /dev/null +++ b/admin/public/auth/background-pattern-dark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/auth/background-pattern.svg b/admin/public/auth/background-pattern.svg new file mode 100644 index 000000000..5fcbeec27 --- /dev/null +++ b/admin/public/auth/background-pattern.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/favicon/android-chrome-192x192.png b/admin/public/favicon/android-chrome-192x192.png new file mode 100644 index 000000000..62e95acfc Binary files /dev/null and b/admin/public/favicon/android-chrome-192x192.png differ diff --git a/admin/public/favicon/android-chrome-512x512.png b/admin/public/favicon/android-chrome-512x512.png new file mode 100644 index 000000000..41400832b Binary files /dev/null and b/admin/public/favicon/android-chrome-512x512.png differ diff --git a/admin/public/favicon/apple-touch-icon.png b/admin/public/favicon/apple-touch-icon.png new file mode 100644 index 000000000..5273d4951 Binary files /dev/null and b/admin/public/favicon/apple-touch-icon.png differ diff --git a/admin/public/favicon/favicon-16x16.png b/admin/public/favicon/favicon-16x16.png new file mode 100644 index 000000000..8ddbd49c0 Binary files /dev/null and b/admin/public/favicon/favicon-16x16.png differ diff --git a/admin/public/favicon/favicon-32x32.png b/admin/public/favicon/favicon-32x32.png new file mode 100644 index 000000000..80cbe7a68 Binary files /dev/null and b/admin/public/favicon/favicon-32x32.png differ diff --git a/admin/public/favicon/favicon.ico b/admin/public/favicon/favicon.ico new file mode 100644 index 000000000..9094a07c7 Binary files /dev/null and b/admin/public/favicon/favicon.ico differ diff --git a/admin/public/favicon/site.webmanifest b/admin/public/favicon/site.webmanifest new file mode 100644 index 000000000..0b08af126 --- /dev/null +++ b/admin/public/favicon/site.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/admin/public/images/logo-spinner-dark.gif b/admin/public/images/logo-spinner-dark.gif new file mode 100644 index 000000000..4e0a1deb7 Binary files /dev/null and b/admin/public/images/logo-spinner-dark.gif differ diff --git a/admin/public/images/logo-spinner-light.gif b/admin/public/images/logo-spinner-light.gif new file mode 100644 index 000000000..7c9bfbe0e Binary files /dev/null and b/admin/public/images/logo-spinner-light.gif differ diff --git a/admin/public/images/plane-takeoff.png b/admin/public/images/plane-takeoff.png new file mode 100644 index 000000000..417ff8299 Binary files /dev/null and b/admin/public/images/plane-takeoff.png differ diff --git a/admin/public/instance/instance-failure-dark.svg b/admin/public/instance/instance-failure-dark.svg new file mode 100644 index 000000000..58d691705 --- /dev/null +++ b/admin/public/instance/instance-failure-dark.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/instance/instance-failure.svg b/admin/public/instance/instance-failure.svg new file mode 100644 index 000000000..a59862283 --- /dev/null +++ b/admin/public/instance/instance-failure.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/instance/plane-takeoff.png b/admin/public/instance/plane-takeoff.png new file mode 100644 index 000000000..417ff8299 Binary files /dev/null and b/admin/public/instance/plane-takeoff.png differ diff --git a/admin/public/logos/github-black.png b/admin/public/logos/github-black.png new file mode 100644 index 000000000..7a7a82474 Binary files /dev/null and b/admin/public/logos/github-black.png differ diff --git a/admin/public/logos/github-white.png b/admin/public/logos/github-white.png new file mode 100644 index 000000000..dbb2b578c Binary files /dev/null and b/admin/public/logos/github-white.png differ diff --git a/admin/public/logos/google-logo.svg b/admin/public/logos/google-logo.svg new file mode 100644 index 000000000..088288fa3 --- /dev/null +++ b/admin/public/logos/google-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/public/logos/takeoff-icon-dark.svg b/admin/public/logos/takeoff-icon-dark.svg new file mode 100644 index 000000000..d3ef19119 --- /dev/null +++ b/admin/public/logos/takeoff-icon-dark.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/logos/takeoff-icon-light.svg b/admin/public/logos/takeoff-icon-light.svg new file mode 100644 index 000000000..97cf43fe7 --- /dev/null +++ b/admin/public/logos/takeoff-icon-light.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/plane-logos/black-horizontal-with-blue-logo.png b/admin/public/plane-logos/black-horizontal-with-blue-logo.png new file mode 100644 index 000000000..c14505a6f Binary files /dev/null and b/admin/public/plane-logos/black-horizontal-with-blue-logo.png differ diff --git a/admin/public/plane-logos/blue-without-text.png b/admin/public/plane-logos/blue-without-text.png new file mode 100644 index 000000000..ea94aec79 Binary files /dev/null and b/admin/public/plane-logos/blue-without-text.png differ diff --git a/admin/public/plane-logos/white-horizontal-with-blue-logo.png b/admin/public/plane-logos/white-horizontal-with-blue-logo.png new file mode 100644 index 000000000..97560fb9f Binary files /dev/null and b/admin/public/plane-logos/white-horizontal-with-blue-logo.png differ diff --git a/admin/public/site.webmanifest.json b/admin/public/site.webmanifest.json new file mode 100644 index 000000000..6e5e438f8 --- /dev/null +++ b/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/admin/services/api.service.ts b/admin/services/api.service.ts new file mode 100644 index 000000000..fa45c10b7 --- /dev/null +++ b/admin/services/api.service.ts @@ -0,0 +1,53 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; +// store +// import { rootStore } from "@/lib/store-context"; + +export abstract class APIService { + protected baseURL: string; + private axiosInstance: AxiosInstance; + + constructor(baseURL: string) { + this.baseURL = baseURL; + this.axiosInstance = axios.create({ + baseURL, + withCredentials: true, + }); + + this.setupInterceptors(); + } + + private setupInterceptors() { + // this.axiosInstance.interceptors.response.use( + // (response) => response, + // (error) => { + // const store = rootStore; + // if (error.response && error.response.status === 401 && store.user.currentUser) store.user.reset(); + // return Promise.reject(error); + // } + // ); + } + + get(url: string, params = {}): Promise> { + return this.axiosInstance.get(url, { params }); + } + + post(url: string, data: RequestType, config = {}): Promise> { + return this.axiosInstance.post(url, data, config); + } + + put(url: string, data: RequestType, config = {}): Promise> { + return this.axiosInstance.put(url, data, config); + } + + patch(url: string, data: RequestType, config = {}): Promise> { + return this.axiosInstance.patch(url, data, config); + } + + delete(url: string, data?: RequestType, config = {}) { + return this.axiosInstance.delete(url, { data, ...config }); + } + + request(config: AxiosRequestConfig = {}): Promise> { + return this.axiosInstance(config); + } +} diff --git a/admin/services/auth.service.ts b/admin/services/auth.service.ts new file mode 100644 index 000000000..ef7b7b151 --- /dev/null +++ b/admin/services/auth.service.ts @@ -0,0 +1,22 @@ +// helpers +import { API_BASE_URL } from "helpers/common.helper"; +// services +import { APIService } from "services/api.service"; + +type TCsrfTokenResponse = { + csrf_token: string; +}; + +export class AuthService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async requestCSRFToken(): Promise { + return this.get("/auth/get-csrf-token/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } +} diff --git a/admin/services/instance.service.ts b/admin/services/instance.service.ts new file mode 100644 index 000000000..feb94ceea --- /dev/null +++ b/admin/services/instance.service.ts @@ -0,0 +1,72 @@ +// types +import type { + IFormattedInstanceConfiguration, + IInstance, + IInstanceAdmin, + IInstanceConfiguration, + IInstanceInfo, +} from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +import { APIService } from "@/services/api.service"; + +export class InstanceService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getInstanceInfo(): Promise { + return this.get("/api/instances/") + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getInstanceAdmins(): Promise { + return this.get("/api/instances/admins/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + async updateInstanceInfo(data: Partial): Promise { + return this.patch, IInstance>("/api/instances/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getInstanceConfigurations() { + return this.get("/api/instances/configurations/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + async updateInstanceConfigurations( + data: Partial + ): Promise { + return this.patch, IInstanceConfiguration[]>( + "/api/instances/configurations/", + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async sendTestEmail(receiverEmail: string): Promise { + return this.post<{ receiver_email: string }, undefined>("/api/instances/email-credentials-check/", { + receiver_email: receiverEmail, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/admin/services/user.service.ts b/admin/services/user.service.ts new file mode 100644 index 000000000..bef384daf --- /dev/null +++ b/admin/services/user.service.ts @@ -0,0 +1,30 @@ +// helpers +import { API_BASE_URL } from "helpers/common.helper"; +// services +import { APIService } from "services/api.service"; +// types +import type { IUser } from "@plane/types"; + +interface IUserSession extends IUser { + isAuthenticated: boolean; +} + +export class UserService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async authCheck(): Promise { + return this.get("/api/instances/admins/me/") + .then((response) => ({ ...response?.data, isAuthenticated: true })) + .catch(() => ({ isAuthenticated: false })); + } + + async currentUser(): Promise { + return this.get("/api/instances/admins/me/") + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} diff --git a/admin/store/instance.store.ts b/admin/store/instance.store.ts new file mode 100644 index 000000000..a99cd808c --- /dev/null +++ b/admin/store/instance.store.ts @@ -0,0 +1,191 @@ +import set from "lodash/set"; +import { observable, action, computed, makeObservable, runInAction } from "mobx"; +import { + IInstance, + IInstanceAdmin, + IInstanceConfiguration, + IFormattedInstanceConfiguration, + IInstanceInfo, + IInstanceConfig, +} from "@plane/types"; +// helpers +import { EInstanceStatus, TInstanceStatus } from "@/helpers"; +// services +import { InstanceService } from "@/services/instance.service"; +// root store +import { RootStore } 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; +} + +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: RootStore) { + 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.getInstanceInfo(); + // 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.updateInstanceInfo(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.getInstanceAdmins(); + 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.getInstanceConfigurations(); + 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.updateInstanceConfigurations(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; + } + }; +} diff --git a/admin/store/root.store.ts b/admin/store/root.store.ts new file mode 100644 index 000000000..553a22200 --- /dev/null +++ b/admin/store/root.store.ts @@ -0,0 +1,32 @@ +import { enableStaticRendering } from "mobx-react-lite"; +// stores +import { IInstanceStore, InstanceStore } from "./instance.store"; +import { IThemeStore, ThemeStore } from "./theme.store"; +import { IUserStore, UserStore } from "./user.store"; + +enableStaticRendering(typeof window === "undefined"); + +export class RootStore { + theme: IThemeStore; + instance: IInstanceStore; + user: IUserStore; + + constructor() { + this.theme = new ThemeStore(this); + this.instance = new InstanceStore(this); + this.user = new UserStore(this); + } + + hydrate(initialData: any) { + this.theme.hydrate(initialData.theme); + this.instance.hydrate(initialData.instance); + this.user.hydrate(initialData.user); + } + + resetOnSignOut() { + localStorage.setItem("theme", "system"); + this.instance = new InstanceStore(this); + this.user = new UserStore(this); + this.theme = new ThemeStore(this); + } +} diff --git a/admin/store/theme.store.ts b/admin/store/theme.store.ts new file mode 100644 index 000000000..a3f3b3d5a --- /dev/null +++ b/admin/store/theme.store.ts @@ -0,0 +1,68 @@ +import { action, observable, makeObservable } from "mobx"; +// root store +import { RootStore } 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: RootStore) { + 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/admin/store/user.store.ts b/admin/store/user.store.ts new file mode 100644 index 000000000..60638f0cd --- /dev/null +++ b/admin/store/user.store.ts @@ -0,0 +1,104 @@ +import { action, observable, runInAction, makeObservable } from "mobx"; +import { IUser } from "@plane/types"; +// helpers +import { EUserStatus, TUserStatus } from "@/helpers"; +// services +import { AuthService } from "@/services/auth.service"; +import { UserService } from "@/services/user.service"; +// root store +import { RootStore } 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: RootStore) { + 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.currentUser(); + 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/admin/tailwind.config.js b/admin/tailwind.config.js new file mode 100644 index 000000000..05bc93bdc --- /dev/null +++ b/admin/tailwind.config.js @@ -0,0 +1,5 @@ +const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); + +module.exports = { + presets: [sharedConfig], +}; diff --git a/admin/tsconfig.json b/admin/tsconfig.json new file mode 100644 index 000000000..5bc5a5684 --- /dev/null +++ b/admin/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "tsconfig/nextjs.json", + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"], + "compilerOptions": { + "baseUrl": ".", + "jsx": "preserve", + "esModuleInterop": true, + "paths": { + "@/*": ["*"] + }, + "plugins": [ + { + "name": "next" + } + ] + } +} diff --git a/aio/Dockerfile b/aio/Dockerfile new file mode 100644 index 000000000..94d61b866 --- /dev/null +++ b/aio/Dockerfile @@ -0,0 +1,149 @@ +# ***************************************************************************** +# STAGE 1: Build the project +# ***************************************************************************** +FROM node:18-alpine AS builder +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app +ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER + +RUN yarn global add turbo +COPY . . + +RUN turbo prune --scope=web --scope=space --scope=admin --docker + +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** +# Add lockfile and package.json's of isolated subworkspace +FROM node:18-alpine 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/yarn.lock ./yarn.lock +RUN yarn install + +# # Build the project +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +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 + +ENV NEXT_TELEMETRY_DISABLED 1 +ENV TURBO_TELEMETRY_DISABLED 1 + +RUN yarn turbo run build + +# ***************************************************************************** +# STAGE 3: Copy the project and start it +# ***************************************************************************** +# FROM makeplane/plane-aio-base AS runner +FROM makeplane/plane-aio-base:develop AS runner + +WORKDIR /app + +SHELL [ "/bin/bash", "-c" ] + +# PYTHON APPLICATION SETUP + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 + +COPY apiserver/requirements.txt ./api/ +COPY apiserver/requirements ./api/requirements + +RUN python3.12 -m venv /app/venv && \ + source /app/venv/bin/activate && \ + /app/venv/bin/pip install --upgrade pip && \ + /app/venv/bin/pip install -r ./api/requirements.txt --compile --no-cache-dir + +# Add in Django deps and generate Django's static files +COPY apiserver/manage.py ./api/manage.py +COPY apiserver/plane ./api/plane/ +COPY apiserver/templates ./api/templates/ +COPY package.json ./api/package.json + +COPY apiserver/bin ./api/bin/ + +RUN chmod +x ./api/bin/* +RUN chmod -R 777 ./api/ + +# NEXTJS BUILDS + +COPY --from=installer /app/web/next.config.js ./web/ +COPY --from=installer /app/web/package.json ./web/ +COPY --from=installer /app/web/.next/standalone ./web +COPY --from=installer /app/web/.next/static ./web/web/.next/static +COPY --from=installer /app/web/public ./web/web/public + +COPY --from=installer /app/space/next.config.js ./space/ +COPY --from=installer /app/space/package.json ./space/ +COPY --from=installer /app/space/.next/standalone ./space +COPY --from=installer /app/space/.next/static ./space/space/.next/static +COPY --from=installer /app/space/public ./space/space/public + +COPY --from=installer /app/admin/next.config.js ./admin/ +COPY --from=installer /app/admin/package.json ./admin/ +COPY --from=installer /app/admin/.next/standalone ./admin +COPY --from=installer /app/admin/.next/static ./admin/admin/.next/static +COPY --from=installer /app/admin/public ./admin/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 + +COPY aio/supervisord.conf /app/supervisord.conf + +COPY aio/aio.sh /app/aio.sh +RUN chmod +x /app/aio.sh + +COPY aio/pg-setup.sh /app/pg-setup.sh +RUN chmod +x /app/pg-setup.sh + +COPY deploy/selfhost/variables.env /app/plane.env + +# NGINX Conf Copy +COPY ./aio/nginx.conf.aio /etc/nginx/nginx.conf.template +COPY ./nginx/env.sh /app/nginx-start.sh +RUN chmod +x /app/nginx-start.sh + +RUN ./pg-setup.sh + +VOLUME [ "/app/data/minio/uploads", "/var/lib/postgresql/data" ] + +CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"] diff --git a/aio/Dockerfile.base b/aio/Dockerfile.base new file mode 100644 index 000000000..092deb797 --- /dev/null +++ b/aio/Dockerfile.base @@ -0,0 +1,92 @@ +FROM --platform=$BUILDPLATFORM tonistiigi/binfmt as binfmt + +FROM debian:12-slim + +# Set environment variables to non-interactive for apt +ENV DEBIAN_FRONTEND=noninteractive + +SHELL [ "/bin/bash", "-c" ] + +# Update the package list and install prerequisites +RUN apt-get update && \ + apt-get install -y \ + gnupg2 curl ca-certificates lsb-release software-properties-common \ + build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \ + libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev xz-utils \ + tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu + +# Install Redis 7.2 +RUN echo "deb http://deb.debian.org/debian $(lsb_release -cs)-backports main" > /etc/apt/sources.list.d/backports.list && \ + curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" > /etc/apt/sources.list.d/redis.list && \ + apt-get update && \ + apt-get install -y redis-server + +# Install PostgreSQL 15 +ENV POSTGRES_VERSION 15 +RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg-archive-keyring.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/pgdg-archive-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ + apt-get update && \ + apt-get install -y postgresql-$POSTGRES_VERSION postgresql-client-$POSTGRES_VERSION && \ + mkdir -p /var/lib/postgresql/data && \ + chown -R postgres:postgres /var/lib/postgresql + +# Install MinIO +ARG TARGETARCH +RUN if [ "$TARGETARCH" = "amd64" ]; then \ + curl -fSl https://dl.min.io/server/minio/release/linux-amd64/minio -o /usr/local/bin/minio; \ + elif [ "$TARGETARCH" = "arm64" ]; then \ + curl -fSl https://dl.min.io/server/minio/release/linux-arm64/minio -o /usr/local/bin/minio; \ + else \ + echo "Unsupported architecture: $TARGETARCH"; exit 1; \ + fi && \ + chmod +x /usr/local/bin/minio + + +# Install Node.js 18 +RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ + apt-get install -y nodejs + +# Install Python 3.12 from source +RUN cd /usr/src && \ + wget https://www.python.org/ftp/python/3.12.0/Python-3.12.0.tgz && \ + tar xzf Python-3.12.0.tgz && \ + cd Python-3.12.0 && \ + ./configure --enable-optimizations && \ + make altinstall && \ + rm -f /usr/src/Python-3.12.0.tgz + +RUN python3.12 -m pip install --upgrade pip + +RUN echo "alias python=/usr/local/bin/python3.12" >> ~/.bashrc && \ + echo "alias pip=/usr/local/bin/pip3.12" >> ~/.bashrc + +# Clean up +RUN apt-get clean && \ + rm -rf /var/lib/apt/lists/* /usr/src/Python-3.12.0 + +WORKDIR /app + +RUN mkdir -p /app/{data,logs} && \ + mkdir -p /app/data/{redis,pg,minio,nginx} && \ + mkdir -p /app/logs/{access,error} && \ + mkdir -p /etc/supervisor/conf.d + +# Create Supervisor configuration file +COPY supervisord.base /app/supervisord.conf + +RUN apt-get update && \ + apt-get install -y sudo lsof net-tools libpq-dev procps gettext && \ + apt-get clean + +RUN sudo -u postgres /usr/lib/postgresql/$POSTGRES_VERSION/bin/initdb -D /var/lib/postgresql/data +COPY postgresql.conf /etc/postgresql/postgresql.conf + +RUN echo "alias python=/usr/local/bin/python3.12" >> ~/.bashrc && \ + echo "alias pip=/usr/local/bin/pip3.12" >> ~/.bashrc + +# Expose ports for Redis, PostgreSQL, and MinIO +EXPOSE 6379 5432 9000 80 + +# Start Supervisor +CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"] diff --git a/aio/aio.sh b/aio/aio.sh new file mode 100644 index 000000000..53adbf42b --- /dev/null +++ b/aio/aio.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + + +if [ "$1" = 'api' ]; then + source /app/venv/bin/activate + cd /app/api + exec ./bin/docker-entrypoint-api.sh +elif [ "$1" = 'worker' ]; then + source /app/venv/bin/activate + cd /app/api + exec ./bin/docker-entrypoint-worker.sh +elif [ "$1" = 'beat' ]; then + source /app/venv/bin/activate + cd /app/api + exec ./bin/docker-entrypoint-beat.sh +elif [ "$1" = 'migrator' ]; then + source /app/venv/bin/activate + cd /app/api + exec ./bin/docker-entrypoint-migrator.sh +elif [ "$1" = 'web' ]; then + node /app/web/web/server.js +elif [ "$1" = 'space' ]; then + node /app/space/space/server.js +elif [ "$1" = 'admin' ]; then + node /app/admin/admin/server.js +else + echo "Command not found" + exit 1 +fi \ No newline at end of file diff --git a/aio/nginx.conf.aio b/aio/nginx.conf.aio new file mode 100644 index 000000000..1a1f3c0b8 --- /dev/null +++ b/aio/nginx.conf.aio @@ -0,0 +1,73 @@ +events { +} + +http { + sendfile on; + + server { + listen 80; + root /www/data/; + access_log /var/log/nginx/access.log; + + client_max_body_size ${FILE_SIZE_LIMIT}; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Permissions-Policy "interest-cohort=()" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Forwarded-Proto "${dollar}scheme"; + add_header X-Forwarded-Host "${dollar}host"; + add_header X-Forwarded-For "${dollar}proxy_add_x_forwarded_for"; + add_header X-Real-IP "${dollar}remote_addr"; + + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:3001/; + } + + location /spaces/ { + rewrite ^/spaces/?$ /spaces/login break; + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:3002/spaces/; + } + + + location /god-mode/ { + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:3003/god-mode/; + } + + location /api/ { + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:8000/api/; + } + + location /auth/ { + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:8000/auth/; + } + + location /${BUCKET_NAME}/ { + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:9000/uploads/; + } + } +} diff --git a/aio/pg-setup.sh b/aio/pg-setup.sh new file mode 100644 index 000000000..6f6ea88e6 --- /dev/null +++ b/aio/pg-setup.sh @@ -0,0 +1,14 @@ +#!/bin/bash + + +# Variables +set -o allexport +source plane.env set +set +o allexport + +export PGHOST=localhost + +sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data start +sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "CREATE USER $POSTGRES_USER WITH SUPERUSER PASSWORD '$POSTGRES_PASSWORD';" && \ +sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/createdb" -O "$POSTGRES_USER" "$POSTGRES_DB" && \ +sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data stop diff --git a/aio/postgresql.conf b/aio/postgresql.conf new file mode 100644 index 000000000..8c6223fc4 --- /dev/null +++ b/aio/postgresql.conf @@ -0,0 +1,12 @@ +# PostgreSQL configuration file + +# Allow connections from any IP address +listen_addresses = '*' + +# Set the maximum number of connections +max_connections = 100 + +# Set the shared buffers size +shared_buffers = 128MB + +# Other custom configurations can be added here diff --git a/aio/supervisord.base b/aio/supervisord.base new file mode 100644 index 000000000..fe6a76e41 --- /dev/null +++ b/aio/supervisord.base @@ -0,0 +1,37 @@ +[supervisord] +user=root +nodaemon=true +stderr_logfile=/app/logs/error/supervisor.err.log +stdout_logfile=/app/logs/access/supervisor.out.log + +[program:redis] +directory=/app/data/redis +command=redis-server +autostart=true +autorestart=true +stderr_logfile=/app/logs/error/redis.err.log +stdout_logfile=/app/logs/access/redis.out.log + +[program:postgresql] +user=postgres +command=/usr/lib/postgresql/15/bin/postgres --config-file=/etc/postgresql/15/main/postgresql.conf +autostart=true +autorestart=true +stderr_logfile=/app/logs/error/postgresql.err.log +stdout_logfile=/app/logs/access/postgresql.out.log + +[program:minio] +directory=/app/data/minio +command=minio server /app/data/minio +autostart=true +autorestart=true +stderr_logfile=/app/logs/error/minio.err.log +stdout_logfile=/app/logs/access/minio.out.log + +[program:nginx] +directory=/app/data/nginx +command=/usr/sbin/nginx -g 'daemon off;' +autostart=true +autorestart=true +stderr_logfile=/app/logs/error/nginx.err.log +stdout_logfile=/app/logs/access/nginx.out.log diff --git a/aio/supervisord.conf b/aio/supervisord.conf new file mode 100644 index 000000000..46ef1b4fa --- /dev/null +++ b/aio/supervisord.conf @@ -0,0 +1,115 @@ +[supervisord] +user=root +nodaemon=true +priority=1 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:redis] +directory=/app/data/redis +command=redis-server +autostart=true +autorestart=true +priority=1 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:postgresql] +user=postgres +command=/usr/lib/postgresql/15/bin/postgres -D /var/lib/postgresql/data --config-file=/etc/postgresql/postgresql.conf +autostart=true +autorestart=true +priority=1 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:minio] +directory=/app/data/minio +command=minio server /app/data/minio +autostart=true +autorestart=true +priority=1 +stdout_logfile=/app/logs/access/minio.log +stderr_logfile=/app/logs/error/minio.err.log + +[program:nginx] +command=/app/nginx-start.sh +autostart=true +autorestart=true +priority=1 +stdout_logfile=/app/logs/access/nginx.log +stderr_logfile=/app/logs/error/nginx.err.log + + +[program:web] +command=/app/aio.sh web +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +environment=PORT=3001,HOSTNAME=0.0.0.0 + +[program:space] +command=/app/aio.sh space +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +environment=PORT=3002,HOSTNAME=0.0.0.0 + +[program:admin] +command=/app/aio.sh admin +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +environment=PORT=3003,HOSTNAME=0.0.0.0 + +[program:migrator] +command=/app/aio.sh migrator +autostart=true +autorestart=false +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:api] +command=/app/aio.sh api +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:worker] +command=/app/aio.sh worker +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:beat] +command=/app/aio.sh beat +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + diff --git a/apiserver/.env.example b/apiserver/.env.example index 37178b398..38944f79c 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -1,23 +1,20 @@ # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -CORS_ALLOWED_ORIGINS="" +CORS_ALLOWED_ORIGINS="http://localhost" # Error logs SENTRY_DSN="" SENTRY_ENVIRONMENT="development" # Database Settings -PGUSER="plane" -PGPASSWORD="plane" -PGHOST="plane-db" -PGDATABASE="plane" -DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} +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} -# Oauth variables -GOOGLE_CLIENT_ID="" -GITHUB_CLIENT_ID="" -GITHUB_CLIENT_SECRET="" # Redis Settings REDIS_HOST="plane-redis" @@ -34,14 +31,6 @@ 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 - -# Github -GITHUB_CLIENT_SECRET="" # For fetching release notes - # Settings related to Docker DOCKERIZED=1 # deprecated @@ -51,19 +40,13 @@ USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 - -# SignUps -ENABLE_SIGNUP="1" - -# Enable Email/Password Signup -ENABLE_EMAIL_PASSWORD="1" - -# Enable Magic link Login -ENABLE_MAGIC_LINK_LOGIN="0" - # Email redirections and minio domain settings WEB_URL="http://localhost" # Gunicorn Workers GUNICORN_WORKERS=2 +# Base URLs +ADMIN_BASE_URL= +SPACE_BASE_URL= +APP_BASE_URL= diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api index 0e4e0ac50..6447e9f97 100644 --- a/apiserver/Dockerfile.api +++ b/apiserver/Dockerfile.api @@ -32,29 +32,20 @@ RUN apk add --no-cache --virtual .build-deps \ apk del .build-deps -RUN addgroup -S plane && \ - adduser -S captain -G plane - -RUN chown captain.plane /code - -USER captain - # Add in Django deps and generate Django's static files COPY manage.py manage.py COPY plane plane/ COPY templates templates/ COPY package.json package.json -USER root + RUN apk --no-cache add "bash~=5.2" COPY ./bin ./bin/ -RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat +RUN mkdir -p /code/plane/logs +RUN chmod +x ./bin/* RUN chmod -R 777 /code -USER captain - # Expose container port and run entry point script EXPOSE 8000 -# CMD [ "./bin/takeoff" ] diff --git a/apiserver/Dockerfile.dev b/apiserver/Dockerfile.dev index cb2d1ca28..3de300db7 100644 --- a/apiserver/Dockerfile.dev +++ b/apiserver/Dockerfile.dev @@ -30,24 +30,16 @@ ADD requirements ./requirements # Install the local development settings RUN pip install -r requirements/local.txt --compile --no-cache-dir -RUN addgroup -S plane && \ - adduser -S captain -G plane -RUN chown captain.plane /code +COPY . . -USER captain - -# Add in Django deps and generate Django's static files - -USER root - -# RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat +RUN mkdir -p /code/plane/logs +RUN chmod -R +x /code/bin RUN chmod -R 777 /code -USER captain # Expose container port and run entry point script EXPOSE 8000 -CMD [ "./bin/takeoff.local" ] +CMD [ "./bin/docker-entrypoint-api-local.sh" ] diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py index c04ee7771..328b9db2b 100644 --- a/apiserver/back_migration.py +++ b/apiserver/back_migration.py @@ -26,7 +26,9 @@ def update_description(): updated_issues.append(issue) Issue.objects.bulk_update( - updated_issues, ["description_html", "description_stripped"], batch_size=100 + updated_issues, + ["description_html", "description_stripped"], + batch_size=100, ) print("Success") except Exception as e: @@ -40,7 +42,9 @@ def update_comments(): updated_issue_comments = [] for issue_comment in issue_comments: - issue_comment.comment_html = f"

    {issue_comment.comment_stripped}

    " + issue_comment.comment_html = ( + f"

    {issue_comment.comment_stripped}

    " + ) updated_issue_comments.append(issue_comment) IssueComment.objects.bulk_update( @@ -99,7 +103,9 @@ def updated_issue_sort_order(): issue.sort_order = issue.sequence_id * random.randint(100, 500) updated_issues.append(issue) - Issue.objects.bulk_update(updated_issues, ["sort_order"], batch_size=100) + Issue.objects.bulk_update( + updated_issues, ["sort_order"], batch_size=100 + ) print("Success") except Exception as e: print(e) @@ -137,7 +143,9 @@ def update_project_cover_images(): project.cover_image = project_cover_images[random.randint(0, 19)] updated_projects.append(project) - Project.objects.bulk_update(updated_projects, ["cover_image"], batch_size=100) + Project.objects.bulk_update( + updated_projects, ["cover_image"], batch_size=100 + ) print("Success") except Exception as e: print(e) @@ -174,7 +182,7 @@ def update_label_color(): labels = Label.objects.filter(color="") updated_labels = [] for label in labels: - label.color = "#" + "%06x" % random.randint(0, 0xFFFFFF) + label.color = f"#{random.randint(0, 0xFFFFFF+1):06X}" updated_labels.append(label) Label.objects.bulk_update(updated_labels, ["color"], batch_size=100) @@ -186,7 +194,9 @@ def update_label_color(): def create_slack_integration(): try: - _ = Integration.objects.create(provider="slack", network=2, title="Slack") + _ = Integration.objects.create( + provider="slack", network=2, title="Slack" + ) print("Success") except Exception as e: print(e) @@ -212,12 +222,16 @@ def update_integration_verified(): def update_start_date(): try: - issues = Issue.objects.filter(state__group__in=["started", "completed"]) + issues = Issue.objects.filter( + state__group__in=["started", "completed"] + ) updated_issues = [] for issue in issues: issue.start_date = issue.created_at.date() updated_issues.append(issue) - Issue.objects.bulk_update(updated_issues, ["start_date"], batch_size=500) + Issue.objects.bulk_update( + updated_issues, ["start_date"], batch_size=500 + ) print("Success") except Exception as e: print(e) diff --git a/apiserver/bin/beat b/apiserver/bin/beat deleted file mode 100644 index 45d357442..000000000 --- a/apiserver/bin/beat +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -set -e - -python manage.py wait_for_db -celery -A plane beat -l info \ No newline at end of file diff --git a/apiserver/bin/takeoff.local b/apiserver/bin/docker-entrypoint-api-local.sh similarity index 78% rename from apiserver/bin/takeoff.local rename to apiserver/bin/docker-entrypoint-api-local.sh index b89c20874..3194009b2 100755 --- a/apiserver/bin/takeoff.local +++ b/apiserver/bin/docker-entrypoint-api-local.sh @@ -1,7 +1,8 @@ #!/bin/bash set -e python manage.py wait_for_db -python manage.py migrate +# Wait for migrations +python manage.py wait_for_migrations # Create the default bucket #!/bin/bash @@ -20,12 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256 export MACHINE_SIGNATURE=$SIGNATURE # Register instance -python manage.py register_instance $MACHINE_SIGNATURE +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/apiserver/bin/takeoff b/apiserver/bin/docker-entrypoint-api.sh similarity index 62% rename from apiserver/bin/takeoff rename to apiserver/bin/docker-entrypoint-api.sh index 0ec2e495c..5a1da1570 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/docker-entrypoint-api.sh @@ -1,7 +1,8 @@ #!/bin/bash set -e python manage.py wait_for_db -python manage.py migrate +# Wait for migrations +python manage.py wait_for_migrations # Create the default bucket #!/bin/bash @@ -20,11 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256 export MACHINE_SIGNATURE=$SIGNATURE # Register instance -python manage.py register_instance $MACHINE_SIGNATURE +python manage.py register_instance "$MACHINE_SIGNATURE" + # Load the configuration variable python manage.py configure_instance # Create the default bucket python manage.py create_bucket -exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile - +# 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/apiserver/bin/docker-entrypoint-beat.sh b/apiserver/bin/docker-entrypoint-beat.sh new file mode 100644 index 000000000..3a9602a9e --- /dev/null +++ b/apiserver/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/apiserver/bin/docker-entrypoint-migrator.sh b/apiserver/bin/docker-entrypoint-migrator.sh new file mode 100644 index 000000000..104b39024 --- /dev/null +++ b/apiserver/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/apiserver/bin/worker b/apiserver/bin/docker-entrypoint-worker.sh similarity index 50% rename from apiserver/bin/worker rename to apiserver/bin/docker-entrypoint-worker.sh index 9d2da1254..a70b5f77c 100755 --- a/apiserver/bin/worker +++ b/apiserver/bin/docker-entrypoint-worker.sh @@ -2,4 +2,7 @@ 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/apiserver/manage.py b/apiserver/manage.py index 837297219..744086783 100644 --- a/apiserver/manage.py +++ b/apiserver/manage.py @@ -2,10 +2,10 @@ import os import sys -if __name__ == '__main__': +if __name__ == "__main__": os.environ.setdefault( - 'DJANGO_SETTINGS_MODULE', - 'plane.settings.production') + "DJANGO_SETTINGS_MODULE", "plane.settings.production" + ) try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/apiserver/package.json b/apiserver/package.json index a317b4776..ecaf1194a 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.14.0" + "version": "0.21.0" } diff --git a/apiserver/plane/__init__.py b/apiserver/plane/__init__.py index fb989c4e6..53f4ccb1d 100644 --- a/apiserver/plane/__init__.py +++ b/apiserver/plane/__init__.py @@ -1,3 +1,3 @@ from .celery import app as celery_app -__all__ = ('celery_app',) +__all__ = ("celery_app",) diff --git a/apiserver/plane/analytics/apps.py b/apiserver/plane/analytics/apps.py index 353779983..52a59f313 100644 --- a/apiserver/plane/analytics/apps.py +++ b/apiserver/plane/analytics/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class AnalyticsConfig(AppConfig): - name = 'plane.analytics' + name = "plane.analytics" diff --git a/apiserver/plane/api/apps.py b/apiserver/plane/api/apps.py index 292ad9344..6ba36e7e5 100644 --- a/apiserver/plane/api/apps.py +++ b/apiserver/plane/api/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class ApiConfig(AppConfig): - name = "plane.api" \ No newline at end of file + name = "plane.api" diff --git a/apiserver/plane/api/middleware/api_authentication.py b/apiserver/plane/api/middleware/api_authentication.py index 1b2c03318..893df7f84 100644 --- a/apiserver/plane/api/middleware/api_authentication.py +++ b/apiserver/plane/api/middleware/api_authentication.py @@ -25,7 +25,10 @@ class APIKeyAuthentication(authentication.BaseAuthentication): def validate_api_token(self, token): try: api_token = APIToken.objects.get( - Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), + Q( + Q(expired_at__gt=timezone.now()) + | Q(expired_at__isnull=True) + ), token=token, is_active=True, ) @@ -44,4 +47,4 @@ class APIKeyAuthentication(authentication.BaseAuthentication): # Validate the API token user, token = self.validate_api_token(token) - return user, token \ No newline at end of file + return user, token diff --git a/apiserver/plane/api/rate_limit.py b/apiserver/plane/api/rate_limit.py index f91e2d65d..b62936d8e 100644 --- a/apiserver/plane/api/rate_limit.py +++ b/apiserver/plane/api/rate_limit.py @@ -1,17 +1,18 @@ from rest_framework.throttling import SimpleRateThrottle + class ApiKeyRateThrottle(SimpleRateThrottle): - scope = 'api_key' - rate = '60/minute' + scope = "api_key" + rate = "60/minute" def get_cache_key(self, request, view): # Retrieve the API key from the request header - api_key = request.headers.get('X-Api-Key') + 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}' + return f"{self.scope}:{api_key}" def allow_request(self, request, view): allowed = super().allow_request(request, view) @@ -24,7 +25,7 @@ class ApiKeyRateThrottle(SimpleRateThrottle): # Remove old histories while history and history[-1] <= now - self.duration: history.pop() - + # Calculate the requests num_requests = len(history) @@ -35,7 +36,7 @@ class ApiKeyRateThrottle(SimpleRateThrottle): reset_time = int(now + self.duration) # Add headers - request.META['X-RateLimit-Remaining'] = max(0, available) - request.META['X-RateLimit-Reset'] = reset_time + request.META["X-RateLimit-Remaining"] = max(0, available) + request.META["X-RateLimit-Reset"] = reset_time - return allowed \ No newline at end of file + return allowed diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 1fd1bce78..10b0182d6 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -13,5 +13,9 @@ from .issue import ( ) from .state import StateLiteSerializer, StateSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer -from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer -from .inbox import InboxIssueSerializer \ No newline at end of file +from .module import ( + ModuleSerializer, + ModuleIssueSerializer, + ModuleLiteSerializer, +) +from .inbox import InboxIssueSerializer diff --git a/apiserver/plane/api/serializers/base.py b/apiserver/plane/api/serializers/base.py index b96422501..5b68a7113 100644 --- a/apiserver/plane/api/serializers/base.py +++ b/apiserver/plane/api/serializers/base.py @@ -66,11 +66,11 @@ class BaseSerializer(serializers.ModelSerializer): if expand in self.fields: # Import all the expandable serializers from . import ( - WorkspaceLiteSerializer, - ProjectLiteSerializer, - UserLiteSerializer, - StateLiteSerializer, IssueSerializer, + ProjectLiteSerializer, + StateLiteSerializer, + UserLiteSerializer, + WorkspaceLiteSerializer, ) # Expansion mapper @@ -97,9 +97,11 @@ class BaseSerializer(serializers.ModelSerializer): exp_serializer = expansion[expand]( getattr(instance, expand) ) - response[expand] = exp_serializer.data + response[expand] = exp_serializer.data else: # You might need to handle this case differently - response[expand] = getattr(instance, f"{expand}_id", None) + response[expand] = getattr( + instance, f"{expand}_id", None + ) - return response \ No newline at end of file + return response diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index eaff8181a..6fc73a4bc 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -23,7 +23,9 @@ class CycleSerializer(BaseSerializer): and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None) ): - raise serializers.ValidationError("Start date cannot exceed end date") + raise serializers.ValidationError( + "Start date cannot exceed end date" + ) return data class Meta: @@ -55,7 +57,6 @@ class CycleIssueSerializer(BaseSerializer): class CycleLiteSerializer(BaseSerializer): - class Meta: model = Cycle - fields = "__all__" \ No newline at end of file + fields = "__all__" diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/inbox.py index 17ae8c1ed..a0c79235d 100644 --- a/apiserver/plane/api/serializers/inbox.py +++ b/apiserver/plane/api/serializers/inbox.py @@ -1,9 +1,13 @@ # Module improts from .base import BaseSerializer +from .issue import IssueExpandSerializer from plane.db.models import InboxIssue + class InboxIssueSerializer(BaseSerializer): + issue_detail = IssueExpandSerializer(read_only=True, source="issue") + class Meta: model = InboxIssue fields = "__all__" @@ -16,4 +20,4 @@ class InboxIssueSerializer(BaseSerializer): "updated_by", "created_at", "updated_at", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 75396e9bb..020917ee5 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -1,31 +1,34 @@ -from lxml import html - +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator # Django imports from django.utils import timezone +from lxml import html # Third party imports from rest_framework import serializers # Module imports from plane.db.models import ( - User, Issue, - State, + IssueActivity, IssueAssignee, - Label, + IssueAttachment, + IssueComment, IssueLabel, IssueLink, - IssueComment, - IssueAttachment, - IssueActivity, + Label, ProjectMember, + State, + User, ) + from .base import BaseSerializer -from .cycle import CycleSerializer, CycleLiteSerializer -from .module import ModuleSerializer, ModuleLiteSerializer -from .user import UserLiteSerializer +from .cycle import CycleLiteSerializer, CycleSerializer +from .module import ModuleLiteSerializer, ModuleSerializer from .state import StateLiteSerializer +from .user import UserLiteSerializer + class IssueSerializer(BaseSerializer): assignees = serializers.ListField( @@ -66,16 +69,18 @@ class IssueSerializer(BaseSerializer): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError("Start date cannot exceed target date") - + raise serializers.ValidationError( + "Start date cannot exceed target date" + ) + try: - if(data.get("description_html", None) is not None): + if data.get("description_html", None) is not None: parsed = html.fromstring(data["description_html"]) - parsed_str = html.tostring(parsed, encoding='unicode') + parsed_str = html.tostring(parsed, encoding="unicode") data["description_html"] = parsed_str - - except Exception as e: - raise serializers.ValidationError(f"Invalid HTML: {str(e)}") + + except Exception: + raise serializers.ValidationError("Invalid HTML passed") # Validate assignees are from project if data.get("assignees", []): @@ -96,7 +101,8 @@ class IssueSerializer(BaseSerializer): if ( data.get("state") and not State.objects.filter( - project_id=self.context.get("project_id"), pk=data.get("state").id + project_id=self.context.get("project_id"), + pk=data.get("state").id, ).exists() ): raise serializers.ValidationError( @@ -107,7 +113,8 @@ class IssueSerializer(BaseSerializer): if ( data.get("parent") and not Issue.objects.filter( - workspace_id=self.context.get("workspace_id"), pk=data.get("parent").id + workspace_id=self.context.get("workspace_id"), + pk=data.get("parent").id, ).exists() ): raise serializers.ValidationError( @@ -238,9 +245,13 @@ class IssueSerializer(BaseSerializer): ] if "labels" in self.fields: if "labels" in self.expand: - data["labels"] = LabelSerializer(instance.labels.all(), many=True).data + data["labels"] = LabelSerializer( + instance.labels.all(), many=True + ).data else: - data["labels"] = [str(label.id) for label in instance.labels.all()] + data["labels"] = [ + str(label.id) for label in instance.labels.all() + ] return data @@ -275,16 +286,42 @@ class IssueLinkSerializer(BaseSerializer): "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") + 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 IssueAttachmentSerializer(BaseSerializer): class Meta: @@ -324,13 +361,13 @@ class IssueCommentSerializer(BaseSerializer): def validate(self, data): try: - if(data.get("comment_html", None) is not None): + if data.get("comment_html", None) is not None: parsed = html.fromstring(data["comment_html"]) - parsed_str = html.tostring(parsed, encoding='unicode') + parsed_str = html.tostring(parsed, encoding="unicode") data["comment_html"] = parsed_str - - except Exception as e: - raise serializers.ValidationError(f"Invalid HTML: {str(e)}") + + except Exception: + raise serializers.ValidationError("Invalid HTML passed") return data @@ -362,7 +399,6 @@ class ModuleIssueSerializer(BaseSerializer): class LabelLiteSerializer(BaseSerializer): - class Meta: model = Label fields = [ diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py index a96a9b54d..01a201064 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -52,7 +52,9 @@ class ModuleSerializer(BaseSerializer): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError("Start date cannot exceed target date") + raise serializers.ValidationError( + "Start date cannot exceed target date" + ) if data.get("members", []): data["members"] = ProjectMember.objects.filter( @@ -146,16 +148,16 @@ class ModuleLinkSerializer(BaseSerializer): # Validation if url already exists def create(self, validated_data): if ModuleLink.objects.filter( - url=validated_data.get("url"), module_id=validated_data.get("module_id") + url=validated_data.get("url"), + module_id=validated_data.get("module_id"), ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} ) return ModuleLink.objects.create(**validated_data) - + class ModuleLiteSerializer(BaseSerializer): - class Meta: model = Module - fields = "__all__" \ No newline at end of file + fields = "__all__" diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index c394a080d..ce354ba5f 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -2,12 +2,16 @@ from rest_framework import serializers # Module imports -from plane.db.models import Project, ProjectIdentifier, WorkspaceMember, State, Estimate +from plane.db.models import ( + Project, + ProjectIdentifier, + WorkspaceMember, +) + from .base import BaseSerializer class ProjectSerializer(BaseSerializer): - total_members = serializers.IntegerField(read_only=True) total_cycles = serializers.IntegerField(read_only=True) total_modules = serializers.IntegerField(read_only=True) @@ -21,7 +25,7 @@ class ProjectSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "id", - 'emoji', + "emoji", "workspace", "created_at", "updated_at", @@ -59,12 +63,16 @@ class ProjectSerializer(BaseSerializer): def create(self, validated_data): identifier = validated_data.get("identifier", "").strip().upper() if identifier == "": - raise serializers.ValidationError(detail="Project Identifier is required") + raise serializers.ValidationError( + detail="Project Identifier is required" + ) if ProjectIdentifier.objects.filter( name=identifier, workspace_id=self.context["workspace_id"] ).exists(): - raise serializers.ValidationError(detail="Project Identifier is taken") + raise serializers.ValidationError( + detail="Project Identifier is taken" + ) project = Project.objects.create( **validated_data, workspace_id=self.context["workspace_id"] @@ -89,4 +97,4 @@ class ProjectLiteSerializer(BaseSerializer): "emoji", "description", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/api/serializers/state.py b/apiserver/plane/api/serializers/state.py index 9d08193d8..1649a7bcf 100644 --- a/apiserver/plane/api/serializers/state.py +++ b/apiserver/plane/api/serializers/state.py @@ -7,9 +7,9 @@ class StateSerializer(BaseSerializer): def validate(self, data): # If the default is being provided then make all other states default False if data.get("default", False): - State.objects.filter(project_id=self.context.get("project_id")).update( - default=False - ) + State.objects.filter( + project_id=self.context.get("project_id") + ).update(default=False) return data class Meta: @@ -35,4 +35,4 @@ class StateLiteSerializer(BaseSerializer): "color", "group", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index 42b6c3967..e853b90c2 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -1,5 +1,6 @@ # Module imports from plane.db.models import User + from .base import BaseSerializer @@ -10,7 +11,9 @@ class UserLiteSerializer(BaseSerializer): "id", "first_name", "last_name", + "email", "avatar", "display_name", + "email", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/api/serializers/workspace.py b/apiserver/plane/api/serializers/workspace.py index c4c5caceb..a47de3d31 100644 --- a/apiserver/plane/api/serializers/workspace.py +++ b/apiserver/plane/api/serializers/workspace.py @@ -5,6 +5,7 @@ from .base import BaseSerializer class WorkspaceLiteSerializer(BaseSerializer): """Lite serializer with only required fields""" + class Meta: model = Workspace fields = [ @@ -12,4 +13,4 @@ class WorkspaceLiteSerializer(BaseSerializer): "slug", "id", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/api/urls/__init__.py b/apiserver/plane/api/urls/__init__.py index a5ef0f5f1..84927439e 100644 --- a/apiserver/plane/api/urls/__init__.py +++ b/apiserver/plane/api/urls/__init__.py @@ -12,4 +12,4 @@ urlpatterns = [ *cycle_patterns, *module_patterns, *inbox_patterns, -] \ No newline at end of file +] diff --git a/apiserver/plane/api/urls/cycle.py b/apiserver/plane/api/urls/cycle.py index f557f8af0..b0ae21174 100644 --- a/apiserver/plane/api/urls/cycle.py +++ b/apiserver/plane/api/urls/cycle.py @@ -4,6 +4,7 @@ from plane.api.views.cycle import ( CycleAPIEndpoint, CycleIssueAPIEndpoint, TransferCycleIssueAPIEndpoint, + CycleArchiveUnarchiveAPIEndpoint, ) urlpatterns = [ @@ -32,4 +33,14 @@ urlpatterns = [ TransferCycleIssueAPIEndpoint.as_view(), name="transfer-issues", ), -] \ No newline at end of file + path( + "workspaces//projects//cycles//archive/", + CycleArchiveUnarchiveAPIEndpoint.as_view(), + name="cycle-archive-unarchive", + ), + path( + "workspaces//projects//archived-cycles/", + CycleArchiveUnarchiveAPIEndpoint.as_view(), + name="cycle-archive-unarchive", + ), +] diff --git a/apiserver/plane/api/urls/inbox.py b/apiserver/plane/api/urls/inbox.py index 3a2a57786..95eb68f3f 100644 --- a/apiserver/plane/api/urls/inbox.py +++ b/apiserver/plane/api/urls/inbox.py @@ -14,4 +14,4 @@ urlpatterns = [ InboxIssueAPIEndpoint.as_view(), name="inbox-issue", ), -] \ No newline at end of file +] diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index 070ea8bd9..5ce9db85c 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -6,9 +6,15 @@ from plane.api.views import ( IssueLinkAPIEndpoint, IssueCommentAPIEndpoint, IssueActivityAPIEndpoint, + WorkspaceIssueAPIEndpoint, ) urlpatterns = [ + path( + "workspaces//issues/-/", + WorkspaceIssueAPIEndpoint.as_view(), + name="issue-by-identifier", + ), path( "workspaces//projects//issues/", IssueAPIEndpoint.as_view(), diff --git a/apiserver/plane/api/urls/module.py b/apiserver/plane/api/urls/module.py index 7117a9e8b..a131f4d4f 100644 --- a/apiserver/plane/api/urls/module.py +++ b/apiserver/plane/api/urls/module.py @@ -1,6 +1,10 @@ from django.urls import path -from plane.api.views import ModuleAPIEndpoint, ModuleIssueAPIEndpoint +from plane.api.views import ( + ModuleAPIEndpoint, + ModuleIssueAPIEndpoint, + ModuleArchiveUnarchiveAPIEndpoint, +) urlpatterns = [ path( @@ -23,4 +27,14 @@ urlpatterns = [ ModuleIssueAPIEndpoint.as_view(), name="module-issues", ), -] \ No newline at end of file + path( + "workspaces//projects//modules//archive/", + ModuleArchiveUnarchiveAPIEndpoint.as_view(), + name="module-archive-unarchive", + ), + path( + "workspaces//projects//archived-modules/", + ModuleArchiveUnarchiveAPIEndpoint.as_view(), + name="module-archive-unarchive", + ), +] diff --git a/apiserver/plane/api/urls/project.py b/apiserver/plane/api/urls/project.py index c73e84c89..5efb85bb0 100644 --- a/apiserver/plane/api/urls/project.py +++ b/apiserver/plane/api/urls/project.py @@ -1,16 +1,24 @@ from django.urls import path -from plane.api.views import ProjectAPIEndpoint +from plane.api.views import ( + ProjectAPIEndpoint, + ProjectArchiveUnarchiveAPIEndpoint, +) urlpatterns = [ - path( + path( "workspaces//projects/", ProjectAPIEndpoint.as_view(), name="project", ), path( - "workspaces//projects//", + "workspaces//projects//", ProjectAPIEndpoint.as_view(), name="project", ), -] \ No newline at end of file + path( + "workspaces//projects//archive/", + ProjectArchiveUnarchiveAPIEndpoint.as_view(), + name="project-archive-unarchive", + ), +] diff --git a/apiserver/plane/api/urls/state.py b/apiserver/plane/api/urls/state.py index 0676ac5ad..b03f386e6 100644 --- a/apiserver/plane/api/urls/state.py +++ b/apiserver/plane/api/urls/state.py @@ -13,4 +13,4 @@ urlpatterns = [ StateAPIEndpoint.as_view(), name="states", ), -] \ No newline at end of file +] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 84d8dcabb..d59b40fc5 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -1,8 +1,9 @@ -from .project import ProjectAPIEndpoint +from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint from .state import StateAPIEndpoint from .issue import ( + WorkspaceIssueAPIEndpoint, IssueAPIEndpoint, LabelAPIEndpoint, IssueLinkAPIEndpoint, @@ -14,8 +15,13 @@ from .cycle import ( CycleAPIEndpoint, CycleIssueAPIEndpoint, TransferCycleIssueAPIEndpoint, + CycleArchiveUnarchiveAPIEndpoint, ) -from .module import ModuleAPIEndpoint, ModuleIssueAPIEndpoint +from .module import ( + ModuleAPIEndpoint, + ModuleIssueAPIEndpoint, + ModuleArchiveUnarchiveAPIEndpoint, +) -from .inbox import InboxIssueAPIEndpoint \ No newline at end of file +from .inbox import InboxIssueAPIEndpoint diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index abde4e8b0..fee508a30 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -1,25 +1,24 @@ # Python imports import zoneinfo -import json # Django imports from django.conf import settings -from django.db import IntegrityError from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import IntegrityError +from django.urls import resolve from django.utils import timezone +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response # Third party imports from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated -from rest_framework import status -from sentry_sdk import capture_exception # Module imports from plane.api.middleware.api_authentication import APIKeyAuthentication from plane.api.rate_limit import ApiKeyRateThrottle +from plane.utils.exception_logger import log_exception from plane.utils.paginator import BasePaginator -from plane.bgtasks.webhook_task import send_webhook class TimezoneMixin: @@ -36,32 +35,6 @@ class TimezoneMixin: timezone.deactivate() -class WebhookMixin: - webhook_event = None - bulk = False - - def finalize_response(self, request, response, *args, **kwargs): - response = super().finalize_response(request, response, *args, **kwargs) - - # Check for the case should webhook be sent - if ( - self.webhook_event - and self.request.method in ["POST", "PATCH", "DELETE"] - and response.status_code in [200, 201, 204] - ): - # Push the object to delay - send_webhook.delay( - event=self.webhook_event, - payload=response.data, - kw=self.kwargs, - action=self.request.method, - slug=self.workspace_slug, - bulk=self.bulk, - ) - - return response - - class BaseAPIView(TimezoneMixin, APIView, BasePaginator): authentication_classes = [ APIKeyAuthentication, @@ -97,28 +70,23 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): if isinstance(e, ValidationError): return Response( - { - "error": "The provided payload is not valid please try with a valid payload" - }, + {"error": "Please provide valid detail"}, status=status.HTTP_400_BAD_REQUEST, ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": "The requested resource does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): return Response( - {"error": f"key {e} does not exist"}, + {"error": "The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST, ) - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -140,7 +108,9 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): def finalize_response(self, request, response, *args, **kwargs): # Call super to get the default response - response = super().finalize_response(request, response, *args, **kwargs) + response = super().finalize_response( + request, response, *args, **kwargs + ) # Add custom headers if they exist in the request META ratelimit_remaining = request.META.get("X-RateLimit-Remaining") @@ -159,18 +129,27 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): @property def project_id(self): - return self.kwargs.get("project_id", None) + 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 + field + for field in self.request.GET.get("fields", "").split(",") + if field ] return fields if fields else None @property def expand(self): expand = [ - expand for expand in self.request.GET.get("expand", "").split(",") if expand + expand + for expand in self.request.GET.get("expand", "").split(",") + if expand ] return expand if expand else None diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 310332333..6e1e5e057 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -2,26 +2,36 @@ import json # Django imports -from django.db.models import Q, Count, Sum, Prefetch, F, OuterRef, Func -from django.utils import timezone from django.core import serializers +from django.db.models import Count, F, Func, OuterRef, Q, Sum +from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response # Module imports -from .base import BaseAPIView, WebhookMixin -from plane.db.models import Cycle, Issue, CycleIssue, IssueLink, IssueAttachment -from plane.app.permissions import ProjectEntityPermission from plane.api.serializers import ( - CycleSerializer, CycleIssueSerializer, + CycleSerializer, ) +from plane.app.permissions import ProjectEntityPermission from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Cycle, + CycleIssue, + Issue, + IssueAttachment, + IssueLink, +) +from plane.utils.analytics_plot import burndown_plot + +from .base import BaseAPIView +from plane.bgtasks.webhook_task import model_activity -class CycleAPIEndpoint(WebhookMixin, BaseAPIView): +class CycleAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to cycle. @@ -39,7 +49,10 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): 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) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("owned_by") @@ -102,7 +115,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): ), ) ) - .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) .annotate( completed_estimates=Sum( "issue_cycle__issue__estimate_point", @@ -129,7 +144,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): def get(self, request, slug, project_id, pk=None): if pk: - queryset = self.get_queryset().get(pk=pk) + queryset = ( + self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) + ) data = CycleSerializer( queryset, fields=self.fields, @@ -139,7 +156,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): data, status=status.HTTP_200_OK, ) - queryset = self.get_queryset() + queryset = self.get_queryset().filter(archived_at__isnull=True) cycle_view = request.GET.get("cycle_view", "all") # Current Cycle @@ -201,7 +218,8 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): # Incomplete Cycles if cycle_view == "incomplete": queryset = queryset.filter( - Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True), + Q(end_date__gte=timezone.now().date()) + | Q(end_date__isnull=True), ) return self.paginate( request=request, @@ -234,12 +252,49 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): ): serializer = CycleSerializer(data=request.data) 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, owned_by=request.user, ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) else: return Response( { @@ -249,15 +304,32 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): ) def patch(self, request, slug, project_id, pk): - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + + current_instance = json.dumps( + CycleSerializer(cycle).data, cls=DjangoJSONEncoder + ) + + 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().date(): + if ( + cycle.end_date is not None + and cycle.end_date < timezone.now().date() + ): if "sort_order" in request_data: # Can only change sort order request_data = { - "sort_order": request_data.get("sort_order", cycle.sort_order) + "sort_order": request_data.get( + "sort_order", cycle.sort_order + ) } else: return Response( @@ -269,17 +341,49 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): serializer = CycleSerializer(cycle, data=request.data, partial=True) 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.data["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, slug, project_id, pk): cycle_issues = list( - CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( - "issue", flat=True - ) + CycleIssue.objects.filter( + cycle_id=self.kwargs.get("pk") + ).values_list("issue", flat=True) + ) + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk ) - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) issue_activity.delay( type="cycle.activity.deleted", @@ -301,7 +405,145 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) -class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): +class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + 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, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + def get(self, request, slug, project_id): + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + ).data, + ) + + 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().date(): + return Response( + {"error": "Only completed cycles can be archived"}, + status=status.HTTP_400_BAD_REQUEST, + ) + cycle.archived_at = timezone.now() + cycle.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + 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) + + +class CycleIssueAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, and `destroy` actions related to cycle issues. @@ -319,14 +561,19 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): def get_queryset(self): return ( CycleIssue.objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("issue_id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .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") @@ -337,12 +584,28 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): .distinct() ) - def get(self, request, slug, project_id, cycle_id): + def get(self, request, slug, project_id, cycle_id, issue_id=None): + # Get + if issue_id: + 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) + + # List order_by = request.GET.get("order_by", "created_at") issues = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -364,7 +627,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -387,14 +652,18 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): if not issues: return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Issues are required"}, + status=status.HTTP_400_BAD_REQUEST, ) cycle = Cycle.objects.get( workspace__slug=slug, project_id=project_id, pk=cycle_id ) - if cycle.end_date is not None and cycle.end_date < timezone.now().date(): + if ( + cycle.end_date is not None + and cycle.end_date < timezone.now().date() + ): return Response( { "error": "The Cycle has already been completed so no new issues can be added" @@ -479,7 +748,10 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): def delete(self, request, slug, project_id, cycle_id, issue_id): cycle_issue = CycleIssue.objects.get( - issue_id=issue_id, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + cycle_id=cycle_id, ) issue_id = cycle_issue.issue_id cycle_issue.delete() @@ -502,7 +774,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): class TransferCycleIssueAPIEndpoint(BaseAPIView): """ - This viewset provides `create` actions for transfering the issues into a particular cycle. + This viewset provides `create` actions for transferring the issues into a particular cycle. """ @@ -523,6 +795,209 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): workspace__slug=slug, project_id=project_id, pk=new_cycle_id ) + 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, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + ) + + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + cycle_id=cycle_id, + ) + + # Get the assignee distribution + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_issues=Count( + "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": item["avatar"], + "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.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "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 serilization + 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 + ] + + current_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ).first() + + if current_cycle: + current_cycle.progress_snapshot = { + "total_issues": old_cycle.first().total_issues, + "completed_issues": old_cycle.first().completed_issues, + "cancelled_issues": old_cycle.first().cancelled_issues, + "started_issues": old_cycle.first().started_issues, + "unstarted_issues": old_cycle.first().unstarted_issues, + "backlog_issues": old_cycle.first().backlog_issues, + "distribution": { + "labels": label_distribution_data, + "assignees": assignee_distribution_data, + "completion_chart": completion_chart, + }, + } + # Save the snapshot of the current cycle + current_cycle.save(update_fields=["progress_snapshot"]) + if ( new_cycle.end_date is not None and new_cycle.end_date < timezone.now().date() @@ -550,4 +1025,4 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): updated_cycles, ["cycle_id"], batch_size=100 ) - return Response({"message": "Success"}, status=status.HTTP_200_OK) \ No newline at end of file + return Response({"message": "Success"}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 4f4cdc4ef..8987e4f63 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -2,20 +2,28 @@ import json # Django improts -from django.utils import timezone -from django.db.models import Q from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Q +from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response # Module imports -from .base import BaseAPIView -from plane.app.permissions import ProjectLitePermission from plane.api.serializers import InboxIssueSerializer, IssueSerializer -from plane.db.models import InboxIssue, Issue, State, ProjectMember, Project, Inbox +from plane.app.permissions import ProjectLitePermission from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Inbox, + InboxIssue, + Issue, + Project, + ProjectMember, + State, +) + +from .base import BaseAPIView class InboxIssueAPIEndpoint(BaseAPIView): @@ -43,7 +51,8 @@ class InboxIssueAPIEndpoint(BaseAPIView): ).first() project = Project.objects.get( - workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id") + workspace__slug=self.kwargs.get("slug"), + pk=self.kwargs.get("project_id"), ) if inbox is None and not project.inbox_view: @@ -51,7 +60,8 @@ class InboxIssueAPIEndpoint(BaseAPIView): return ( InboxIssue.objects.filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + Q(snoozed_till__gte=timezone.now()) + | Q(snoozed_till__isnull=True), workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), inbox_id=inbox.id, @@ -87,7 +97,8 @@ class InboxIssueAPIEndpoint(BaseAPIView): def post(self, request, slug, project_id): if not request.data.get("issue", {}).get("name", False): return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Name is required"}, + status=status.HTTP_400_BAD_REQUEST, ) inbox = Inbox.objects.filter( @@ -109,7 +120,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): ) # Check for valid priority - if not request.data.get("issue", {}).get("priority", "none") in [ + if request.data.get("issue", {}).get("priority", "none") not in [ "low", "medium", "high", @@ -117,16 +128,18 @@ class InboxIssueAPIEndpoint(BaseAPIView): "none", ]: return Response( - {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Invalid priority"}, + status=status.HTTP_400_BAD_REQUEST, ) # Create or get state state, _ = State.objects.get_or_create( name="Triage", - group="backlog", + group="triage", description="Default state for managing all Inbox Issues", project_id=project_id, color="#ff7700", + is_triage=True, ) # create an issue @@ -141,6 +154,13 @@ class InboxIssueAPIEndpoint(BaseAPIView): state=state, ) + # create an inbox issue + inbox_issue = InboxIssue.objects.create( + inbox_id=inbox.id, + project_id=project_id, + issue=issue, + source=request.data.get("source", "in-app"), + ) # Create an Issue Activity issue_activity.delay( type="issue.activity.created", @@ -150,14 +170,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), - ) - - # create an inbox issue - inbox_issue = InboxIssue.objects.create( - inbox_id=inbox.id, - project_id=project_id, - issue=issue, - source=request.data.get("source", "in-app"), + inbox=str(inbox_issue.id), ) serializer = InboxIssueSerializer(inbox_issue) @@ -222,10 +235,14 @@ class InboxIssueAPIEndpoint(BaseAPIView): "description_html": issue_data.get( "description_html", issue.description_html ), - "description": issue_data.get("description", issue.description), + "description": issue_data.get( + "description", issue.description + ), } - issue_serializer = IssueSerializer(issue, data=issue_data, partial=True) + issue_serializer = IssueSerializer( + issue, data=issue_data, partial=True + ) if issue_serializer.is_valid(): current_instance = issue @@ -243,6 +260,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): cls=DjangoJSONEncoder, ), epoch=int(timezone.now().timestamp()), + inbox=(inbox_issue.id), ) issue_serializer.save() else: @@ -255,6 +273,9 @@ class InboxIssueAPIEndpoint(BaseAPIView): serializer = InboxIssueSerializer( inbox_issue, data=request.data, partial=True ) + current_instance = json.dumps( + InboxIssueSerializer(inbox_issue).data, cls=DjangoJSONEncoder + ) if serializer.is_valid(): serializer.save() @@ -266,7 +287,9 @@ class InboxIssueAPIEndpoint(BaseAPIView): project_id=project_id, ) state = State.objects.filter( - group="cancelled", workspace__slug=slug, project_id=project_id + group="cancelled", + workspace__slug=slug, + project_id=project_id, ).first() if state is not None: issue.state = state @@ -281,20 +304,41 @@ class InboxIssueAPIEndpoint(BaseAPIView): ) # Update the issue state only if it is in triage state - if issue.state.name == "Triage": + if issue.state.is_triage: # Move to default state state = State.objects.filter( - workspace__slug=slug, project_id=project_id, default=True + workspace__slug=slug, + project_id=project_id, + default=True, ).first() if state is not None: issue.state = state issue.save() + # create a activity for status change + issue_activity.delay( + type="inbox.activity.created", + requested_data=json.dumps( + request.data, cls=DjangoJSONEncoder + ), + 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=request.META.get("HTTP_ORIGIN"), + inbox=str(inbox_issue.id), + ) + return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) else: return Response( - InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK + InboxIssueSerializer(inbox_issue).data, + status=status.HTTP_200_OK, ) def delete(self, request, slug, project_id, issue_id): diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 1ac8ddcff..ce0501dd2 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -1,22 +1,22 @@ # Python imports import json -from itertools import chain + +from django.core.serializers.json import DjangoJSONEncoder # Django imports from django.db import IntegrityError from django.db.models import ( - OuterRef, - Func, - Q, - F, Case, - When, - Value, CharField, - Max, Exists, + F, + Func, + Max, + OuterRef, + Q, + Value, + When, ) -from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone # Third party imports @@ -24,33 +24,96 @@ from rest_framework import status from rest_framework.response import Response # Module imports -from .base import BaseAPIView, WebhookMixin -from plane.app.permissions import ( - ProjectEntityPermission, - ProjectMemberPermission, - ProjectLitePermission, -) -from plane.db.models import ( - Issue, - IssueAttachment, - IssueLink, - Project, - Label, - ProjectMember, - IssueComment, - IssueActivity, -) -from plane.bgtasks.issue_activites_task import issue_activity from plane.api.serializers import ( + IssueActivitySerializer, + IssueCommentSerializer, + IssueLinkSerializer, IssueSerializer, LabelSerializer, - IssueLinkSerializer, - IssueCommentSerializer, - IssueActivitySerializer, +) +from plane.app.permissions import ( + ProjectEntityPermission, + ProjectLitePermission, + ProjectMemberPermission, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Issue, + IssueActivity, + IssueAttachment, + IssueComment, + IssueLink, + Label, + Project, + ProjectMember, ) +from .base import BaseAPIView -class IssueAPIEndpoint(WebhookMixin, BaseAPIView): + +class WorkspaceIssueAPIEndpoint(BaseAPIView): + """ + This viewset provides `retrieveByIssueId` on workspace level + + """ + + model = Issue + webhook_event = "issue" + permission_classes = [ProjectEntityPermission] + serializer_class = IssueSerializer + + @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() + + def get( + self, request, slug, project__identifier=None, issue__identifier=None + ): + if issue__identifier and project__identifier: + issue = Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .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 IssueAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to issue. @@ -67,7 +130,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): def get_queryset(self): return ( Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -86,7 +151,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): def get(self, request, slug, project_id, pk=None): if pk: issue = Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -102,14 +169,19 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( self.get_queryset() .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -117,7 +189,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -127,7 +201,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] + priority_order + if order_by_param == "priority" + else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -175,7 +251,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): else order_by_param ) ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" + "-max_values" + if order_by_param.startswith("-") + else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) @@ -204,12 +282,38 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): ) 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() # Track the issue issue_activity.delay( type="issue.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), actor_id=str(request.user.id), issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), @@ -220,7 +324,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def patch(self, request, slug, project_id, pk=None): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) project = Project.objects.get(pk=project_id) current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder @@ -236,6 +342,26 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): 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", @@ -250,7 +376,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, slug, project_id, pk=None): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) @@ -284,7 +412,11 @@ class LabelAPIEndpoint(BaseAPIView): 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) + .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") @@ -296,13 +428,49 @@ class LabelAPIEndpoint(BaseAPIView): try: serializer = LabelSerializer(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) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - except IntegrityError: + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) return Response( - {"error": "Label with the same name already exists in the project"}, - status=status.HTTP_400_BAD_REQUEST, + 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, ) def get(self, request, slug, project_id, pk=None): @@ -318,17 +486,39 @@ class LabelAPIEndpoint(BaseAPIView): ).data, ) label = self.get_queryset().get(pk=pk) - serializer = LabelSerializer(label, fields=self.fields, expand=self.expand,) + serializer = LabelSerializer( + label, + fields=self.fields, + expand=self.expand, + ) return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request, slug, project_id, pk=None): label = self.get_queryset().get(pk=pk) serializer = LabelSerializer(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 Issue.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"), + ).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() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def delete(self, request, slug, project_id, pk=None): label = self.get_queryset().get(pk=pk) @@ -355,7 +545,11 @@ class IssueLinkAPIEndpoint(BaseAPIView): 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) + .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() ) @@ -395,7 +589,9 @@ class IssueLinkAPIEndpoint(BaseAPIView): ) issue_activity.delay( type="link.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + serializer.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), @@ -407,14 +603,19 @@ class IssueLinkAPIEndpoint(BaseAPIView): def patch(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder, ) - serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) + serializer = IssueLinkSerializer( + issue_link, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -431,7 +632,10 @@ class IssueLinkAPIEndpoint(BaseAPIView): def delete(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) current_instance = json.dumps( IssueLinkSerializer(issue_link).data, @@ -450,7 +654,7 @@ class IssueLinkAPIEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) -class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): +class IssueCommentAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to comments of the particular issue. @@ -466,14 +670,17 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): def get_queryset(self): return ( - IssueComment.objects.filter(workspace__slug=self.kwargs.get("slug")) + IssueComment.objects.filter( + workspace__slug=self.kwargs.get("slug") + ) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("issue") - .select_related("actor") + .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( @@ -509,6 +716,31 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): ) def post(self, request, slug, project_id, issue_id): + # 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": "Issue Comment with the same external id and external source already exists", + "id": str(issue_comment.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -518,7 +750,9 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): ) issue_activity.delay( type="comment.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + serializer.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), @@ -530,13 +764,41 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): def patch(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder, ) + + # 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": "Issue Comment with the same external id and external source already exists", + "id": str(issue_comment.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer = IssueCommentSerializer( issue_comment, data=request.data, partial=True ) @@ -556,7 +818,10 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): def delete(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) current_instance = json.dumps( IssueCommentSerializer(issue_comment).data, @@ -588,10 +853,12 @@ class IssueActivityAPIEndpoint(BaseAPIView): .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")) - + if pk: issue_activities = issue_activities.get(pk=pk) serializer = IssueActivitySerializer(issue_activities) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 959b7ccc3..eeb29dad2 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -2,35 +2,38 @@ import json # Django imports -from django.db.models import Count, Prefetch, Q, F, Func, OuterRef -from django.utils import timezone 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 # Module imports -from .base import BaseAPIView, WebhookMixin +from plane.api.serializers import ( + IssueSerializer, + ModuleIssueSerializer, + ModuleSerializer, +) from plane.app.permissions import ProjectEntityPermission +from plane.bgtasks.issue_activites_task import issue_activity from plane.db.models import ( - Project, - Module, - ModuleLink, Issue, - ModuleIssue, IssueAttachment, IssueLink, + Module, + ModuleIssue, + ModuleLink, + Project, ) -from plane.api.serializers import ( - ModuleSerializer, - ModuleIssueSerializer, - IssueSerializer, -) -from plane.bgtasks.issue_activites_task import issue_activity + +from .base import BaseAPIView +from plane.bgtasks.webhook_task import model_activity -class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): +class ModuleAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to module. @@ -55,7 +58,9 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): .prefetch_related( Prefetch( "link_module", - queryset=ModuleLink.objects.select_related("module", "created_by"), + queryset=ModuleLink.objects.select_related( + "module", "created_by" + ), ) ) .annotate( @@ -65,6 +70,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ), ) .annotate( @@ -75,6 +81,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -85,6 +92,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -95,6 +103,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -105,6 +114,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -115,6 +125,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .order_by(self.kwargs.get("order_by", "-created_at")) @@ -122,25 +133,114 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): def post(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) - serializer = ModuleSerializer(data=request.data, context={"project_id": project_id, "workspace_id": project.workspace_id}) + serializer = ModuleSerializer( + 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.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) module = Module.objects.get(pk=serializer.data["id"]) serializer = ModuleSerializer(module) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - + def patch(self, request, slug, project_id, pk): - module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) - serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True) + 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() - return Response(serializer.data, status=status.HTTP_201_CREATED) + + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + + return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def get(self, request, slug, project_id, pk=None): if pk: - queryset = self.get_queryset().get(pk=pk) + queryset = ( + self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) + ) data = ModuleSerializer( queryset, fields=self.fields, @@ -152,7 +252,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): ) return self.paginate( request=request, - queryset=(self.get_queryset()), + queryset=(self.get_queryset().filter(archived_at__isnull=True)), on_results=lambda modules: ModuleSerializer( modules, many=True, @@ -162,9 +262,13 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): ) def delete(self, request, slug, project_id, pk): - module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + module = Module.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) module_issues = list( - ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True) + ModuleIssue.objects.filter(module_id=pk).values_list( + "issue", flat=True + ) ) issue_activity.delay( type="module.activity.deleted", @@ -185,7 +289,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) -class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): +class ModuleIssueAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to module issues. @@ -204,7 +308,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): def get_queryset(self): return ( ModuleIssue.objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("issue") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -212,7 +318,11 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): .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) + .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") @@ -228,7 +338,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): issues = ( Issue.issue_objects.filter(issue_module__module_id=module_id) .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -250,7 +362,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -271,7 +385,8 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): issues = request.data.get("issues", []) if not len(issues): return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Issues are required"}, + status=status.HTTP_400_BAD_REQUEST, ) module = Module.objects.get( workspace__slug=slug, project_id=project_id, pk=module_id @@ -354,7 +469,10 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): def delete(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.get( - workspace__slug=slug, project_id=project_id, module_id=module_id, issue_id=issue_id + workspace__slug=slug, + project_id=project_id, + module_id=module_id, + issue_id=issue_id, ) module_issue.delete() issue_activity.delay( @@ -371,4 +489,131 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): current_instance=None, epoch=int(timezone.now().timestamp()), ) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + 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, + ), + 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, + ), + 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, + ), + 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, + ), + 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, + ), + 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, + ), + distinct=True, + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + def get(self, request, slug, project_id, pk): + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda modules: ModuleSerializer( + modules, + many=True, + fields=self.fields, + expand=self.expand, + ).data, + ) + + def post(self, request, slug, project_id, pk): + module = Module.objects.get( + pk=pk, project_id=project_id, workspace__slug=slug + ) + 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() + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, slug, project_id, pk): + module = Module.objects.get( + pk=pk, project_id=project_id, workspace__slug=slug + ) + module.archived_at = None + module.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index e8dc9f5a9..019ab704e 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1,31 +1,37 @@ +# Python imports +import json + # Django imports from django.db import IntegrityError -from django.db.models import Exists, OuterRef, Q, F, Func, Subquery, Prefetch +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 plane.api.serializers import ProjectSerializer +from plane.app.permissions import ProjectBasePermission + # Module imports from plane.db.models import ( - Workspace, - Project, - ProjectFavorite, - ProjectMember, - ProjectDeployBoard, - State, Cycle, - Module, - IssueProperty, Inbox, + IssueProperty, + Module, + Project, + ProjectDeployBoard, + ProjectMember, + State, + Workspace, ) -from plane.app.permissions import ProjectBasePermission -from plane.api.serializers import ProjectSerializer -from .base import BaseAPIView, WebhookMixin +from plane.bgtasks.webhook_task import model_activity +from .base import BaseAPIView -class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): +class ProjectAPIEndpoint(BaseAPIView): """Project Endpoints to create, update, list, retrieve and delete endpoint""" serializer_class = ProjectSerializer @@ -39,9 +45,18 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): def get_queryset(self): return ( Project.objects.filter(workspace__slug=self.kwargs.get("slug")) - .filter(Q(project_projectmember__member=self.request.user) | Q(network=2)) + .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" + "workspace", + "workspace__owner", + "default_assignee", + "project_lead", ) .annotate( is_member=Exists( @@ -94,8 +109,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): .distinct() ) - def get(self, request, slug, project_id=None): - if project_id is None: + def get(self, request, slug, pk=None): + if pk is None: sort_order_query = ProjectMember.objects.filter( member=request.user, project_id=OuterRef("pk"), @@ -120,11 +135,18 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): request=request, queryset=(projects), on_results=lambda projects: ProjectSerializer( - projects, many=True, fields=self.fields, expand=self.expand, + projects, + many=True, + fields=self.fields, + expand=self.expand, ).data, ) - project = self.get_queryset().get(workspace__slug=slug, pk=project_id) - serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,) + 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) def post(self, request, slug): @@ -137,8 +159,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): serializer.save() # Add the user as Administrator to the project - project_member = ProjectMember.objects.create( - project_id=serializer.data["id"], member=request.user, role=20 + _ = ProjectMember.objects.create( + project_id=serializer.data["id"], + member=request.user, + role=20, ) # Also create the issue property for the user _ = IssueProperty.objects.create( @@ -211,9 +235,26 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): ] ) - project = self.get_queryset().filter(pk=serializer.data["id"]).first() + project = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .first() + ) + # Model activity + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + serializer = ProjectSerializer(project) - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST, @@ -224,20 +265,29 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): {"name": "The project name is already taken"}, status=status.HTTP_410_GONE, ) - except Workspace.DoesNotExist as e: + except Workspace.DoesNotExist: return Response( - {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND + {"error": "Workspace does not exist"}, + status=status.HTTP_404_NOT_FOUND, ) - except ValidationError as e: + except ValidationError: return Response( {"identifier": "The project identifier is already taken"}, status=status.HTTP_410_GONE, ) - def patch(self, request, slug, project_id=None): + def patch(self, request, slug, pk): try: workspace = Workspace.objects.get(slug=slug) - project = Project.objects.get(pk=project_id) + project = Project.objects.get(pk=pk) + current_instance = json.dumps( + ProjectSerializer(project).data, cls=DjangoJSONEncoder + ) + if project.archived_at: + return Response( + {"error": "Archived project cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) serializer = ProjectSerializer( project, @@ -250,22 +300,42 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): serializer.save() if serializer.data["inbox_view"]: Inbox.objects.get_or_create( - name=f"{project.name} Inbox", project=project, is_default=True + name=f"{project.name} Inbox", + project=project, + is_default=True, ) # Create the triage state in Backlog group State.objects.get_or_create( name="Triage", - group="backlog", + group="triage", description="Default state for managing all Inbox Issues", - project_id=project_id, + project_id=pk, color="#ff7700", + is_triage=True, ) - project = self.get_queryset().filter(pk=serializer.data["id"]).first() + project = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .first() + ) + + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + serializer = ProjectSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) except IntegrityError as e: if "already exists" in str(e): return Response( @@ -274,15 +344,35 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): ) except (Project.DoesNotExist, Workspace.DoesNotExist): return Response( - {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND + {"error": "Project does not exist"}, + status=status.HTTP_404_NOT_FOUND, ) - except ValidationError as e: + except ValidationError: return Response( {"identifier": "The project identifier is already taken"}, status=status.HTTP_410_GONE, ) + def delete(self, request, slug, pk): + project = Project.objects.get(pk=pk, workspace__slug=slug) + project.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView): + + permission_classes = [ + ProjectBasePermission, + ] + + def post(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.archived_at = timezone.now() + project.save() + return Response(status=status.HTTP_204_NO_CONTENT) + def delete(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) - project.delete() - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + project.archived_at = None + project.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 3d2861778..dd239754c 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -1,18 +1,16 @@ -# Python imports -from itertools import groupby - # Django imports -from django.db.models import Q +from django.db import IntegrityError # Third party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response + +from plane.api.serializers import StateSerializer +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import Issue, State # Module imports from .base import BaseAPIView -from plane.api.serializers import StateSerializer -from plane.app.permissions import ProjectEntityPermission -from plane.db.models import State, Issue class StateAPIEndpoint(BaseAPIView): @@ -26,23 +24,73 @@ class StateAPIEndpoint(BaseAPIView): return ( State.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) - .filter(~Q(name="Triage")) + .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() ) def post(self, request, slug, project_id): - serializer = StateSerializer(data=request.data, context={"project_id": project_id}) - if serializer.is_valid(): - serializer.save(project_id=project_id) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + 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, + ) def get(self, request, slug, project_id, state_id=None): if state_id: - serializer = StateSerializer(self.get_queryset().get(pk=state_id)) + serializer = StateSerializer( + self.get_queryset().get(pk=state_id), + fields=self.fields, + expand=self.expand, + ) return Response(serializer.data, status=status.HTTP_200_OK) return self.paginate( request=request, @@ -57,21 +105,26 @@ class StateAPIEndpoint(BaseAPIView): def delete(self, request, slug, project_id, state_id): state = State.objects.get( - ~Q(name="Triage"), + 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) + 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"}, + { + "error": "The state is not empty, only empty states can be deleted" + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -79,9 +132,30 @@ class StateAPIEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) def patch(self, request, slug, project_id, state_id=None): - state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id) + 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) \ No newline at end of file + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/middleware/api_authentication.py b/apiserver/plane/app/middleware/api_authentication.py index ddabb4132..893df7f84 100644 --- a/apiserver/plane/app/middleware/api_authentication.py +++ b/apiserver/plane/app/middleware/api_authentication.py @@ -25,7 +25,10 @@ class APIKeyAuthentication(authentication.BaseAuthentication): def validate_api_token(self, token): try: api_token = APIToken.objects.get( - Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), + Q( + Q(expired_at__gt=timezone.now()) + | Q(expired_at__isnull=True) + ), token=token, is_active=True, ) diff --git a/apiserver/plane/app/permissions/__init__.py b/apiserver/plane/app/permissions/__init__.py index 2298f3442..8e8793504 100644 --- a/apiserver/plane/app/permissions/__init__.py +++ b/apiserver/plane/app/permissions/__init__.py @@ -1,4 +1,3 @@ - from .workspace import ( WorkSpaceBasePermission, WorkspaceOwnerPermission, @@ -13,5 +12,3 @@ from .project import ( ProjectMemberPermission, ProjectLitePermission, ) - - diff --git a/apiserver/plane/app/permissions/project.py b/apiserver/plane/app/permissions/project.py index 80775cbf6..25e5aaeb0 100644 --- a/apiserver/plane/app/permissions/project.py +++ b/apiserver/plane/app/permissions/project.py @@ -1,8 +1,8 @@ # Third Party imports -from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.permissions import SAFE_METHODS, BasePermission # Module import -from plane.db.models import WorkspaceMember, ProjectMember +from plane.db.models import ProjectMember, WorkspaceMember # Permission Mappings Admin = 20 @@ -79,6 +79,16 @@ class ProjectEntityPermission(BasePermission): 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( diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index c406453b7..bdcdf6c0d 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -7,6 +7,8 @@ from .user import ( UserAdminLiteSerializer, UserMeSerializer, UserMeSettingsSerializer, + ProfileSerializer, + AccountSerializer, ) from .workspace import ( WorkSpaceSerializer, @@ -17,6 +19,7 @@ from .workspace import ( WorkspaceThemeSerializer, WorkspaceMemberAdminSerializer, WorkspaceMemberMeSerializer, + WorkspaceUserPropertiesSerializer, ) from .project import ( ProjectSerializer, @@ -25,20 +28,23 @@ from .project import ( ProjectMemberSerializer, ProjectMemberInviteSerializer, ProjectIdentifierSerializer, - ProjectFavoriteSerializer, ProjectLiteSerializer, ProjectMemberLiteSerializer, ProjectDeployBoardSerializer, ProjectMemberAdminSerializer, ProjectPublicMemberSerializer, + ProjectMemberRoleSerializer, ) from .state import StateSerializer, StateLiteSerializer -from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer +from .view import ( + GlobalViewSerializer, + IssueViewSerializer, +) from .cycle import ( CycleSerializer, CycleIssueSerializer, - CycleFavoriteSerializer, CycleWriteSerializer, + CycleUserPropertiesSerializer, ) from .asset import FileAssetSerializer from .issue import ( @@ -52,6 +58,7 @@ from .issue import ( IssueFlatSerializer, IssueStateSerializer, IssueLinkSerializer, + IssueInboxSerializer, IssueLiteSerializer, IssueAttachmentSerializer, IssueSubscriberSerializer, @@ -61,44 +68,56 @@ from .issue import ( IssueRelationSerializer, RelatedIssueSerializer, IssuePublicSerializer, + IssueDetailSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, ) from .module import ( + ModuleDetailSerializer, ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer, ModuleLinkSerializer, - ModuleFavoriteSerializer, + ModuleUserPropertiesSerializer, ) from .api import APITokenSerializer, APITokenReadSerializer -from .integration import ( - IntegrationSerializer, - WorkspaceIntegrationSerializer, - GithubIssueSyncSerializer, - GithubRepositorySerializer, - GithubRepositorySyncSerializer, - GithubCommentSyncSerializer, - SlackProjectSyncSerializer, -) - from .importer import ImporterSerializer -from .page import PageSerializer, PageLogSerializer, SubPageSerializer, PageFavoriteSerializer +from .page import ( + PageSerializer, + PageLogSerializer, + SubPageSerializer, + PageDetailSerializer, +) from .estimate import ( EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer, + WorkspaceEstimateSerializer, ) -from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer +from .inbox import ( + InboxSerializer, + InboxIssueSerializer, + IssueStateInboxSerializer, + InboxIssueLiteSerializer, + InboxIssueDetailSerializer, +) from .analytic import AnalyticViewSerializer -from .notification import NotificationSerializer +from .notification import ( + NotificationSerializer, + UserNotificationPreferenceSerializer, +) from .exporter import ExporterHistorySerializer -from .webhook import WebhookSerializer, WebhookLogSerializer \ No newline at end of file +from .webhook import WebhookSerializer, WebhookLogSerializer + +from .dashboard import DashboardSerializer, WidgetSerializer diff --git a/apiserver/plane/app/serializers/api.py b/apiserver/plane/app/serializers/api.py index 08bb747d9..264a58f92 100644 --- a/apiserver/plane/app/serializers/api.py +++ b/apiserver/plane/app/serializers/api.py @@ -3,7 +3,6 @@ from plane.db.models import APIToken, APIActivityLog class APITokenSerializer(BaseSerializer): - class Meta: model = APIToken fields = "__all__" @@ -18,14 +17,12 @@ class APITokenSerializer(BaseSerializer): class APITokenReadSerializer(BaseSerializer): - class Meta: model = APIToken - exclude = ('token',) + exclude = ("token",) class APIActivityLogSerializer(BaseSerializer): - class Meta: model = APIActivityLog fields = "__all__" diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py index 89c9725d9..6693ba931 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -4,16 +4,17 @@ from rest_framework import serializers class BaseSerializer(serializers.ModelSerializer): id = serializers.PrimaryKeyRelatedField(read_only=True) -class DynamicBaseSerializer(BaseSerializer): +class DynamicBaseSerializer(BaseSerializer): def __init__(self, *args, **kwargs): # If 'fields' is provided in the arguments, remove it and store it separately. # This is done so as not to pass this custom argument up to the superclass. - fields = kwargs.pop("fields", None) + fields = kwargs.pop("fields", []) + self.expand = kwargs.pop("expand", []) or [] + 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) @@ -31,7 +32,7 @@ class DynamicBaseSerializer(BaseSerializer): # loop through its keys and values. if isinstance(field_name, dict): for key, value in field_name.items(): - # If the value of this nested field is a list, + # If the value of this nested field is a list, # perform a recursive filter on it. if isinstance(value, list): self._filter_fields(self.fields[key], value) @@ -47,12 +48,134 @@ class DynamicBaseSerializer(BaseSerializer): 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) + for field in allowed: + if field not in self.fields: + from . import ( + WorkspaceLiteSerializer, + ProjectLiteSerializer, + UserLiteSerializer, + StateLiteSerializer, + IssueSerializer, + LabelSerializer, + CycleIssueSerializer, + IssueLiteSerializer, + IssueRelationSerializer, + InboxIssueLiteSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, + ) - # Remove fields from the serializer that aren't in the 'allowed' list. - for field_name in (existing - allowed): - self.fields.pop(field_name) + # 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_inbox": InboxIssueLiteSerializer, + "issue_reactions": IssueReactionLiteSerializer, + "issue_attachment": IssueAttachmentLiteSerializer, + "issue_link": IssueLinkLiteSerializer, + "sub_issues": IssueLiteSerializer, + } + + self.fields[field] = expansion[field]( + many=( + True + if field + in [ + "members", + "assignees", + "labels", + "issue_cycle", + "issue_relation", + "issue_inbox", + "issue_reactions", + "issue_attachment", + "issue_link", + "sub_issues", + ] + 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, + InboxIssueLiteSerializer, + IssueLiteSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, + ) + + # 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_inbox": InboxIssueLiteSerializer, + "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 + ) + + return response diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 104a3dd06..97fd47960 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -3,11 +3,12 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from .user import UserLiteSerializer from .issue import IssueStateSerializer -from .workspace import WorkspaceLiteSerializer -from .project import ProjectLiteSerializer -from plane.db.models import Cycle, CycleIssue, CycleFavorite +from plane.db.models import ( + Cycle, + CycleIssue, + CycleUserProperties, +) class CycleWriteSerializer(BaseSerializer): @@ -17,69 +18,68 @@ class CycleWriteSerializer(BaseSerializer): and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None) ): - raise serializers.ValidationError("Start date cannot exceed end date") + raise serializers.ValidationError( + "Start date cannot exceed end date" + ) return data class Meta: model = Cycle fields = "__all__" - - -class CycleSerializer(BaseSerializer): - owned_by = UserLiteSerializer(read_only=True) - is_favorite = serializers.BooleanField(read_only=True) - total_issues = serializers.IntegerField(read_only=True) - cancelled_issues = serializers.IntegerField(read_only=True) - completed_issues = serializers.IntegerField(read_only=True) - started_issues = serializers.IntegerField(read_only=True) - unstarted_issues = serializers.IntegerField(read_only=True) - backlog_issues = serializers.IntegerField(read_only=True) - assignees = serializers.SerializerMethodField(read_only=True) - total_estimates = serializers.IntegerField(read_only=True) - completed_estimates = serializers.IntegerField(read_only=True) - started_estimates = serializers.IntegerField(read_only=True) - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - - def validate(self, data): - if ( - data.get("start_date", None) is not None - and data.get("end_date", None) is not None - and data.get("start_date", None) > data.get("end_date", None) - ): - raise serializers.ValidationError("Start date cannot exceed end date") - return data - - def get_assignees(self, obj): - members = [ - { - "avatar": assignee.avatar, - "display_name": assignee.display_name, - "id": assignee.id, - } - for issue_cycle in obj.issue_cycle.prefetch_related( - "issue__assignees" - ).all() - for assignee in issue_cycle.issue.assignees.all() - ] - # Use a set comprehension to return only the unique objects - unique_objects = {frozenset(item.items()) for item in members} - - # Convert the set back to a list of dictionaries - unique_list = [dict(item) for item in unique_objects] - - return unique_list - - class Meta: - model = Cycle - fields = "__all__" read_only_fields = [ "workspace", "project", "owned_by", + "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) @@ -93,15 +93,12 @@ class CycleIssueSerializer(BaseSerializer): "cycle", ] - -class CycleFavoriteSerializer(BaseSerializer): - cycle_detail = CycleSerializer(source="cycle", read_only=True) - +class CycleUserPropertiesSerializer(BaseSerializer): class Meta: - model = CycleFavorite + model = CycleUserProperties fields = "__all__" read_only_fields = [ "workspace", "project", - "user", + "cycle" "user", ] diff --git a/apiserver/plane/app/serializers/dashboard.py b/apiserver/plane/app/serializers/dashboard.py new file mode 100644 index 000000000..b0ed8841b --- /dev/null +++ b/apiserver/plane/app/serializers/dashboard.py @@ -0,0 +1,21 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import Dashboard, Widget + +# Third party frameworks +from rest_framework import serializers + + +class DashboardSerializer(BaseSerializer): + class Meta: + model = Dashboard + fields = "__all__" + + +class WidgetSerializer(BaseSerializer): + is_visible = serializers.BooleanField(read_only=True) + widget_filters = serializers.JSONField(read_only=True) + + class Meta: + model = Widget + fields = ["id", "key", "is_visible", "widget_filters"] diff --git a/apiserver/plane/app/serializers/estimate.py b/apiserver/plane/app/serializers/estimate.py index 4a1cda779..d28f38c75 100644 --- a/apiserver/plane/app/serializers/estimate.py +++ b/apiserver/plane/app/serializers/estimate.py @@ -2,11 +2,18 @@ from .base import BaseSerializer from plane.db.models import Estimate, EstimatePoint -from plane.app.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer +from plane.app.serializers import ( + WorkspaceLiteSerializer, + ProjectLiteSerializer, +) + +from rest_framework import serializers class EstimateSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) project_detail = ProjectLiteSerializer(read_only=True, source="project") class Meta: @@ -19,6 +26,16 @@ class EstimateSerializer(BaseSerializer): 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__" @@ -31,7 +48,9 @@ class EstimatePointSerializer(BaseSerializer): class EstimateReadSerializer(BaseSerializer): points = EstimatePointSerializer(read_only=True, many=True) - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) project_detail = ProjectLiteSerializer(read_only=True, source="project") class Meta: @@ -42,3 +61,16 @@ class EstimateReadSerializer(BaseSerializer): "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/apiserver/plane/app/serializers/exporter.py b/apiserver/plane/app/serializers/exporter.py index 5c78cfa69..2dd850fd3 100644 --- a/apiserver/plane/app/serializers/exporter.py +++ b/apiserver/plane/app/serializers/exporter.py @@ -5,7 +5,9 @@ from .user import UserLiteSerializer class ExporterHistorySerializer(BaseSerializer): - initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True) + initiated_by_detail = UserLiteSerializer( + source="initiated_by", read_only=True + ) class Meta: model = ExporterHistory diff --git a/apiserver/plane/app/serializers/importer.py b/apiserver/plane/app/serializers/importer.py index 8997f6392..c058994d6 100644 --- a/apiserver/plane/app/serializers/importer.py +++ b/apiserver/plane/app/serializers/importer.py @@ -7,9 +7,13 @@ from plane.db.models import Importer class ImporterSerializer(BaseSerializer): - initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True) + initiated_by_detail = UserLiteSerializer( + source="initiated_by", read_only=True + ) project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) class Meta: model = Importer diff --git a/apiserver/plane/app/serializers/inbox.py b/apiserver/plane/app/serializers/inbox.py index f52a90660..e0c18b3d1 100644 --- a/apiserver/plane/app/serializers/inbox.py +++ b/apiserver/plane/app/serializers/inbox.py @@ -3,7 +3,11 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from .issue import IssueFlatSerializer, LabelLiteSerializer +from .issue import ( + IssueInboxSerializer, + LabelLiteSerializer, + IssueDetailSerializer, +) from .project import ProjectLiteSerializer from .state import StateLiteSerializer from .user import UserLiteSerializer @@ -24,17 +28,62 @@ class InboxSerializer(BaseSerializer): class InboxIssueSerializer(BaseSerializer): - issue_detail = IssueFlatSerializer(source="issue", read_only=True) - project_detail = ProjectLiteSerializer(source="project", read_only=True) + issue = IssueInboxSerializer(read_only=True) class Meta: model = InboxIssue - fields = "__all__" + 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 InboxIssueDetailSerializer(BaseSerializer): + issue = IssueDetailSerializer(read_only=True) + duplicate_issue_detail = IssueInboxSerializer( + read_only=True, source="duplicate_to" + ) + + class Meta: + model = InboxIssue + 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 InboxIssueLiteSerializer(BaseSerializer): class Meta: @@ -46,10 +95,13 @@ class InboxIssueLiteSerializer(BaseSerializer): class IssueStateInboxSerializer(BaseSerializer): state_detail = StateLiteSerializer(read_only=True, source="state") project_detail = ProjectLiteSerializer(read_only=True, source="project") - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + label_details = LabelLiteSerializer( + read_only=True, source="labels", many=True + ) + assignee_details = UserLiteSerializer( + read_only=True, source="assignees", many=True + ) sub_issues_count = serializers.IntegerField(read_only=True) - bridge_id = serializers.UUIDField(read_only=True) issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True) class Meta: diff --git a/apiserver/plane/app/serializers/integration/__init__.py b/apiserver/plane/app/serializers/integration/__init__.py deleted file mode 100644 index 112ff02d1..000000000 --- a/apiserver/plane/app/serializers/integration/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .base import IntegrationSerializer, WorkspaceIntegrationSerializer -from .github import ( - GithubRepositorySerializer, - GithubRepositorySyncSerializer, - GithubIssueSyncSerializer, - GithubCommentSyncSerializer, -) -from .slack import SlackProjectSyncSerializer diff --git a/apiserver/plane/app/serializers/integration/base.py b/apiserver/plane/app/serializers/integration/base.py deleted file mode 100644 index 6f6543b9e..000000000 --- a/apiserver/plane/app/serializers/integration/base.py +++ /dev/null @@ -1,20 +0,0 @@ -# Module imports -from plane.app.serializers import BaseSerializer -from plane.db.models import Integration, WorkspaceIntegration - - -class IntegrationSerializer(BaseSerializer): - class Meta: - model = Integration - fields = "__all__" - read_only_fields = [ - "verified", - ] - - -class WorkspaceIntegrationSerializer(BaseSerializer): - integration_detail = IntegrationSerializer(read_only=True, source="integration") - - class Meta: - model = WorkspaceIntegration - fields = "__all__" diff --git a/apiserver/plane/app/serializers/integration/github.py b/apiserver/plane/app/serializers/integration/github.py deleted file mode 100644 index 850bccf1b..000000000 --- a/apiserver/plane/app/serializers/integration/github.py +++ /dev/null @@ -1,45 +0,0 @@ -# Module imports -from plane.app.serializers import BaseSerializer -from plane.db.models import ( - GithubIssueSync, - GithubRepository, - GithubRepositorySync, - GithubCommentSync, -) - - -class GithubRepositorySerializer(BaseSerializer): - class Meta: - model = GithubRepository - fields = "__all__" - - -class GithubRepositorySyncSerializer(BaseSerializer): - repo_detail = GithubRepositorySerializer(source="repository") - - class Meta: - model = GithubRepositorySync - fields = "__all__" - - -class GithubIssueSyncSerializer(BaseSerializer): - class Meta: - model = GithubIssueSync - fields = "__all__" - read_only_fields = [ - "project", - "workspace", - "repository_sync", - ] - - -class GithubCommentSyncSerializer(BaseSerializer): - class Meta: - model = GithubCommentSync - fields = "__all__" - read_only_fields = [ - "project", - "workspace", - "repository_sync", - "issue_sync", - ] diff --git a/apiserver/plane/app/serializers/integration/slack.py b/apiserver/plane/app/serializers/integration/slack.py deleted file mode 100644 index 9c461c5b9..000000000 --- a/apiserver/plane/app/serializers/integration/slack.py +++ /dev/null @@ -1,14 +0,0 @@ -# Module imports -from plane.app.serializers import BaseSerializer -from plane.db.models import SlackProjectSync - - -class SlackProjectSyncSerializer(BaseSerializer): - class Meta: - model = SlackProjectSync - fields = "__all__" - read_only_fields = [ - "project", - "workspace", - "workspace_integration", - ] diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index b13d03e35..e4a04fadf 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -1,5 +1,7 @@ # Django imports from django.utils import timezone +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError # Third Party imports from rest_framework import serializers @@ -7,7 +9,7 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer -from .state import StateSerializer, StateLiteSerializer +from .state import StateLiteSerializer from .project import ProjectLiteSerializer from .workspace import WorkspaceLiteSerializer from plane.db.models import ( @@ -30,6 +32,7 @@ from plane.db.models import ( CommentReaction, IssueVote, IssueRelation, + State, ) @@ -69,19 +72,26 @@ class IssueProjectLiteSerializer(BaseSerializer): ##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()), + # 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, ) - - labels = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, required=False, ) @@ -100,8 +110,10 @@ class IssueCreateSerializer(BaseSerializer): def to_representation(self, instance): data = super().to_representation(instance) - data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()] - data['labels'] = [str(label.id) for label in instance.labels.all()] + assignee_ids = self.initial_data.get("assignee_ids") + data["assignee_ids"] = assignee_ids if assignee_ids else [] + label_ids = self.initial_data.get("label_ids") + data["label_ids"] = label_ids if label_ids else [] return data def validate(self, data): @@ -110,12 +122,14 @@ class IssueCreateSerializer(BaseSerializer): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError("Start date cannot exceed target date") + raise serializers.ValidationError( + "Start date cannot exceed target date" + ) return data def create(self, validated_data): - assignees = validated_data.pop("assignees", None) - labels = validated_data.pop("labels", None) + 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"] @@ -173,8 +187,8 @@ class IssueCreateSerializer(BaseSerializer): return issue def update(self, instance, validated_data): - assignees = validated_data.pop("assignees", None) - labels = validated_data.pop("labels", None) + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) # Related models project_id = instance.project_id @@ -225,14 +239,15 @@ class IssueActivitySerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") issue_detail = IssueFlatSerializer(read_only=True, source="issue") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) class Meta: model = IssueActivity fields = "__all__" - class IssuePropertySerializer(BaseSerializer): class Meta: model = IssueProperty @@ -245,12 +260,17 @@ class IssuePropertySerializer(BaseSerializer): 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__" + fields = [ + "parent", + "name", + "color", + "id", + "project_id", + "workspace_id", + "sort_order", + ] read_only_fields = [ "workspace", "project", @@ -268,7 +288,6 @@ class LabelLiteSerializer(BaseSerializer): class IssueLabelSerializer(BaseSerializer): - class Meta: model = IssueLabel fields = "__all__" @@ -279,33 +298,50 @@ class IssueLabelSerializer(BaseSerializer): class IssueRelationSerializer(BaseSerializer): - issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") + 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) class Meta: model = IssueRelation fields = [ - "issue_detail", + "id", + "project_id", + "sequence_id", "relation_type", - "related_issue", - "issue", - "id" + "name", ] read_only_fields = [ "workspace", "project", ] + class RelatedIssueSerializer(BaseSerializer): - issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") + 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) class Meta: model = IssueRelation fields = [ - "issue_detail", + "id", + "project_id", + "sequence_id", "relation_type", - "related_issue", - "issue", - "id" + "name", ] read_only_fields = [ "workspace", @@ -397,16 +433,57 @@ class IssueLinkSerializer(BaseSerializer): "issue", ] + 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") + 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): class Meta: @@ -423,10 +500,23 @@ class IssueAttachmentSerializer(BaseSerializer): ] +class IssueAttachmentLiteSerializer(DynamicBaseSerializer): + class Meta: + model = IssueAttachment + fields = [ + "id", + "asset", + "attributes", + "issue_id", + "updated_at", + "updated_by_id", + ] + read_only_fields = fields + + class IssueReactionSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - + class Meta: model = IssueReaction fields = "__all__" @@ -438,16 +528,14 @@ class IssueReactionSerializer(BaseSerializer): ] -class CommentReactionLiteSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - +class IssueReactionLiteSerializer(DynamicBaseSerializer): class Meta: - model = CommentReaction + model = IssueReaction fields = [ "id", + "actor", + "issue", "reaction", - "comment", - "actor_detail", ] @@ -459,12 +547,18 @@ class CommentReactionSerializer(BaseSerializer): class IssueVoteSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") class Meta: model = IssueVote - fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"] + fields = [ + "issue", + "vote", + "workspace", + "project", + "actor", + "actor_detail", + ] read_only_fields = fields @@ -472,8 +566,10 @@ class IssueCommentSerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") issue_detail = IssueFlatSerializer(read_only=True, source="issue") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) + comment_reactions = CommentReactionSerializer(read_only=True, many=True) is_member = serializers.BooleanField(read_only=True) class Meta: @@ -507,12 +603,15 @@ class IssueStateFlatSerializer(BaseSerializer): # Issue Serializer with state details class IssueStateSerializer(DynamicBaseSerializer): - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + label_details = LabelLiteSerializer( + read_only=True, source="labels", many=True + ) state_detail = StateLiteSerializer(read_only=True, source="state") project_detail = ProjectLiteSerializer(read_only=True, source="project") - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + assignee_details = UserLiteSerializer( + read_only=True, source="assignees", many=True + ) sub_issues_count = serializers.IntegerField(read_only=True) - bridge_id = serializers.UUIDField(read_only=True) attachment_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True) @@ -521,67 +620,111 @@ class IssueStateSerializer(DynamicBaseSerializer): fields = "__all__" -class IssueSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - state_detail = StateSerializer(read_only=True, source="state") - parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") - label_details = LabelSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) - issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True) - issue_cycle = IssueCycleDetailSerializer(read_only=True) - issue_module = IssueModuleDetailSerializer(read_only=True) - issue_link = IssueLinkSerializer(read_only=True, many=True) - issue_attachment = IssueAttachmentSerializer(read_only=True, many=True) - sub_issues_count = serializers.IntegerField(read_only=True) - issue_reactions = IssueReactionSerializer(read_only=True, many=True) +class IssueInboxSerializer(DynamicBaseSerializer): + label_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) class Meta: model = Issue - fields = "__all__" - read_only_fields = [ - "workspace", - "project", + fields = [ + "id", + "name", + "priority", + "sequence_id", + "project_id", + "created_at", + "label_ids", "created_by", - "updated_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 IssueLiteSerializer(DynamicBaseSerializer): - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - state_detail = StateLiteSerializer(read_only=True, source="state") - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - sub_issues_count = serializers.IntegerField(read_only=True) - cycle_id = serializers.UUIDField(read_only=True) - module_id = serializers.UUIDField(read_only=True) - attachment_count = serializers.IntegerField(read_only=True) - link_count = serializers.IntegerField(read_only=True) - issue_reactions = IssueReactionSerializer(read_only=True, many=True) - class Meta: model = Issue - fields = "__all__" - read_only_fields = [ - "start_date", - "target_date", - "completed_at", - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", + fields = [ + "id", + "sequence_id", + "project_id", ] + read_only_fields = fields + + +class IssueDetailSerializer(IssueSerializer): + description_html = serializers.CharField() + is_subscribed = serializers.BooleanField(read_only=True) + + class Meta(IssueSerializer.Meta): + fields = IssueSerializer.Meta.fields + [ + "description_html", + "is_subscribed", + ] + 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") + reactions = IssueReactionSerializer( + read_only=True, many=True, source="issue_reactions" + ) votes = IssueVoteSerializer(read_only=True, many=True) class Meta: @@ -604,7 +747,6 @@ class IssuePublicSerializer(BaseSerializer): read_only_fields = fields - class IssueSubscriberSerializer(BaseSerializer): class Meta: model = IssueSubscriber diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index 48f773b0f..28d28d7db 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -2,10 +2,8 @@ from rest_framework import serializers # Module imports -from .base import BaseSerializer -from .user import UserLiteSerializer +from .base import BaseSerializer, DynamicBaseSerializer from .project import ProjectLiteSerializer -from .workspace import WorkspaceLiteSerializer from plane.db.models import ( User, @@ -13,20 +11,23 @@ from plane.db.models import ( ModuleMember, ModuleIssue, ModuleLink, - ModuleFavorite, + ModuleUserProperties, ) class ModuleWriteSerializer(BaseSerializer): - members = serializers.ListField( + 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, ) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) - class Meta: model = Module fields = "__all__" @@ -37,25 +38,32 @@ class ModuleWriteSerializer(BaseSerializer): "updated_by", "created_at", "updated_at", + "archived_at", ] - + def to_representation(self, instance): data = super().to_representation(instance) - data['members'] = [str(member.id) for member in instance.members.all()] + data["member_ids"] = [ + str(member.id) for member in instance.members.all() + ] return data def validate(self, data): - if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None): - raise serializers.ValidationError("Start date cannot exceed target date") - return data + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): + raise serializers.ValidationError( + "Start date cannot exceed target date" + ) + return data def create(self, validated_data): - members = validated_data.pop("members", None) - + members = validated_data.pop("member_ids", None) project = self.context["project"] module = Module.objects.create(**validated_data, project=project) - if members is not None: ModuleMember.objects.bulk_create( [ @@ -76,7 +84,7 @@ class ModuleWriteSerializer(BaseSerializer): return module def update(self, instance, validated_data): - members = validated_data.pop("members", None) + members = validated_data.pop("member_ids", None) if members is not None: ModuleMember.objects.filter(module=instance).delete() @@ -133,8 +141,6 @@ class ModuleIssueSerializer(BaseSerializer): class ModuleLinkSerializer(BaseSerializer): - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") - class Meta: model = ModuleLink fields = "__all__" @@ -151,7 +157,8 @@ class ModuleLinkSerializer(BaseSerializer): # Validation if url already exists def create(self, validated_data): if ModuleLink.objects.filter( - url=validated_data.get("url"), module_id=validated_data.get("module_id") + url=validated_data.get("url"), + module_id=validated_data.get("module_id"), ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} @@ -159,11 +166,10 @@ class ModuleLinkSerializer(BaseSerializer): return ModuleLink.objects.create(**validated_data) -class ModuleSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - lead_detail = UserLiteSerializer(read_only=True, source="lead") - members_detail = UserLiteSerializer(read_only=True, many=True, source="members") - link_module = ModuleLinkSerializer(read_only=True, many=True) +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) @@ -174,25 +180,51 @@ class ModuleSerializer(BaseSerializer): class Meta: model = Module - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", + 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 + "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 ModuleFavoriteSerializer(BaseSerializer): - module_detail = ModuleFlatSerializer(source="module", read_only=True) +class ModuleDetailSerializer(ModuleSerializer): + link_module = ModuleLinkSerializer(read_only=True, many=True) + sub_issues = serializers.IntegerField(read_only=True) + class Meta(ModuleSerializer.Meta): + fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"] + + +class ModuleUserPropertiesSerializer(BaseSerializer): class Meta: - model = ModuleFavorite + model = ModuleUserProperties fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "user", - ] + read_only_fields = ["workspace", "project", "module", "user"] diff --git a/apiserver/plane/app/serializers/notification.py b/apiserver/plane/app/serializers/notification.py index b6a4f3e4a..c6713a354 100644 --- a/apiserver/plane/app/serializers/notification.py +++ b/apiserver/plane/app/serializers/notification.py @@ -1,12 +1,20 @@ # Module imports from .base import BaseSerializer from .user import UserLiteSerializer -from plane.db.models import Notification +from plane.db.models import Notification, UserNotificationPreference + class NotificationSerializer(BaseSerializer): - triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by") + triggered_by_details = UserLiteSerializer( + read_only=True, source="triggered_by" + ) class Meta: model = Notification fields = "__all__" + +class UserNotificationPreferenceSerializer(BaseSerializer): + class Meta: + model = UserNotificationPreference + fields = "__all__" diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index ff152627a..f13923831 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -3,42 +3,65 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from .issue import IssueFlatSerializer, LabelLiteSerializer -from .workspace import WorkspaceLiteSerializer -from .project import ProjectLiteSerializer -from plane.db.models import Page, PageLog, PageFavorite, PageLabel, Label, Issue, Module +from plane.db.models import ( + Page, + PageLog, + PageLabel, + Label, +) class PageSerializer(BaseSerializer): is_favorite = serializers.BooleanField(read_only=True) - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) labels = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, ) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) class Meta: model = Page - fields = "__all__" + fields = [ + "id", + "name", + "owned_by", + "access", + "color", + "labels", + "parent", + "is_favorite", + "is_locked", + "archived_at", + "workspace", + "project", + "created_at", + "updated_at", + "created_by", + "updated_by", + "view_props", + "logo_props", + ] read_only_fields = [ "workspace", "project", "owned_by", ] + def to_representation(self, instance): data = super().to_representation(instance) - data['labels'] = [str(label.id) for label in instance.labels.all()] + data["labels"] = [str(label.id) for label in instance.labels.all()] return data 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_html = self.context["description_html"] page = Page.objects.create( - **validated_data, project_id=project_id, owned_by_id=owned_by_id + **validated_data, + description_html=description_html, + project_id=project_id, + owned_by_id=owned_by_id, ) if labels is not None: @@ -80,6 +103,15 @@ class PageSerializer(BaseSerializer): return super().update(instance, validated_data) +class PageDetailSerializer(PageSerializer): + description_html = serializers.CharField() + + class Meta(PageSerializer.Meta): + fields = PageSerializer.Meta.fields + [ + "description_html", + ] + + class SubPageSerializer(BaseSerializer): entity_details = serializers.SerializerMethodField() @@ -94,7 +126,7 @@ class SubPageSerializer(BaseSerializer): def get_entity_details(self, obj): entity_name = obj.entity_name - if entity_name == 'forward_link' or entity_name == 'back_link': + if entity_name == "forward_link" or entity_name == "back_link": try: page = Page.objects.get(pk=obj.entity_identifier) return PageSerializer(page).data @@ -104,7 +136,6 @@ class SubPageSerializer(BaseSerializer): class PageLogSerializer(BaseSerializer): - class Meta: model = PageLog fields = "__all__" @@ -112,17 +143,4 @@ class PageLogSerializer(BaseSerializer): "workspace", "project", "page", - ] - - -class PageFavoriteSerializer(BaseSerializer): - page_detail = PageSerializer(source="page", read_only=True) - - class Meta: - model = PageFavorite - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "user", - ] + ] \ No newline at end of file diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index aef715e33..96d92f340 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -4,20 +4,24 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer, DynamicBaseSerializer from plane.app.serializers.workspace import WorkspaceLiteSerializer -from plane.app.serializers.user import UserLiteSerializer, UserAdminLiteSerializer +from plane.app.serializers.user import ( + UserLiteSerializer, + UserAdminLiteSerializer, +) from plane.db.models import ( Project, ProjectMember, ProjectMemberInvite, ProjectIdentifier, - ProjectFavorite, ProjectDeployBoard, ProjectPublicMember, ) class ProjectSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) class Meta: model = Project @@ -29,12 +33,16 @@ class ProjectSerializer(BaseSerializer): def create(self, validated_data): identifier = validated_data.get("identifier", "").strip().upper() if identifier == "": - raise serializers.ValidationError(detail="Project Identifier is required") + raise serializers.ValidationError( + detail="Project Identifier is required" + ) if ProjectIdentifier.objects.filter( name=identifier, workspace_id=self.context["workspace_id"] ).exists(): - raise serializers.ValidationError(detail="Project Identifier is taken") + raise serializers.ValidationError( + detail="Project Identifier is taken" + ) project = Project.objects.create( **validated_data, workspace_id=self.context["workspace_id"] ) @@ -73,7 +81,9 @@ class ProjectSerializer(BaseSerializer): return project # If not same fail update - raise serializers.ValidationError(detail="Project Identifier is already taken") + raise serializers.ValidationError( + detail="Project Identifier is already taken" + ) class ProjectLiteSerializer(BaseSerializer): @@ -84,14 +94,19 @@ class ProjectLiteSerializer(BaseSerializer): "identifier", "name", "cover_image", - "icon_prop", - "emoji", + "logo_props", "description", ] read_only_fields = fields class ProjectListSerializer(DynamicBaseSerializer): + total_issues = serializers.IntegerField(read_only=True) + archived_issues = serializers.IntegerField(read_only=True) + archived_sub_issues = serializers.IntegerField(read_only=True) + draft_issues = serializers.IntegerField(read_only=True) + draft_sub_issues = serializers.IntegerField(read_only=True) + sub_issues = serializers.IntegerField(read_only=True) is_favorite = serializers.BooleanField(read_only=True) total_members = serializers.IntegerField(read_only=True) total_cycles = serializers.IntegerField(read_only=True) @@ -160,6 +175,12 @@ class ProjectMemberAdminSerializer(BaseSerializer): fields = "__all__" +class ProjectMemberRoleSerializer(DynamicBaseSerializer): + class Meta: + model = ProjectMember + fields = ("id", "role", "member", "project") + + class ProjectMemberInviteSerializer(BaseSerializer): project = ProjectLiteSerializer(read_only=True) workspace = WorkspaceLiteSerializer(read_only=True) @@ -175,16 +196,6 @@ class ProjectIdentifierSerializer(BaseSerializer): fields = "__all__" -class ProjectFavoriteSerializer(BaseSerializer): - class Meta: - model = ProjectFavorite - fields = "__all__" - read_only_fields = [ - "workspace", - "user", - ] - - class ProjectMemberLiteSerializer(BaseSerializer): member = UserLiteSerializer(read_only=True) is_subscribed = serializers.BooleanField(read_only=True) @@ -197,7 +208,9 @@ class ProjectMemberLiteSerializer(BaseSerializer): class ProjectDeployBoardSerializer(BaseSerializer): project_details = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) class Meta: model = ProjectDeployBoard @@ -217,4 +230,4 @@ class ProjectPublicMemberSerializer(BaseSerializer): "workspace", "project", "member", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/app/serializers/state.py b/apiserver/plane/app/serializers/state.py index 323254f26..773d8e461 100644 --- a/apiserver/plane/app/serializers/state.py +++ b/apiserver/plane/app/serializers/state.py @@ -6,10 +6,19 @@ from plane.db.models import State class StateSerializer(BaseSerializer): - class Meta: model = State - fields = "__all__" + fields = [ + "id", + "project_id", + "workspace_id", + "name", + "color", + "group", + "default", + "description", + "sequence", + ] read_only_fields = [ "workspace", "project", @@ -25,4 +34,4 @@ class StateLiteSerializer(BaseSerializer): "color", "group", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index 1b94758e8..05d8665b5 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -2,9 +2,15 @@ from rest_framework import serializers # Module import +from plane.db.models import ( + Account, + Profile, + User, + Workspace, + WorkspaceMemberInvite, +) + from .base import BaseSerializer -from plane.db.models import User, Workspace, WorkspaceMemberInvite -from plane.license.models import InstanceAdmin, Instance class UserSerializer(BaseSerializer): @@ -24,10 +30,10 @@ class UserSerializer(BaseSerializer): "last_logout_ip", "last_login_uagent", "token_updated_at", - "is_onboarded", "is_bot", "is_password_autoset", "is_email_verified", + "is_active", ] extra_kwargs = {"password": {"write_only": True}} @@ -51,19 +57,11 @@ class UserMeSerializer(BaseSerializer): "is_active", "is_bot", "is_email_verified", - "is_managed", - "is_onboarded", - "is_tour_completed", - "mobile_number", - "role", - "onboarding_step", "user_timezone", "username", - "theme", - "last_workspace_id", - "use_case", "is_password_autoset", "is_email_verified", + "last_login_medium", ] read_only_fields = fields @@ -84,32 +82,38 @@ class UserMeSettingsSerializer(BaseSerializer): workspace_invites = WorkspaceMemberInvite.objects.filter( email=obj.email ).count() + + # profile + profile = Profile.objects.get(user=obj) if ( - obj.last_workspace_id is not None + profile.last_workspace_id is not None and Workspace.objects.filter( - pk=obj.last_workspace_id, + pk=profile.last_workspace_id, workspace_member__member=obj.id, workspace_member__is_active=True, ).exists() ): workspace = Workspace.objects.filter( - pk=obj.last_workspace_id, + pk=profile.last_workspace_id, workspace_member__member=obj.id, workspace_member__is_active=True, ).first() return { - "last_workspace_id": obj.last_workspace_id, - "last_workspace_slug": workspace.slug if workspace is not None else "", - "fallback_workspace_id": obj.last_workspace_id, - "fallback_workspace_slug": workspace.slug - if workspace is not None - else "", + "last_workspace_id": profile.last_workspace_id, + "last_workspace_slug": ( + workspace.slug if workspace is not None else "" + ), + "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 + workspace_member__member_id=obj.id, + workspace_member__is_active=True, ) .order_by("created_at") .first() @@ -117,12 +121,16 @@ class UserMeSettingsSerializer(BaseSerializer): 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, + "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, } @@ -180,7 +188,9 @@ class ChangePasswordSerializer(serializers.Serializer): if data.get("new_password") != data.get("confirm_password"): raise serializers.ValidationError( - {"error": "Confirm password should be same as the new password."} + { + "error": "Confirm password should be same as the new password." + } ) return data @@ -190,4 +200,17 @@ 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__" + + +class AccountSerializer(BaseSerializer): + class Meta: + model = Account + fields = "__all__" diff --git a/apiserver/plane/app/serializers/view.py b/apiserver/plane/app/serializers/view.py index e7502609a..c46a545d0 100644 --- a/apiserver/plane/app/serializers/view.py +++ b/apiserver/plane/app/serializers/view.py @@ -2,15 +2,17 @@ from rest_framework import serializers # Module imports -from .base import BaseSerializer +from .base import BaseSerializer, DynamicBaseSerializer from .workspace import WorkspaceLiteSerializer from .project import ProjectLiteSerializer -from plane.db.models import GlobalView, IssueView, IssueViewFavorite +from plane.db.models import GlobalView, IssueView from plane.utils.issue_filters import issue_filters class GlobalViewSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) class Meta: model = GlobalView @@ -38,10 +40,12 @@ class GlobalViewSerializer(BaseSerializer): return super().update(instance, validated_data) -class IssueViewSerializer(BaseSerializer): +class IssueViewSerializer(DynamicBaseSerializer): is_favorite = serializers.BooleanField(read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) class Meta: model = IssueView @@ -68,16 +72,3 @@ class IssueViewSerializer(BaseSerializer): validated_data["query"] = {} validated_data["query"] = issue_filters(query_params, "PATCH") return super().update(instance, validated_data) - - -class IssueViewFavoriteSerializer(BaseSerializer): - view_detail = IssueViewSerializer(source="issue_view", read_only=True) - - class Meta: - model = IssueViewFavorite - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "user", - ] diff --git a/apiserver/plane/app/serializers/webhook.py b/apiserver/plane/app/serializers/webhook.py index 961466d28..175dea304 100644 --- a/apiserver/plane/app/serializers/webhook.py +++ b/apiserver/plane/app/serializers/webhook.py @@ -1,5 +1,4 @@ # Python imports -import urllib import socket import ipaddress from urllib.parse import urlparse @@ -10,78 +9,113 @@ 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 +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."}) + 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."}) + raise serializers.ValidationError( + {"url": "Hostname could not be resolved."} + ) if not ip_addresses: - raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) + raise serializers.ValidationError( + {"url": "No IP addresses found for the hostname."} + ) for addr in ip_addresses: ip = ipaddress.ip_address(addr[4][0]) if ip.is_private or ip.is_loopback: - raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) + 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 + 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 + 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."}) + 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."}) + 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."}) + raise serializers.ValidationError( + {"url": "Hostname could not be resolved."} + ) if not ip_addresses: - raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) + raise serializers.ValidationError( + {"url": "No IP addresses found for the hostname."} + ) for addr in ip_addresses: ip = ipaddress.ip_address(addr[4][0]) if ip.is_private or ip.is_loopback: - raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) + 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 + 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 + 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."}) + 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) @@ -95,12 +129,7 @@ class WebhookSerializer(DynamicBaseSerializer): class WebhookLogSerializer(DynamicBaseSerializer): - class Meta: model = WebhookLog fields = "__all__" - read_only_fields = [ - "workspace", - "webhook" - ] - + read_only_fields = ["workspace", "webhook"] diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index f0ad4b4ab..69f827c24 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -2,7 +2,7 @@ from rest_framework import serializers # Module imports -from .base import BaseSerializer +from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer, UserAdminLiteSerializer from plane.db.models import ( @@ -13,10 +13,11 @@ from plane.db.models import ( TeamMember, WorkspaceMemberInvite, WorkspaceTheme, + WorkspaceUserProperties, ) -class WorkSpaceSerializer(BaseSerializer): +class WorkSpaceSerializer(DynamicBaseSerializer): owner = UserLiteSerializer(read_only=True) total_members = serializers.IntegerField(read_only=True) total_issues = serializers.IntegerField(read_only=True) @@ -50,6 +51,7 @@ class WorkSpaceSerializer(BaseSerializer): "owner", ] + class WorkspaceLiteSerializer(BaseSerializer): class Meta: model = Workspace @@ -61,8 +63,7 @@ class WorkspaceLiteSerializer(BaseSerializer): read_only_fields = fields - -class WorkSpaceMemberSerializer(BaseSerializer): +class WorkSpaceMemberSerializer(DynamicBaseSerializer): member = UserLiteSerializer(read_only=True) workspace = WorkspaceLiteSerializer(read_only=True) @@ -72,13 +73,12 @@ class WorkSpaceMemberSerializer(BaseSerializer): class WorkspaceMemberMeSerializer(BaseSerializer): - class Meta: model = WorkspaceMember fields = "__all__" -class WorkspaceMemberAdminSerializer(BaseSerializer): +class WorkspaceMemberAdminSerializer(DynamicBaseSerializer): member = UserAdminLiteSerializer(read_only=True) workspace = WorkspaceLiteSerializer(read_only=True) @@ -108,7 +108,9 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer): class TeamSerializer(BaseSerializer): - members_detail = UserLiteSerializer(read_only=True, source="members", many=True) + members_detail = UserLiteSerializer( + read_only=True, source="members", many=True + ) members = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, @@ -145,7 +147,9 @@ class TeamSerializer(BaseSerializer): members = validated_data.pop("members") TeamMember.objects.filter(team=instance).delete() team_members = [ - TeamMember(member=member, team=instance, workspace=instance.workspace) + TeamMember( + member=member, team=instance, workspace=instance.workspace + ) for member in members ] TeamMember.objects.bulk_create(team_members, batch_size=10) @@ -161,3 +165,13 @@ class WorkspaceThemeSerializer(BaseSerializer): "workspace", "actor", ] + + +class WorkspaceUserPropertiesSerializer(BaseSerializer): + class Meta: + model = WorkspaceUserProperties + fields = "__all__" + read_only_fields = [ + "workspace", + "user", + ] diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py index d8334ed57..cb5f0253a 100644 --- a/apiserver/plane/app/urls/__init__.py +++ b/apiserver/plane/app/urls/__init__.py @@ -1,13 +1,11 @@ from .analytic import urlpatterns as analytic_urls +from .api import urlpatterns as api_urls from .asset import urlpatterns as asset_urls -from .authentication import urlpatterns as authentication_urls -from .config import urlpatterns as configuration_urls from .cycle import urlpatterns as cycle_urls +from .dashboard import urlpatterns as dashboard_urls from .estimate import urlpatterns as estimate_urls from .external import urlpatterns as external_urls -from .importer import urlpatterns as importer_urls from .inbox import urlpatterns as inbox_urls -from .integration import urlpatterns as integration_urls from .issue import urlpatterns as issue_urls from .module import urlpatterns as module_urls from .notification import urlpatterns as notification_urls @@ -17,22 +15,17 @@ from .search import urlpatterns as search_urls from .state import urlpatterns as state_urls from .user import urlpatterns as user_urls from .views import urlpatterns as view_urls -from .workspace import urlpatterns as workspace_urls -from .api import urlpatterns as api_urls from .webhook import urlpatterns as webhook_urls - +from .workspace import urlpatterns as workspace_urls urlpatterns = [ *analytic_urls, *asset_urls, - *authentication_urls, - *configuration_urls, *cycle_urls, + *dashboard_urls, *estimate_urls, *external_urls, - *importer_urls, *inbox_urls, - *integration_urls, *issue_urls, *module_urls, *notification_urls, @@ -45,4 +38,4 @@ urlpatterns = [ *workspace_urls, *api_urls, *webhook_urls, -] \ No newline at end of file +] diff --git a/apiserver/plane/app/urls/authentication.py b/apiserver/plane/app/urls/authentication.py deleted file mode 100644 index 39986f791..000000000 --- a/apiserver/plane/app/urls/authentication.py +++ /dev/null @@ -1,57 +0,0 @@ -from django.urls import path - -from rest_framework_simplejwt.views import TokenRefreshView - - -from plane.app.views import ( - # Authentication - SignInEndpoint, - SignOutEndpoint, - MagicGenerateEndpoint, - MagicSignInEndpoint, - OauthEndpoint, - EmailCheckEndpoint, - ## End Authentication - # Auth Extended - ForgotPasswordEndpoint, - ResetPasswordEndpoint, - ChangePasswordEndpoint, - ## End Auth Extender - # API Tokens - ApiTokenEndpoint, - ## End API Tokens -) - - -urlpatterns = [ - # Social Auth - path("email-check/", EmailCheckEndpoint.as_view(), name="email"), - path("social-auth/", OauthEndpoint.as_view(), name="oauth"), - # Auth - path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), - path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), - # magic sign in - path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"), - path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), - path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), - # Password Manipulation - path( - "users/me/change-password/", - ChangePasswordEndpoint.as_view(), - name="change-password", - ), - path( - "reset-password///", - ResetPasswordEndpoint.as_view(), - name="password-reset", - ), - path( - "forgot-password/", - ForgotPasswordEndpoint.as_view(), - name="forgot-password", - ), - # API Tokens - path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"), - path("api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens"), - ## End API Tokens -] diff --git a/apiserver/plane/app/urls/config.py b/apiserver/plane/app/urls/config.py deleted file mode 100644 index 12beb63aa..000000000 --- a/apiserver/plane/app/urls/config.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.urls import path - - -from plane.app.views import ConfigurationEndpoint - -urlpatterns = [ - path( - "configs/", - ConfigurationEndpoint.as_view(), - name="configuration", - ), -] \ No newline at end of file diff --git a/apiserver/plane/app/urls/cycle.py b/apiserver/plane/app/urls/cycle.py index 46e6a5e84..ce2e0f6dc 100644 --- a/apiserver/plane/app/urls/cycle.py +++ b/apiserver/plane/app/urls/cycle.py @@ -7,6 +7,8 @@ from plane.app.views import ( CycleDateCheckEndpoint, CycleFavoriteViewSet, TransferCycleIssueEndpoint, + CycleUserPropertiesEndpoint, + CycleArchiveUnarchiveEndpoint, ) @@ -44,7 +46,7 @@ urlpatterns = [ name="project-issue-cycle", ), path( - "workspaces//projects//cycles//cycle-issues//", + "workspaces//projects//cycles//cycle-issues//", CycleIssueViewSet.as_view( { "get": "retrieve", @@ -84,4 +86,24 @@ urlpatterns = [ 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", + ), ] diff --git a/apiserver/plane/app/urls/dashboard.py b/apiserver/plane/app/urls/dashboard.py new file mode 100644 index 000000000..0dc24a808 --- /dev/null +++ b/apiserver/plane/app/urls/dashboard.py @@ -0,0 +1,23 @@ +from django.urls import path + + +from plane.app.views import DashboardEndpoint, WidgetsEndpoint + + +urlpatterns = [ + path( + "workspaces//dashboard/", + DashboardEndpoint.as_view(), + name="dashboard", + ), + path( + "workspaces//dashboard//", + DashboardEndpoint.as_view(), + name="dashboard", + ), + path( + "dashboard//widgets//", + WidgetsEndpoint.as_view(), + name="widgets", + ), +] diff --git a/apiserver/plane/app/urls/external.py b/apiserver/plane/app/urls/external.py index 774e6fb7c..8db87a249 100644 --- a/apiserver/plane/app/urls/external.py +++ b/apiserver/plane/app/urls/external.py @@ -2,7 +2,6 @@ from django.urls import path from plane.app.views import UnsplashEndpoint -from plane.app.views import ReleaseNotesEndpoint from plane.app.views import GPTIntegrationEndpoint @@ -12,11 +11,6 @@ urlpatterns = [ UnsplashEndpoint.as_view(), name="unsplash", ), - path( - "release-notes/", - ReleaseNotesEndpoint.as_view(), - name="release-notes", - ), path( "workspaces//projects//ai-assistant/", GPTIntegrationEndpoint.as_view(), diff --git a/apiserver/plane/app/urls/importer.py b/apiserver/plane/app/urls/importer.py deleted file mode 100644 index f3a018d78..000000000 --- a/apiserver/plane/app/urls/importer.py +++ /dev/null @@ -1,37 +0,0 @@ -from django.urls import path - - -from plane.app.views import ( - ServiceIssueImportSummaryEndpoint, - ImportServiceEndpoint, - UpdateServiceImportStatusEndpoint, -) - - -urlpatterns = [ - path( - "workspaces//importers//", - ServiceIssueImportSummaryEndpoint.as_view(), - name="importer-summary", - ), - path( - "workspaces//projects/importers//", - ImportServiceEndpoint.as_view(), - name="importer", - ), - path( - "workspaces//importers/", - ImportServiceEndpoint.as_view(), - name="importer", - ), - path( - "workspaces//importers///", - ImportServiceEndpoint.as_view(), - name="importer", - ), - path( - "workspaces//projects//service//importers//", - UpdateServiceImportStatusEndpoint.as_view(), - name="importer-status", - ), -] diff --git a/apiserver/plane/app/urls/inbox.py b/apiserver/plane/app/urls/inbox.py index 16ea40b21..b6848244b 100644 --- a/apiserver/plane/app/urls/inbox.py +++ b/apiserver/plane/app/urls/inbox.py @@ -30,7 +30,7 @@ urlpatterns = [ name="inbox", ), path( - "workspaces//projects//inboxes//inbox-issues/", + "workspaces//projects//inbox-issues/", InboxIssueViewSet.as_view( { "get": "list", @@ -40,7 +40,7 @@ urlpatterns = [ name="inbox-issue", ), path( - "workspaces//projects//inboxes//inbox-issues//", + "workspaces//projects//inbox-issues//", InboxIssueViewSet.as_view( { "get": "retrieve", diff --git a/apiserver/plane/app/urls/integration.py b/apiserver/plane/app/urls/integration.py deleted file mode 100644 index cf3f82d5a..000000000 --- a/apiserver/plane/app/urls/integration.py +++ /dev/null @@ -1,150 +0,0 @@ -from django.urls import path - - -from plane.app.views import ( - IntegrationViewSet, - WorkspaceIntegrationViewSet, - GithubRepositoriesEndpoint, - GithubRepositorySyncViewSet, - GithubIssueSyncViewSet, - GithubCommentSyncViewSet, - BulkCreateGithubIssueSyncEndpoint, - SlackProjectSyncViewSet, -) - - -urlpatterns = [ - path( - "integrations/", - IntegrationViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="integrations", - ), - path( - "integrations//", - IntegrationViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="integrations", - ), - path( - "workspaces//workspace-integrations/", - WorkspaceIntegrationViewSet.as_view( - { - "get": "list", - } - ), - name="workspace-integrations", - ), - path( - "workspaces//workspace-integrations//", - WorkspaceIntegrationViewSet.as_view( - { - "post": "create", - } - ), - name="workspace-integrations", - ), - path( - "workspaces//workspace-integrations//provider/", - WorkspaceIntegrationViewSet.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - name="workspace-integrations", - ), - # Github Integrations - path( - "workspaces//workspace-integrations//github-repositories/", - GithubRepositoriesEndpoint.as_view(), - ), - path( - "workspaces//projects//workspace-integrations//github-repository-sync/", - GithubRepositorySyncViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - ), - path( - "workspaces//projects//workspace-integrations//github-repository-sync//", - GithubRepositorySyncViewSet.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - ), - path( - "workspaces//projects//github-repository-sync//github-issue-sync/", - GithubIssueSyncViewSet.as_view( - { - "post": "create", - "get": "list", - } - ), - ), - path( - "workspaces//projects//github-repository-sync//bulk-create-github-issue-sync/", - BulkCreateGithubIssueSyncEndpoint.as_view(), - ), - path( - "workspaces//projects//github-repository-sync//github-issue-sync//", - GithubIssueSyncViewSet.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - ), - path( - "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync/", - GithubCommentSyncViewSet.as_view( - { - "post": "create", - "get": "list", - } - ), - ), - path( - "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync//", - GithubCommentSyncViewSet.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - ), - ## End Github Integrations - # Slack Integration - path( - "workspaces//projects//workspace-integrations//project-slack-sync/", - SlackProjectSyncViewSet.as_view( - { - "post": "create", - "get": "list", - } - ), - ), - path( - "workspaces//projects//workspace-integrations//project-slack-sync//", - SlackProjectSyncViewSet.as_view( - { - "delete": "destroy", - "get": "retrieve", - } - ), - ), - ## End Slack Integration -] diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 971fbc395..0d3b9e063 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -1,30 +1,32 @@ from django.urls import path - from plane.app.views import ( - IssueViewSet, - LabelViewSet, BulkCreateIssueLabelsEndpoint, BulkDeleteIssuesEndpoint, - BulkImportIssuesEndpoint, - UserWorkSpaceIssues, SubIssuesEndpoint, IssueLinkViewSet, IssueAttachmentEndpoint, + CommentReactionViewSet, ExportIssuesEndpoint, IssueActivityEndpoint, - IssueCommentViewSet, - IssueSubscriberViewSet, - IssueReactionViewSet, - CommentReactionViewSet, - IssueUserDisplayPropertyEndpoint, IssueArchiveViewSet, - IssueRelationViewSet, + IssueCommentViewSet, IssueDraftViewSet, + IssueListEndpoint, + IssueReactionViewSet, + IssueRelationViewSet, + IssueSubscriberViewSet, + IssueUserDisplayPropertyEndpoint, + IssueViewSet, + LabelViewSet, ) - urlpatterns = [ + path( + "workspaces//projects//issues/list/", + IssueListEndpoint.as_view(), + name="project-issue", + ), path( "workspaces//projects//issues/", IssueViewSet.as_view( @@ -79,16 +81,7 @@ urlpatterns = [ BulkDeleteIssuesEndpoint.as_view(), name="project-issues-bulk", ), - path( - "workspaces//projects//bulk-import-issues//", - BulkImportIssuesEndpoint.as_view(), - name="project-issues-bulk", - ), - path( - "workspaces//my-issues/", - UserWorkSpaceIssues.as_view(), - name="workspace-issues", - ), + ## path( "workspaces//projects//issues//sub-issues/", SubIssuesEndpoint.as_view(), @@ -235,7 +228,7 @@ urlpatterns = [ ## End Comment Reactions ## IssueProperty path( - "workspaces//projects//issue-display-properties/", + "workspaces//projects//user-properties/", IssueUserDisplayPropertyEndpoint.as_view(), name="project-issue-display-properties", ), @@ -251,23 +244,15 @@ urlpatterns = [ name="project-issue-archive", ), path( - "workspaces//projects//archived-issues//", + "workspaces//projects//issues//archive/", IssueArchiveViewSet.as_view( { "get": "retrieve", - "delete": "destroy", + "post": "archive", + "delete": "unarchive", } ), - name="project-issue-archive", - ), - path( - "workspaces//projects//unarchive//", - IssueArchiveViewSet.as_view( - { - "post": "unarchive", - } - ), - name="project-issue-archive", + name="project-issue-archive-unarchive", ), ## End Issue Archives ## Issue Relation @@ -275,16 +260,17 @@ urlpatterns = [ "workspaces//projects//issues//issue-relation/", IssueRelationViewSet.as_view( { + "get": "list", "post": "create", } ), name="issue-relation", ), path( - "workspaces//projects//issues//issue-relation//", + "workspaces//projects//issues//remove-relation/", IssueRelationViewSet.as_view( { - "delete": "destroy", + "post": "remove_relation", } ), name="issue-relation", diff --git a/apiserver/plane/app/urls/module.py b/apiserver/plane/app/urls/module.py index 5507b3a37..bf6c84b2f 100644 --- a/apiserver/plane/app/urls/module.py +++ b/apiserver/plane/app/urls/module.py @@ -6,7 +6,8 @@ from plane.app.views import ( ModuleIssueViewSet, ModuleLinkViewSet, ModuleFavoriteViewSet, - BulkImportModulesEndpoint, + ModuleUserPropertiesEndpoint, + ModuleArchiveUnarchiveEndpoint, ) @@ -34,17 +35,26 @@ urlpatterns = [ name="project-modules", ), path( - "workspaces//projects//modules//module-issues/", + "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", - "post": "create", } ), name="project-module-issues", ), path( - "workspaces//projects//modules//module-issues//", + "workspaces//projects//modules//issues//", ModuleIssueViewSet.as_view( { "get": "retrieve", @@ -97,8 +107,23 @@ urlpatterns = [ name="user-favorite-module", ), path( - "workspaces//projects//bulk-import-modules//", - BulkImportModulesEndpoint.as_view(), - name="bulk-modules-create", + "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/apiserver/plane/app/urls/notification.py b/apiserver/plane/app/urls/notification.py index 0c96e5f15..0bbf4f3c7 100644 --- a/apiserver/plane/app/urls/notification.py +++ b/apiserver/plane/app/urls/notification.py @@ -5,6 +5,7 @@ from plane.app.views import ( NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet, + UserNotificationPreferenceEndpoint, ) @@ -63,4 +64,9 @@ urlpatterns = [ ), name="mark-all-read-notifications", ), + path( + "users/me/notification-preferences/", + UserNotificationPreferenceEndpoint.as_view(), + name="user-notification-preferences", + ), ] diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index 58cec2cd4..a6d43600f 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -6,6 +6,7 @@ from plane.app.views import ( PageFavoriteViewSet, PageLogEndpoint, SubPagesEndpoint, + PagesDescriptionViewSet, ) @@ -31,103 +32,62 @@ urlpatterns = [ ), name="project-pages", ), + # favorite pages path( - "workspaces//projects//user-favorite-pages/", + "workspaces//projects//favorite-pages//", PageFavoriteViewSet.as_view( { - "get": "list", "post": "create", - } - ), - name="user-favorite-pages", - ), - path( - "workspaces//projects//user-favorite-pages//", - PageFavoriteViewSet.as_view( - { "delete": "destroy", } ), name="user-favorite-pages", ), + # archived pages path( - "workspaces//projects//pages/", - PageViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//", - PageViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//archive/", + "workspaces//projects//pages//archive/", PageViewSet.as_view( { "post": "archive", + "delete": "unarchive", } ), - name="project-page-archive", + name="project-page-archive-unarchive", ), + # lock and unlock path( - "workspaces//projects//pages//unarchive/", - PageViewSet.as_view( - { - "post": "unarchive", - } - ), - name="project-page-unarchive", - ), - path( - "workspaces//projects//archived-pages/", - PageViewSet.as_view( - { - "get": "archive_list", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//lock/", + "workspaces//projects//pages//lock/", PageViewSet.as_view( { "post": "lock", + "delete": "unlock", } ), - name="project-pages", + name="project-pages-lock-unlock", ), path( - "workspaces//projects//pages//unlock/", - PageViewSet.as_view( - { - "post": "unlock", - } - ), - ), - path( - "workspaces//projects//pages//transactions/", + "workspaces//projects//pages//transactions/", PageLogEndpoint.as_view(), name="page-transactions", ), path( - "workspaces//projects//pages//transactions//", + "workspaces//projects//pages//transactions//", PageLogEndpoint.as_view(), name="page-transactions", ), path( - "workspaces//projects//pages//sub-pages/", + "workspaces//projects//pages//sub-pages/", SubPagesEndpoint.as_view(), name="sub-page", ), + path( + "workspaces//projects//pages//description/", + PagesDescriptionViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + } + ), + name="page-description", + ), ] diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py index 39456a830..7ea636df8 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -14,6 +14,7 @@ from plane.app.views import ( ProjectPublicCoverImagesEndpoint, ProjectDeployBoardViewSet, UserProjectRolesEndpoint, + ProjectArchiveUnarchiveEndpoint, ) @@ -175,4 +176,9 @@ urlpatterns = [ ), name="project-deploy-board", ), -] \ No newline at end of file + path( + "workspaces//projects//archive/", + ProjectArchiveUnarchiveEndpoint.as_view(), + name="project-archive-unarchive", + ), +] diff --git a/apiserver/plane/app/urls/user.py b/apiserver/plane/app/urls/user.py index 9dae7b5da..fd18ea87b 100644 --- a/apiserver/plane/app/urls/user.py +++ b/apiserver/plane/app/urls/user.py @@ -1,20 +1,20 @@ from django.urls import path from plane.app.views import ( - ## User - UserEndpoint, + AccountEndpoint, + ProfileEndpoint, UpdateUserOnBoardedEndpoint, UpdateUserTourCompletedEndpoint, UserActivityEndpoint, - ChangePasswordEndpoint, - SetUserPasswordEndpoint, + UserActivityGraphEndpoint, + ## User + UserEndpoint, + UserIssueCompletedGraphEndpoint, + UserWorkspaceDashboardEndpoint, + UserSessionEndpoint, ## End User ## Workspaces UserWorkSpacesEndpoint, - UserActivityGraphEndpoint, - UserIssueCompletedGraphEndpoint, - UserWorkspaceDashboardEndpoint, - ## End Workspaces ) urlpatterns = [ @@ -30,6 +30,11 @@ urlpatterns = [ ), name="users", ), + path( + "users/session/", + UserSessionEndpoint.as_view(), + name="user-session", + ), path( "users/me/settings/", UserEndpoint.as_view( @@ -39,6 +44,25 @@ urlpatterns = [ ), 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( @@ -48,11 +72,6 @@ urlpatterns = [ ), name="users", ), - path( - "users/me/change-password/", - ChangePasswordEndpoint.as_view(), - name="change-password", - ), path( "users/me/onboard/", UpdateUserOnBoardedEndpoint.as_view(), @@ -90,10 +109,5 @@ urlpatterns = [ UserWorkspaceDashboardEndpoint.as_view(), name="user-workspace-dashboard", ), - path( - "users/me/set-password/", - SetUserPasswordEndpoint.as_view(), - name="set-password", - ), ## End User Graph ] diff --git a/apiserver/plane/app/urls/views.py b/apiserver/plane/app/urls/views.py index 3d45b627a..36372c03a 100644 --- a/apiserver/plane/app/urls/views.py +++ b/apiserver/plane/app/urls/views.py @@ -5,7 +5,7 @@ from plane.app.views import ( IssueViewViewSet, GlobalViewViewSet, GlobalViewIssuesViewSet, - IssueViewFavoriteViewSet, + IssueViewFavoriteViewSet, ) diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 2c3638842..8b21bb9e1 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -18,6 +18,13 @@ from plane.app.views import ( WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceLabelsEndpoint, + WorkspaceProjectMemberEndpoint, + WorkspaceUserPropertiesEndpoint, + WorkspaceStatesEndpoint, + WorkspaceEstimatesEndpoint, + ExportWorkspaceUserActivityEndpoint, + WorkspaceModulesEndpoint, + WorkspaceCyclesEndpoint, ) @@ -92,6 +99,11 @@ urlpatterns = [ 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( @@ -180,6 +192,11 @@ urlpatterns = [ 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(), @@ -195,4 +212,29 @@ urlpatterns = [ 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", + ), ] diff --git a/apiserver/plane/app/urls_deprecated.py b/apiserver/plane/app/urls_deprecated.py deleted file mode 100644 index c6e6183fa..000000000 --- a/apiserver/plane/app/urls_deprecated.py +++ /dev/null @@ -1,1797 +0,0 @@ -from django.urls import path - -from rest_framework_simplejwt.views import TokenRefreshView - -# Create your urls here. - -from plane.app.views import ( - # Authentication - SignUpEndpoint, - SignInEndpoint, - SignOutEndpoint, - MagicSignInEndpoint, - MagicSignInGenerateEndpoint, - OauthEndpoint, - ## End Authentication - # Auth Extended - ForgotPasswordEndpoint, - VerifyEmailEndpoint, - ResetPasswordEndpoint, - RequestEmailVerificationEndpoint, - ChangePasswordEndpoint, - ## End Auth Extender - # User - UserEndpoint, - UpdateUserOnBoardedEndpoint, - UpdateUserTourCompletedEndpoint, - UserActivityEndpoint, - ## End User - # Workspaces - WorkSpaceViewSet, - UserWorkSpacesEndpoint, - InviteWorkspaceEndpoint, - JoinWorkspaceEndpoint, - WorkSpaceMemberViewSet, - WorkspaceMembersEndpoint, - WorkspaceInvitationsViewset, - UserWorkspaceInvitationsEndpoint, - WorkspaceMemberUserEndpoint, - WorkspaceMemberUserViewsEndpoint, - WorkSpaceAvailabilityCheckEndpoint, - TeamMemberViewSet, - AddTeamToProjectEndpoint, - UserLastProjectWithWorkspaceEndpoint, - UserWorkspaceInvitationEndpoint, - UserActivityGraphEndpoint, - UserIssueCompletedGraphEndpoint, - UserWorkspaceDashboardEndpoint, - WorkspaceThemeViewSet, - WorkspaceUserProfileStatsEndpoint, - WorkspaceUserActivityEndpoint, - WorkspaceUserProfileEndpoint, - WorkspaceUserProfileIssuesEndpoint, - WorkspaceLabelsEndpoint, - LeaveWorkspaceEndpoint, - ## End Workspaces - # File Assets - FileAssetEndpoint, - UserAssetsEndpoint, - ## End File Assets - # Projects - ProjectViewSet, - InviteProjectEndpoint, - ProjectMemberViewSet, - ProjectMemberEndpoint, - ProjectMemberInvitationsViewset, - ProjectMemberUserEndpoint, - AddMemberToProjectEndpoint, - ProjectJoinEndpoint, - UserProjectInvitationsViewset, - ProjectIdentifierEndpoint, - ProjectFavoritesViewSet, - LeaveProjectEndpoint, - ProjectPublicCoverImagesEndpoint, - ## End Projects - # Issues - IssueViewSet, - WorkSpaceIssuesEndpoint, - IssueActivityEndpoint, - IssueCommentViewSet, - UserWorkSpaceIssues, - BulkDeleteIssuesEndpoint, - BulkImportIssuesEndpoint, - ProjectUserViewsEndpoint, - IssueUserDisplayPropertyEndpoint, - LabelViewSet, - SubIssuesEndpoint, - IssueLinkViewSet, - BulkCreateIssueLabelsEndpoint, - IssueAttachmentEndpoint, - IssueArchiveViewSet, - IssueSubscriberViewSet, - IssueCommentPublicViewSet, - IssueReactionViewSet, - IssueRelationViewSet, - CommentReactionViewSet, - IssueDraftViewSet, - ## End Issues - # States - StateViewSet, - ## End States - # Estimates - ProjectEstimatePointEndpoint, - BulkEstimatePointEndpoint, - ## End Estimates - # Views - GlobalViewViewSet, - GlobalViewIssuesViewSet, - IssueViewViewSet, - IssueViewFavoriteViewSet, - ## End Views - # Cycles - CycleViewSet, - CycleIssueViewSet, - CycleDateCheckEndpoint, - CycleFavoriteViewSet, - TransferCycleIssueEndpoint, - ## End Cycles - # Modules - ModuleViewSet, - ModuleIssueViewSet, - ModuleFavoriteViewSet, - ModuleLinkViewSet, - BulkImportModulesEndpoint, - ## End Modules - # Pages - PageViewSet, - PageLogEndpoint, - SubPagesEndpoint, - PageFavoriteViewSet, - CreateIssueFromBlockEndpoint, - ## End Pages - # Api Tokens - ApiTokenEndpoint, - ## End Api Tokens - # Integrations - IntegrationViewSet, - WorkspaceIntegrationViewSet, - GithubRepositoriesEndpoint, - GithubRepositorySyncViewSet, - GithubIssueSyncViewSet, - GithubCommentSyncViewSet, - BulkCreateGithubIssueSyncEndpoint, - SlackProjectSyncViewSet, - ## End Integrations - # Importer - ServiceIssueImportSummaryEndpoint, - ImportServiceEndpoint, - UpdateServiceImportStatusEndpoint, - ## End importer - # Search - GlobalSearchEndpoint, - IssueSearchEndpoint, - ## End Search - # External - GPTIntegrationEndpoint, - ReleaseNotesEndpoint, - UnsplashEndpoint, - ## End External - # Inbox - InboxViewSet, - InboxIssueViewSet, - ## End Inbox - # Analytics - AnalyticsEndpoint, - AnalyticViewViewset, - SavedAnalyticEndpoint, - ExportAnalyticsEndpoint, - DefaultAnalyticsEndpoint, - ## End Analytics - # Notification - NotificationViewSet, - UnreadNotificationEndpoint, - MarkAllReadNotificationViewSet, - ## End Notification - # Public Boards - ProjectDeployBoardViewSet, - ProjectIssuesPublicEndpoint, - ProjectDeployBoardPublicSettingsEndpoint, - IssueReactionPublicViewSet, - CommentReactionPublicViewSet, - InboxIssuePublicViewSet, - IssueVotePublicViewSet, - WorkspaceProjectDeployBoardEndpoint, - IssueRetrievePublicEndpoint, - ## End Public Boards - ## Exporter - ExportIssuesEndpoint, - ## End Exporter - # Configuration - ConfigurationEndpoint, - ## End Configuration -) - - -#TODO: Delete this file -# This url file has been deprecated use apiserver/plane/urls folder to create new urls - -urlpatterns = [ - # Social Auth - path("social-auth/", OauthEndpoint.as_view(), name="oauth"), - # Auth - path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"), - path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), - path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), - # Magic Sign In/Up - path( - "magic-generate/", MagicSignInGenerateEndpoint.as_view(), name="magic-generate" - ), - path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), - path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), - # Email verification - path("email-verify/", VerifyEmailEndpoint.as_view(), name="email-verify"), - path( - "request-email-verify/", - RequestEmailVerificationEndpoint.as_view(), - name="request-reset-email", - ), - # Password Manipulation - path( - "reset-password///", - ResetPasswordEndpoint.as_view(), - name="password-reset", - ), - path( - "forgot-password/", - ForgotPasswordEndpoint.as_view(), - name="forgot-password", - ), - # User Profile - path( - "users/me/", - UserEndpoint.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), - name="users", - ), - path( - "users/me/settings/", - UserEndpoint.as_view( - { - "get": "retrieve_user_settings", - } - ), - name="users", - ), - path( - "users/me/change-password/", - ChangePasswordEndpoint.as_view(), - name="change-password", - ), - path( - "users/me/onboard/", - UpdateUserOnBoardedEndpoint.as_view(), - name="user-onboard", - ), - path( - "users/me/tour-completed/", - UpdateUserTourCompletedEndpoint.as_view(), - name="user-tour", - ), - path( - "users/workspaces//activities/", - UserActivityEndpoint.as_view(), - name="user-activities", - ), - # user workspaces - path( - "users/me/workspaces/", - UserWorkSpacesEndpoint.as_view(), - name="user-workspace", - ), - # user workspace invitations - path( - "users/me/invitations/workspaces/", - UserWorkspaceInvitationsEndpoint.as_view({"get": "list", "post": "create"}), - name="user-workspace-invitations", - ), - # user workspace invitation - path( - "users/me/invitations//", - UserWorkspaceInvitationEndpoint.as_view( - { - "get": "retrieve", - } - ), - name="workspace", - ), - # user join workspace - # 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", - ), - ## User Graph - path( - "users/me/invitations/workspaces///join/", - JoinWorkspaceEndpoint.as_view(), - name="user-join-workspace", - ), - # user project invitations - path( - "users/me/invitations/projects/", - UserProjectInvitationsViewset.as_view({"get": "list", "post": "create"}), - name="user-project-invitaions", - ), - ## Workspaces ## - 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//invite/", - InviteWorkspaceEndpoint.as_view(), - name="workspace", - ), - path( - "workspaces//invitations/", - WorkspaceInvitationsViewset.as_view({"get": "list"}), - name="workspace", - ), - path( - "workspaces//invitations//", - WorkspaceInvitationsViewset.as_view( - { - "delete": "destroy", - "get": "retrieve", - } - ), - name="workspace", - ), - path( - "workspaces//members/", - WorkSpaceMemberViewSet.as_view({"get": "list"}), - name="workspace", - ), - path( - "workspaces//members//", - WorkSpaceMemberViewSet.as_view( - { - "patch": "partial_update", - "delete": "destroy", - "get": "retrieve", - } - ), - name="workspace", - ), - path( - "workspaces//workspace-members/", - WorkspaceMembersEndpoint.as_view(), - name="workspace-members", - ), - path( - "workspaces//teams/", - TeamMemberViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="workspace", - ), - path( - "workspaces//teams//", - TeamMemberViewSet.as_view( - { - "put": "update", - "patch": "partial_update", - "delete": "destroy", - "get": "retrieve", - } - ), - name="workspace", - ), - path( - "users/last-visited-workspace/", - UserLastProjectWithWorkspaceEndpoint.as_view(), - name="workspace-project-details", - ), - path( - "workspaces//workspace-members/me/", - WorkspaceMemberUserEndpoint.as_view(), - name="workspace-member-details", - ), - path( - "workspaces//workspace-views/", - WorkspaceMemberUserViewsEndpoint.as_view(), - name="workspace-member-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-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//members/leave/", - LeaveWorkspaceEndpoint.as_view(), - name="workspace-labels", - ), - ## End Workspaces ## - # Projects - path( - "workspaces//projects/", - ProjectViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project", - ), - path( - "workspaces//projects//", - ProjectViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project", - ), - path( - "workspaces//project-identifiers/", - ProjectIdentifierEndpoint.as_view(), - name="project-identifiers", - ), - path( - "workspaces//projects//invite/", - InviteProjectEndpoint.as_view(), - name="project", - ), - path( - "workspaces//projects//members/", - ProjectMemberViewSet.as_view({"get": "list"}), - name="project", - ), - path( - "workspaces//projects//members//", - ProjectMemberViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project", - ), - path( - "workspaces//projects//project-members/", - ProjectMemberEndpoint.as_view(), - name="project", - ), - path( - "workspaces//projects//members/add/", - AddMemberToProjectEndpoint.as_view(), - name="project", - ), - path( - "workspaces//projects/join/", - ProjectJoinEndpoint.as_view(), - name="project", - ), - path( - "workspaces//projects//team-invite/", - AddTeamToProjectEndpoint.as_view(), - name="projects", - ), - path( - "workspaces//projects//invitations/", - ProjectMemberInvitationsViewset.as_view({"get": "list"}), - name="workspace", - ), - path( - "workspaces//projects//invitations//", - ProjectMemberInvitationsViewset.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - name="project", - ), - path( - "workspaces//projects//project-views/", - ProjectUserViewsEndpoint.as_view(), - name="project-view", - ), - path( - "workspaces//projects//project-members/me/", - ProjectMemberUserEndpoint.as_view(), - name="project-view", - ), - path( - "workspaces//user-favorite-projects/", - ProjectFavoritesViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project", - ), - path( - "workspaces//user-favorite-projects//", - ProjectFavoritesViewSet.as_view( - { - "delete": "destroy", - } - ), - name="project", - ), - path( - "workspaces//projects//members/leave/", - LeaveProjectEndpoint.as_view(), - name="project", - ), - path( - "project-covers/", - ProjectPublicCoverImagesEndpoint.as_view(), - name="project-covers", - ), - # End Projects - # States - path( - "workspaces//projects//states/", - StateViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-states", - ), - path( - "workspaces//projects//states//", - StateViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-state", - ), - # End States ## - # Estimates - 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", - ), - # End Estimates ## - # Views - 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/", - GlobalViewViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="global-view", - ), - path( - "workspaces//views//", - GlobalViewViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="global-view", - ), - path( - "workspaces//issues/", - GlobalViewIssuesViewSet.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", - ), - ## End Views - ## Cycles - path( - "workspaces//projects//cycles/", - CycleViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-cycle", - ), - path( - "workspaces//projects//cycles//", - CycleViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-cycle", - ), - path( - "workspaces//projects//cycles//cycle-issues/", - CycleIssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-cycle", - ), - path( - "workspaces//projects//cycles//cycle-issues//", - CycleIssueViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-cycle", - ), - path( - "workspaces//projects//cycles/date-check/", - CycleDateCheckEndpoint.as_view(), - name="project-cycle", - ), - 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", - ), - ## End Cycles - # Issue - path( - "workspaces//projects//issues/", - IssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue", - ), - path( - "workspaces//projects//issues//", - IssueViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue", - ), - path( - "workspaces//projects//issue-labels/", - LabelViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-labels", - ), - path( - "workspaces//projects//issue-labels//", - LabelViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-labels", - ), - path( - "workspaces//projects//bulk-create-labels/", - BulkCreateIssueLabelsEndpoint.as_view(), - name="project-bulk-labels", - ), - path( - "workspaces//projects//bulk-delete-issues/", - BulkDeleteIssuesEndpoint.as_view(), - name="project-issues-bulk", - ), - path( - "workspaces//projects//bulk-import-issues//", - BulkImportIssuesEndpoint.as_view(), - name="project-issues-bulk", - ), - path( - "workspaces//my-issues/", - UserWorkSpaceIssues.as_view(), - name="workspace-issues", - ), - path( - "workspaces//projects//issues//sub-issues/", - SubIssuesEndpoint.as_view(), - name="sub-issues", - ), - path( - "workspaces//projects//issues//issue-links/", - IssueLinkViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-links", - ), - path( - "workspaces//projects//issues//issue-links//", - IssueLinkViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-links", - ), - path( - "workspaces//projects//issues//issue-attachments/", - IssueAttachmentEndpoint.as_view(), - name="project-issue-attachments", - ), - path( - "workspaces//projects//issues//issue-attachments//", - IssueAttachmentEndpoint.as_view(), - name="project-issue-attachments", - ), - path( - "workspaces//export-issues/", - ExportIssuesEndpoint.as_view(), - name="export-issues", - ), - ## End Issues - ## Issue Activity - path( - "workspaces//projects//issues//history/", - IssueActivityEndpoint.as_view(), - name="project-issue-history", - ), - ## Issue Activity - ## IssueComments - path( - "workspaces//projects//issues//comments/", - IssueCommentViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-comment", - ), - path( - "workspaces//projects//issues//comments//", - IssueCommentViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-comment", - ), - ## End IssueComments - # Issue Subscribers - path( - "workspaces//projects//issues//issue-subscribers/", - IssueSubscriberViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-subscribers", - ), - path( - "workspaces//projects//issues//issue-subscribers//", - IssueSubscriberViewSet.as_view({"delete": "destroy"}), - name="project-issue-subscribers", - ), - path( - "workspaces//projects//issues//subscribe/", - IssueSubscriberViewSet.as_view( - { - "get": "subscription_status", - "post": "subscribe", - "delete": "unsubscribe", - } - ), - name="project-issue-subscribers", - ), - ## End Issue Subscribers - # Issue Reactions - path( - "workspaces//projects//issues//reactions/", - IssueReactionViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-reactions", - ), - path( - "workspaces//projects//issues//reactions//", - IssueReactionViewSet.as_view( - { - "delete": "destroy", - } - ), - name="project-issue-reactions", - ), - ## End Issue Reactions - # Comment Reactions - path( - "workspaces//projects//comments//reactions/", - CommentReactionViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-comment-reactions", - ), - path( - "workspaces//projects//comments//reactions//", - CommentReactionViewSet.as_view( - { - "delete": "destroy", - } - ), - name="project-issue-comment-reactions", - ), - ## End Comment Reactions - ## IssueProperty - path( - "workspaces//projects//issue-display-properties/", - IssueUserDisplayPropertyEndpoint.as_view(), - name="project-issue-display-properties", - ), - ## IssueProperty Ebd - ## Issue Archives - path( - "workspaces//projects//archived-issues/", - IssueArchiveViewSet.as_view( - { - "get": "list", - } - ), - name="project-issue-archive", - ), - path( - "workspaces//projects//archived-issues//", - IssueArchiveViewSet.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - name="project-issue-archive", - ), - path( - "workspaces//projects//unarchive//", - IssueArchiveViewSet.as_view( - { - "post": "unarchive", - } - ), - name="project-issue-archive", - ), - ## End Issue Archives - ## Issue Relation - path( - "workspaces//projects//issues//issue-relation/", - IssueRelationViewSet.as_view( - { - "post": "create", - } - ), - name="issue-relation", - ), - path( - "workspaces//projects//issues//issue-relation//", - IssueRelationViewSet.as_view( - { - "delete": "destroy", - } - ), - name="issue-relation", - ), - ## End Issue Relation - ## Issue Drafts - path( - "workspaces//projects//issue-drafts/", - IssueDraftViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-draft", - ), - path( - "workspaces//projects//issue-drafts//", - IssueDraftViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-draft", - ), - ## End Issue Drafts - ## File Assets - 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", - ), - ## End File Assets - ## Modules - path( - "workspaces//projects//modules/", - ModuleViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-modules", - ), - path( - "workspaces//projects//modules//", - ModuleViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-modules", - ), - path( - "workspaces//projects//modules//module-issues/", - ModuleIssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-module-issues", - ), - path( - "workspaces//projects//modules//module-issues//", - ModuleIssueViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-module-issues", - ), - path( - "workspaces//projects//modules//module-links/", - ModuleLinkViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-module-links", - ), - path( - "workspaces//projects//modules//module-links//", - ModuleLinkViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-module-links", - ), - path( - "workspaces//projects//user-favorite-modules/", - ModuleFavoriteViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="user-favorite-module", - ), - path( - "workspaces//projects//user-favorite-modules//", - ModuleFavoriteViewSet.as_view( - { - "delete": "destroy", - } - ), - name="user-favorite-module", - ), - path( - "workspaces//projects//bulk-import-modules//", - BulkImportModulesEndpoint.as_view(), - name="bulk-modules-create", - ), - ## End Modules - # Pages - path( - "workspaces//projects//pages/", - PageViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//", - PageViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//archive/", - PageViewSet.as_view( - { - "post": "archive", - } - ), - name="project-page-archive", - ), - path( - "workspaces//projects//pages//unarchive/", - PageViewSet.as_view( - { - "post": "unarchive", - } - ), - name="project-page-unarchive" - ), - path( - "workspaces//projects//archived-pages/", - PageViewSet.as_view( - { - "get": "archive_list", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//lock/", - PageViewSet.as_view( - { - "post": "lock", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//unlock/", - PageViewSet.as_view( - { - "post": "unlock", - } - ) - ), - path( - "workspaces//projects//pages//transactions/", - PageLogEndpoint.as_view(), name="page-transactions" - ), - path( - "workspaces//projects//pages//transactions//", - PageLogEndpoint.as_view(), name="page-transactions" - ), - path( - "workspaces//projects//pages//sub-pages/", - SubPagesEndpoint.as_view(), name="sub-page" - ), - 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//user-favorite-pages/", - PageFavoriteViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="user-favorite-pages", - ), - path( - "workspaces//projects//user-favorite-pages//", - PageFavoriteViewSet.as_view( - { - "delete": "destroy", - } - ), - name="user-favorite-pages", - ), - path( - "workspaces//projects//pages//page-blocks//issues/", - CreateIssueFromBlockEndpoint.as_view(), - name="page-block-issues", - ), - ## End Pages - # API Tokens - path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"), - path("api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens"), - ## End API Tokens - # Integrations - path( - "integrations/", - IntegrationViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="integrations", - ), - path( - "integrations//", - IntegrationViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="integrations", - ), - path( - "workspaces//workspace-integrations/", - WorkspaceIntegrationViewSet.as_view( - { - "get": "list", - } - ), - name="workspace-integrations", - ), - path( - "workspaces//workspace-integrations//", - WorkspaceIntegrationViewSet.as_view( - { - "post": "create", - } - ), - name="workspace-integrations", - ), - path( - "workspaces//workspace-integrations//provider/", - WorkspaceIntegrationViewSet.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - name="workspace-integrations", - ), - # Github Integrations - path( - "workspaces//workspace-integrations//github-repositories/", - GithubRepositoriesEndpoint.as_view(), - ), - path( - "workspaces//projects//workspace-integrations//github-repository-sync/", - GithubRepositorySyncViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - ), - path( - "workspaces//projects//workspace-integrations//github-repository-sync//", - GithubRepositorySyncViewSet.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - ), - path( - "workspaces//projects//github-repository-sync//github-issue-sync/", - GithubIssueSyncViewSet.as_view( - { - "post": "create", - "get": "list", - } - ), - ), - path( - "workspaces//projects//github-repository-sync//bulk-create-github-issue-sync/", - BulkCreateGithubIssueSyncEndpoint.as_view(), - ), - path( - "workspaces//projects//github-repository-sync//github-issue-sync//", - GithubIssueSyncViewSet.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - ), - path( - "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync/", - GithubCommentSyncViewSet.as_view( - { - "post": "create", - "get": "list", - } - ), - ), - path( - "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync//", - GithubCommentSyncViewSet.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - ), - ## End Github Integrations - # Slack Integration - path( - "workspaces//projects//workspace-integrations//project-slack-sync/", - SlackProjectSyncViewSet.as_view( - { - "post": "create", - "get": "list", - } - ), - ), - path( - "workspaces//projects//workspace-integrations//project-slack-sync//", - SlackProjectSyncViewSet.as_view( - { - "delete": "destroy", - "get": "retrieve", - } - ), - ), - ## End Slack Integration - ## End Integrations - # Importer - path( - "workspaces//importers//", - ServiceIssueImportSummaryEndpoint.as_view(), - name="importer", - ), - path( - "workspaces//projects/importers//", - ImportServiceEndpoint.as_view(), - name="importer", - ), - path( - "workspaces//importers/", - ImportServiceEndpoint.as_view(), - name="importer", - ), - path( - "workspaces//importers///", - ImportServiceEndpoint.as_view(), - name="importer", - ), - path( - "workspaces//projects//service//importers//", - UpdateServiceImportStatusEndpoint.as_view(), - name="importer", - ), - ## End Importer - # Search - path( - "workspaces//search/", - GlobalSearchEndpoint.as_view(), - name="global-search", - ), - path( - "workspaces//projects//search-issues/", - IssueSearchEndpoint.as_view(), - name="project-issue-search", - ), - ## End Search - # External - path( - "workspaces//projects//ai-assistant/", - GPTIntegrationEndpoint.as_view(), - name="importer", - ), - path( - "release-notes/", - ReleaseNotesEndpoint.as_view(), - name="release-notes", - ), - path( - "unsplash/", - UnsplashEndpoint.as_view(), - name="release-notes", - ), - ## End External - # Inbox - path( - "workspaces//projects//inboxes/", - InboxViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="inbox", - ), - path( - "workspaces//projects//inboxes//", - InboxViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="inbox", - ), - path( - "workspaces//projects//inboxes//inbox-issues/", - InboxIssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="inbox-issue", - ), - path( - "workspaces//projects//inboxes//inbox-issues//", - InboxIssueViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="inbox-issue", - ), - ## End Inbox - # Analytics - 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", - ), - ## End Analytics - # Notification - 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", - ), - ## End Notification - # Public Boards - path( - "workspaces//projects//project-deploy-boards/", - ProjectDeployBoardViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-deploy-board", - ), - path( - "workspaces//projects//project-deploy-boards//", - ProjectDeployBoardViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-deploy-board", - ), - path( - "public/workspaces//project-boards//settings/", - ProjectDeployBoardPublicSettingsEndpoint.as_view(), - name="project-deploy-board-settings", - ), - path( - "public/workspaces//project-boards//issues/", - ProjectIssuesPublicEndpoint.as_view(), - name="project-deploy-board", - ), - path( - "public/workspaces//project-boards//issues//", - IssueRetrievePublicEndpoint.as_view(), - name="workspace-project-boards", - ), - path( - "public/workspaces//project-boards//issues//comments/", - IssueCommentPublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="issue-comments-project-board", - ), - path( - "public/workspaces//project-boards//issues//comments//", - IssueCommentPublicViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="issue-comments-project-board", - ), - path( - "public/workspaces//project-boards//issues//reactions/", - IssueReactionPublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="issue-reactions-project-board", - ), - path( - "public/workspaces//project-boards//issues//reactions//", - IssueReactionPublicViewSet.as_view( - { - "delete": "destroy", - } - ), - name="issue-reactions-project-board", - ), - path( - "public/workspaces//project-boards//comments//reactions/", - CommentReactionPublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="comment-reactions-project-board", - ), - path( - "public/workspaces//project-boards//comments//reactions//", - CommentReactionPublicViewSet.as_view( - { - "delete": "destroy", - } - ), - name="comment-reactions-project-board", - ), - path( - "public/workspaces//project-boards//inboxes//inbox-issues/", - InboxIssuePublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="inbox-issue", - ), - path( - "public/workspaces//project-boards//inboxes//inbox-issues//", - InboxIssuePublicViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="inbox-issue", - ), - path( - "public/workspaces//project-boards//issues//votes/", - IssueVotePublicViewSet.as_view( - { - "get": "list", - "post": "create", - "delete": "destroy", - } - ), - name="issue-vote-project-board", - ), - path( - "public/workspaces//project-boards/", - WorkspaceProjectDeployBoardEndpoint.as_view(), - name="workspace-project-boards", - ), - ## End Public Boards - # Configuration - path( - "configs/", - ConfigurationEndpoint.as_view(), - name="configuration", - ), - ## End Configuration -] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index c122dce9f..0c489593d 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -1,151 +1,200 @@ -from .project import ( +from .project.base import ( ProjectViewSet, - ProjectMemberViewSet, - UserProjectInvitationsViewset, - ProjectInvitationsViewset, - AddTeamToProjectEndpoint, ProjectIdentifierEndpoint, - ProjectJoinEndpoint, ProjectUserViewsEndpoint, - ProjectMemberUserEndpoint, ProjectFavoritesViewSet, ProjectPublicCoverImagesEndpoint, ProjectDeployBoardViewSet, + ProjectArchiveUnarchiveEndpoint, +) + +from .project.invite import ( + UserProjectInvitationsViewset, + ProjectInvitationsViewset, + ProjectJoinEndpoint, +) + +from .project.member import ( + ProjectMemberViewSet, + AddTeamToProjectEndpoint, + ProjectMemberUserEndpoint, UserProjectRolesEndpoint, ) -from .user import ( + +from .user.base import ( UserEndpoint, UpdateUserOnBoardedEndpoint, UpdateUserTourCompletedEndpoint, UserActivityEndpoint, ) -from .oauth import OauthEndpoint -from .base import BaseAPIView, BaseViewSet, WebhookMixin +from .base import BaseAPIView, BaseViewSet -from .workspace import ( +from .workspace.base import ( WorkSpaceViewSet, UserWorkSpacesEndpoint, WorkSpaceAvailabilityCheckEndpoint, - WorkspaceJoinEndpoint, - WorkSpaceMemberViewSet, - TeamMemberViewSet, - WorkspaceInvitationsViewset, - UserWorkspaceInvitationsViewSet, - UserLastProjectWithWorkspaceEndpoint, - WorkspaceMemberUserEndpoint, - WorkspaceMemberUserViewsEndpoint, - UserActivityGraphEndpoint, - UserIssueCompletedGraphEndpoint, UserWorkspaceDashboardEndpoint, WorkspaceThemeViewSet, - WorkspaceUserProfileStatsEndpoint, - WorkspaceUserActivityEndpoint, - WorkspaceUserProfileEndpoint, - WorkspaceUserProfileIssuesEndpoint, + ExportWorkspaceUserActivityEndpoint, +) + +from .workspace.member import ( + WorkSpaceMemberViewSet, + TeamMemberViewSet, + WorkspaceMemberUserEndpoint, + WorkspaceProjectMemberEndpoint, + WorkspaceMemberUserViewsEndpoint, +) +from .workspace.invite import ( + WorkspaceInvitationsViewset, + WorkspaceJoinEndpoint, + UserWorkspaceInvitationsViewSet, +) +from .workspace.label import ( WorkspaceLabelsEndpoint, ) -from .state import StateViewSet -from .view import ( +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 .state.base import StateViewSet +from .view.base import ( GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, IssueViewFavoriteViewSet, ) -from .cycle import ( +from .cycle.base import ( CycleViewSet, - CycleIssueViewSet, CycleDateCheckEndpoint, CycleFavoriteViewSet, TransferCycleIssueEndpoint, + CycleUserPropertiesEndpoint, + CycleViewSet, + TransferCycleIssueEndpoint, ) -from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet -from .issue import ( +from .cycle.issue import ( + CycleIssueViewSet, +) +from .cycle.archive import ( + CycleArchiveUnarchiveEndpoint, +) + +from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet +from .issue.base import ( + IssueListEndpoint, IssueViewSet, - WorkSpaceIssuesEndpoint, - IssueActivityEndpoint, - IssueCommentViewSet, IssueUserDisplayPropertyEndpoint, - LabelViewSet, BulkDeleteIssuesEndpoint, - UserWorkSpaceIssues, - SubIssuesEndpoint, - IssueLinkViewSet, - BulkCreateIssueLabelsEndpoint, - IssueAttachmentEndpoint, +) + +from .issue.activity import ( + IssueActivityEndpoint, +) + +from .issue.archive import ( IssueArchiveViewSet, - IssueSubscriberViewSet, +) + +from .issue.attachment import ( + IssueAttachmentEndpoint, +) + +from .issue.comment import ( + IssueCommentViewSet, CommentReactionViewSet, - IssueReactionViewSet, +) + +from .issue.draft import IssueDraftViewSet + +from .issue.label import ( + LabelViewSet, + BulkCreateIssueLabelsEndpoint, +) + +from .issue.link import ( + IssueLinkViewSet, +) + +from .issue.relation import ( IssueRelationViewSet, - IssueDraftViewSet, ) -from .auth_extended import ( - ForgotPasswordEndpoint, - ResetPasswordEndpoint, - ChangePasswordEndpoint, - SetUserPasswordEndpoint, - EmailCheckEndpoint, - MagicGenerateEndpoint, +from .issue.reaction import ( + IssueReactionViewSet, +) + +from .issue.sub_issue import ( + SubIssuesEndpoint, +) + +from .issue.subscriber import ( + IssueSubscriberViewSet, ) -from .authentication import ( - SignInEndpoint, - SignOutEndpoint, - MagicSignInEndpoint, -) - -from .module import ( +from .module.base import ( ModuleViewSet, - ModuleIssueViewSet, ModuleLinkViewSet, ModuleFavoriteViewSet, + ModuleUserPropertiesEndpoint, +) + +from .module.issue import ( + ModuleIssueViewSet, +) + +from .module.archive import ( + ModuleArchiveUnarchiveEndpoint, ) from .api import ApiTokenEndpoint -from .integration import ( - WorkspaceIntegrationViewSet, - IntegrationViewSet, - GithubIssueSyncViewSet, - GithubRepositorySyncViewSet, - GithubCommentSyncViewSet, - GithubRepositoriesEndpoint, - BulkCreateGithubIssueSyncEndpoint, - SlackProjectSyncViewSet, -) -from .importer import ( - ServiceIssueImportSummaryEndpoint, - ImportServiceEndpoint, - UpdateServiceImportStatusEndpoint, - BulkImportIssuesEndpoint, - BulkImportModulesEndpoint, -) - -from .page import ( +from .page.base import ( PageViewSet, PageFavoriteViewSet, PageLogEndpoint, SubPagesEndpoint, + PagesDescriptionViewSet, ) from .search import GlobalSearchEndpoint, IssueSearchEndpoint -from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint - -from .estimate import ( +from .external.base import ( + GPTIntegrationEndpoint, + UnsplashEndpoint, +) +from .estimate.base import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, ) -from .inbox import InboxViewSet, InboxIssueViewSet +from .inbox.base import InboxViewSet, InboxIssueViewSet -from .analytic import ( +from .analytic.base import ( AnalyticsEndpoint, AnalyticViewViewset, SavedAnalyticEndpoint, @@ -153,18 +202,25 @@ from .analytic import ( DefaultAnalyticsEndpoint, ) -from .notification import ( +from .notification.base import ( NotificationViewSet, UnreadNotificationEndpoint, - MarkAllReadNotificationViewSet, + UserNotificationPreferenceEndpoint, ) -from .exporter import ExportIssuesEndpoint +from .exporter.base import ExportIssuesEndpoint -from .config import ConfigurationEndpoint -from .webhook import ( +from .webhook.base import ( WebhookEndpoint, WebhookLogsEndpoint, WebhookSecretRegenerateEndpoint, ) + +from .dashboard.base import DashboardEndpoint, WidgetsEndpoint + +from .error_404 import custom_404_view + +from .exporter.base import ExportIssuesEndpoint +from .notification.base import MarkAllReadNotificationViewSet +from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint diff --git a/apiserver/plane/app/views/analytic.py b/apiserver/plane/app/views/analytic/base.py similarity index 88% rename from apiserver/plane/app/views/analytic.py rename to apiserver/plane/app/views/analytic/base.py index c1deb0d8f..256d3cae5 100644 --- a/apiserver/plane/app/views/analytic.py +++ b/apiserver/plane/app/views/analytic/base.py @@ -1,18 +1,20 @@ # Django imports -from django.db.models import Count, Sum, F, Q +from django.db.models import Count, F, Sum from django.db.models.functions import ExtractMonth +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 import BaseAPIView, BaseViewSet from plane.app.permissions import WorkSpaceAdminPermission -from plane.db.models import Issue, AnalyticView, Workspace, State, Label from plane.app.serializers import AnalyticViewSerializer -from plane.utils.analytics_plot import build_graph_plot + +# Module imports +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 +from plane.utils.analytics_plot import build_graph_plot from plane.utils.issue_filters import issue_filters @@ -50,8 +52,8 @@ class AnalyticsEndpoint(BaseAPIView): if ( not x_axis or not y_axis - or not x_axis in valid_xaxis_segment - or not y_axis in valid_yaxis + or x_axis not in valid_xaxis_segment + or y_axis not in valid_yaxis ): return Response( { @@ -61,7 +63,9 @@ class AnalyticsEndpoint(BaseAPIView): ) # If segment is present it cannot be same as x-axis - if segment and (segment not in valid_xaxis_segment or x_axis == segment): + if segment and ( + segment not in valid_xaxis_segment or x_axis == segment + ): return Response( { "error": "Both segment and x axis cannot be same and segment should be valid" @@ -110,7 +114,9 @@ class AnalyticsEndpoint(BaseAPIView): if x_axis in ["assignees__id"] or segment in ["assignees__id"]: assignee_details = ( Issue.issue_objects.filter( - workspace__slug=slug, **filters, assignees__avatar__isnull=False + workspace__slug=slug, + **filters, + assignees__avatar__isnull=False, ) .order_by("assignees__id") .distinct("assignees__id") @@ -124,7 +130,9 @@ class AnalyticsEndpoint(BaseAPIView): ) cycle_details = {} - if x_axis in ["issue_cycle__cycle_id"] or segment in ["issue_cycle__cycle_id"]: + if x_axis in ["issue_cycle__cycle_id"] or segment in [ + "issue_cycle__cycle_id" + ]: cycle_details = ( Issue.issue_objects.filter( workspace__slug=slug, @@ -186,7 +194,9 @@ class AnalyticViewViewset(BaseViewSet): def get_queryset(self): return self.filter_queryset( - super().get_queryset().filter(workspace__slug=self.kwargs.get("slug")) + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) ) @@ -196,7 +206,9 @@ class SavedAnalyticEndpoint(BaseAPIView): ] def get(self, request, slug, analytic_id): - analytic_view = AnalyticView.objects.get(pk=analytic_id, workspace__slug=slug) + analytic_view = AnalyticView.objects.get( + pk=analytic_id, workspace__slug=slug + ) filter = analytic_view.query queryset = Issue.issue_objects.filter(**filter) @@ -255,8 +267,8 @@ class ExportAnalyticsEndpoint(BaseAPIView): if ( not x_axis or not y_axis - or not x_axis in valid_xaxis_segment - or not y_axis in valid_yaxis + or x_axis not in valid_xaxis_segment + or y_axis not in valid_yaxis ): return Response( { @@ -266,7 +278,9 @@ class ExportAnalyticsEndpoint(BaseAPIView): ) # If segment is present it cannot be same as x-axis - if segment and (segment not in valid_xaxis_segment or x_axis == segment): + if segment and ( + segment not in valid_xaxis_segment or x_axis == segment + ): return Response( { "error": "Both segment and x axis cannot be same and segment should be valid" @@ -293,7 +307,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView): def get(self, request, slug): filters = issue_filters(request.GET, "GET") - base_issues = Issue.issue_objects.filter(workspace__slug=slug, **filters) + base_issues = Issue.issue_objects.filter( + workspace__slug=slug, **filters + ) total_issues = base_issues.count() @@ -306,7 +322,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView): ) open_issues_groups = ["backlog", "unstarted", "started"] - open_issues_queryset = state_groups.filter(state__group__in=open_issues_groups) + open_issues_queryset = state_groups.filter( + state__group__in=open_issues_groups + ) open_issues = open_issues_queryset.count() open_issues_classified = ( @@ -315,8 +333,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView): .order_by("state_group") ) + current_year = timezone.now().year issue_completed_month_wise = ( - base_issues.filter(completed_at__isnull=False) + base_issues.filter(completed_at__year=current_year) .annotate(month=ExtractMonth("completed_at")) .values("month") .annotate(count=Count("*")) @@ -361,10 +380,12 @@ class DefaultAnalyticsEndpoint(BaseAPIView): .order_by("-count") ) - open_estimate_sum = open_issues_queryset.aggregate(sum=Sum("estimate_point"))[ + open_estimate_sum = open_issues_queryset.aggregate( + sum=Sum("estimate_point") + )["sum"] + total_estimate_sum = base_issues.aggregate(sum=Sum("estimate_point"))[ "sum" ] - total_estimate_sum = base_issues.aggregate(sum=Sum("estimate_point"))["sum"] return Response( { diff --git a/apiserver/plane/app/views/api.py b/apiserver/plane/app/views/api.py index ce2d4bd09..6cd349b07 100644 --- a/apiserver/plane/app/views/api.py +++ b/apiserver/plane/app/views/api.py @@ -43,7 +43,7 @@ class ApiTokenEndpoint(BaseAPIView): ) def get(self, request, slug, pk=None): - if pk == None: + if pk is None: api_tokens = APIToken.objects.filter( user=request.user, workspace__slug=slug ) @@ -71,7 +71,9 @@ class ApiTokenEndpoint(BaseAPIView): user=request.user, pk=pk, ) - serializer = APITokenSerializer(api_token, data=request.data, partial=True) + serializer = APITokenSerializer( + api_token, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/asset.py b/apiserver/plane/app/views/asset/base.py similarity index 66% rename from apiserver/plane/app/views/asset.py rename to apiserver/plane/app/views/asset/base.py index 17d70d936..6de4a4ee7 100644 --- a/apiserver/plane/app/views/asset.py +++ b/apiserver/plane/app/views/asset/base.py @@ -4,13 +4,17 @@ from rest_framework.response import Response from rest_framework.parsers import MultiPartParser, FormParser, JSONParser # Module imports -from .base import BaseAPIView, BaseViewSet +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,) + parser_classes = ( + MultiPartParser, + FormParser, + JSONParser, + ) """ A viewset for viewing and editing task instances. @@ -20,10 +24,18 @@ class FileAssetEndpoint(BaseAPIView): 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) + 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) + 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) @@ -33,7 +45,7 @@ class FileAssetEndpoint(BaseAPIView): serializer.save(workspace_id=workspace.id) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - + def delete(self, request, workspace_id, asset_key): asset_key = str(workspace_id) + "/" + asset_key file_asset = FileAsset.objects.get(asset=asset_key) @@ -43,7 +55,6 @@ class FileAssetEndpoint(BaseAPIView): 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) @@ -56,12 +67,22 @@ class UserAssetsEndpoint(BaseAPIView): parser_classes = (MultiPartParser, FormParser) def get(self, request, asset_key): - files = FileAsset.objects.filter(asset=asset_key, created_by=request.user) + files = FileAsset.objects.filter( + asset=asset_key, created_by=request.user + ) if files.exists(): - serializer = FileAssetSerializer(files, context={"request": request}) - return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK) + 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) + return Response( + {"error": "Asset key does not exist", "status": False}, + status=status.HTTP_200_OK, + ) def post(self, request): serializer = FileAssetSerializer(data=request.data) @@ -70,9 +91,10 @@ class UserAssetsEndpoint(BaseAPIView): 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 = FileAsset.objects.get( + asset=asset_key, created_by=request.user + ) file_asset.is_deleted = True file_asset.save() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py deleted file mode 100644 index 049e5aab9..000000000 --- a/apiserver/plane/app/views/auth_extended.py +++ /dev/null @@ -1,467 +0,0 @@ -## Python imports -import uuid -import os -import json -import random -import string - -## Django imports -from django.contrib.auth.tokens import PasswordResetTokenGenerator -from django.utils.encoding import ( - smart_str, - smart_bytes, - DjangoUnicodeDecodeError, -) -from django.contrib.auth.hashers import make_password -from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode -from django.core.validators import validate_email -from django.core.exceptions import ValidationError -from django.conf import settings - -## Third Party Imports -from rest_framework import status -from rest_framework.response import Response -from rest_framework.permissions import AllowAny -from rest_framework_simplejwt.tokens import RefreshToken - -## Module imports -from . import BaseAPIView -from plane.app.serializers import ( - ChangePasswordSerializer, - ResetPasswordSerializer, - UserSerializer, -) -from plane.db.models import User, WorkspaceMemberInvite -from plane.license.utils.instance_value import get_configuration_value -from plane.bgtasks.forgot_password_task import forgot_password -from plane.license.models import Instance -from plane.settings.redis import redis_instance -from plane.bgtasks.magic_link_code_task import magic_link -from plane.bgtasks.event_tracking_task import auth_events - - -def get_tokens_for_user(user): - refresh = RefreshToken.for_user(user) - return ( - str(refresh.access_token), - str(refresh), - ) - - -def generate_magic_token(email): - key = "magic_" + str(email) - - ## Generate a random token - token = ( - "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - ) - - # Initialize the redis instance - ri = redis_instance() - - # Check if the key already exists in python - if ri.exists(key): - data = json.loads(ri.get(key)) - - current_attempt = data["current_attempt"] + 1 - - if data["current_attempt"] > 2: - return key, token, False - - value = { - "current_attempt": current_attempt, - "email": email, - "token": token, - } - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - else: - value = {"current_attempt": 0, "email": email, "token": token} - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - return key, token, True - - -def generate_password_token(user): - uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) - token = PasswordResetTokenGenerator().make_token(user) - - return uidb64, token - - -class ForgotPasswordEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - email = request.data.get("email") - - try: - validate_email(email) - except ValidationError: - return Response( - {"error": "Please enter a valid email"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get the user - user = User.objects.filter(email=email).first() - if user: - # Get the reset token for user - uidb64, token = generate_password_token(user=user) - current_site = request.META.get("HTTP_ORIGIN") - # send the forgot password email - forgot_password.delay( - user.first_name, user.email, uidb64, token, current_site - ) - return Response( - {"message": "Check your email to reset your password"}, - status=status.HTTP_200_OK, - ) - return Response( - {"error": "Please check the email"}, status=status.HTTP_400_BAD_REQUEST - ) - - -class ResetPasswordEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request, uidb64, token): - try: - # Decode the id from the uidb64 - id = smart_str(urlsafe_base64_decode(uidb64)) - user = User.objects.get(id=id) - - # check if the token is valid for the user - if not PasswordResetTokenGenerator().check_token(user, token): - return Response( - {"error": "Token is invalid"}, - status=status.HTTP_401_UNAUTHORIZED, - ) - - # Reset the password - serializer = ResetPasswordSerializer(data=request.data) - if serializer.is_valid(): - # set_password also hashes the password that the user will get - user.set_password(serializer.data.get("new_password")) - user.is_password_autoset = False - user.save() - - # Log the user in - # Generate access token for the user - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - - return Response(data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - except DjangoUnicodeDecodeError as indentifier: - return Response( - {"error": "token is not valid, please check the new one"}, - status=status.HTTP_401_UNAUTHORIZED, - ) - - -class ChangePasswordEndpoint(BaseAPIView): - def post(self, request): - serializer = ChangePasswordSerializer(data=request.data) - user = User.objects.get(pk=request.user.id) - if serializer.is_valid(): - if not user.check_password(serializer.data.get("old_password")): - return Response( - {"error": "Old password is not correct"}, - status=status.HTTP_400_BAD_REQUEST, - ) - # set_password also hashes the password that the user will get - user.set_password(serializer.data.get("new_password")) - user.is_password_autoset = False - user.save() - return Response( - {"message": "Password updated successfully"}, status=status.HTTP_200_OK - ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class SetUserPasswordEndpoint(BaseAPIView): - def post(self, request): - user = User.objects.get(pk=request.user.id) - password = request.data.get("password", False) - - # If the user password is not autoset then return error - if not user.is_password_autoset: - return Response( - { - "error": "Your password is already set please change your password from profile" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check password validation - if not password and len(str(password)) < 8: - return Response( - {"error": "Password is not valid"}, status=status.HTTP_400_BAD_REQUEST - ) - - # Set the user password - user.set_password(password) - user.is_password_autoset = False - user.save() - serializer = UserSerializer(user) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class MagicGenerateEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - email = request.data.get("email", False) - - # Check the instance registration - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if not email: - return Response( - {"error": "Please provide a valid email address"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Clean up the email - email = email.strip().lower() - validate_email(email) - - # check if the email exists not - if not User.objects.filter(email=email).exists(): - # Create a user - _ = User.objects.create( - email=email, - username=uuid.uuid4().hex, - password=make_password(uuid.uuid4().hex), - is_password_autoset=True, - ) - - ## Generate a random token - token = ( - "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - ) - - ri = redis_instance() - - key = "magic_" + str(email) - - # Check if the key already exists in python - if ri.exists(key): - data = json.loads(ri.get(key)) - - current_attempt = data["current_attempt"] + 1 - - if data["current_attempt"] > 2: - return Response( - {"error": "Max attempts exhausted. Please try again later."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - value = { - "current_attempt": current_attempt, - "email": email, - "token": token, - } - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - else: - value = {"current_attempt": 0, "email": email, "token": token} - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - # If the smtp is configured send through here - current_site = request.META.get("HTTP_ORIGIN") - magic_link.delay(email, key, token, current_site) - - return Response({"key": key}, status=status.HTTP_200_OK) - - -class EmailCheckEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - # Check the instance registration - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get configuration values - ENABLE_SIGNUP, ENABLE_MAGIC_LINK_LOGIN = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP"), - }, - { - "key": "ENABLE_MAGIC_LINK_LOGIN", - "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN"), - }, - ] - ) - - email = request.data.get("email", False) - - if not email: - return Response( - {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - # validate the email - try: - validate_email(email) - except ValidationError: - return Response( - {"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST - ) - - # Check if the user exists - user = User.objects.filter(email=email).first() - current_site = request.META.get("HTTP_ORIGIN") - - # If new user - if user is None: - # Create the user - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter( - email=email, - ).exists() - ): - return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Create the user with default values - user = User.objects.create( - email=email, - username=uuid.uuid4().hex, - password=make_password(uuid.uuid4().hex), - is_password_autoset=True, - ) - - if not bool( - ENABLE_MAGIC_LINK_LOGIN, - ): - return Response( - {"error": "Magic link sign in is disabled."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Send event - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", - first_time=True, - ) - key, token, current_attempt = generate_magic_token(email=email) - if not current_attempt: - return Response( - {"error": "Max attempts exhausted. Please try again later."}, - status=status.HTTP_400_BAD_REQUEST, - ) - # Trigger the email - magic_link.delay(email, "magic_" + str(email), token, current_site) - return Response( - {"is_password_autoset": user.is_password_autoset, "is_existing": False}, - status=status.HTTP_200_OK, - ) - - # Existing user - else: - if user.is_password_autoset: - ## Generate a random token - if not bool(ENABLE_MAGIC_LINK_LOGIN): - return Response( - {"error": "Magic link sign in is disabled."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", - first_time=False, - ) - - # Generate magic token - key, token, current_attempt = generate_magic_token(email=email) - if not current_attempt: - return Response( - {"error": "Max attempts exhausted. Please try again later."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Trigger the email - magic_link.delay(email, key, token, current_site) - return Response( - { - "is_password_autoset": user.is_password_autoset, - "is_existing": True, - }, - status=status.HTTP_200_OK, - ) - else: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", - first_time=False, - ) - - # User should enter password to login - return Response( - { - "is_password_autoset": user.is_password_autoset, - "is_existing": True, - }, - status=status.HTTP_200_OK, - ) diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py deleted file mode 100644 index 256446313..000000000 --- a/apiserver/plane/app/views/authentication.py +++ /dev/null @@ -1,442 +0,0 @@ -# Python imports -import os -import uuid -import json - -# Django imports -from django.utils import timezone -from django.core.exceptions import ValidationError -from django.core.validators import validate_email -from django.conf import settings -from django.contrib.auth.hashers import make_password - -# Third party imports -from rest_framework.response import Response -from rest_framework.permissions import AllowAny -from rest_framework import status -from rest_framework_simplejwt.tokens import RefreshToken -from sentry_sdk import capture_message - -# Module imports -from . import BaseAPIView -from plane.db.models import ( - User, - WorkspaceMemberInvite, - WorkspaceMember, - ProjectMemberInvite, - ProjectMember, -) -from plane.settings.redis import redis_instance -from plane.license.models import Instance -from plane.license.utils.instance_value import get_configuration_value -from plane.bgtasks.event_tracking_task import auth_events - - -def get_tokens_for_user(user): - refresh = RefreshToken.for_user(user) - return ( - str(refresh.access_token), - str(refresh), - ) - - -class SignUpEndpoint(BaseAPIView): - permission_classes = (AllowAny,) - - def post(self, request): - # Check if the instance configuration is done - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - email = request.data.get("email", False) - password = request.data.get("password", False) - ## Raise exception if any of the above are missing - if not email or not password: - return Response( - {"error": "Both email and password are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Validate the email - email = email.strip().lower() - try: - validate_email(email) - except ValidationError as e: - return Response( - {"error": "Please provide a valid email address."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # get configuration values - # Get configuration values - ENABLE_SIGNUP, = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP"), - }, - ] - ) - - # If the sign up is not enabled and the user does not have invite disallow him from creating the account - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter( - email=email, - ).exists() - ): - return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check if the user already exists - if User.objects.filter(email=email).exists(): - return Response( - {"error": "User with this email already exists"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.create(email=email, username=uuid.uuid4().hex) - user.set_password(password) - - # settings last actives for the user - user.is_password_autoset = False - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - access_token, refresh_token = get_tokens_for_user(user) - - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - - return Response(data, status=status.HTTP_200_OK) - - -class SignInEndpoint(BaseAPIView): - permission_classes = (AllowAny,) - - def post(self, request): - # Check if the instance configuration is done - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - email = request.data.get("email", False) - password = request.data.get("password", False) - - ## Raise exception if any of the above are missing - if not email or not password: - return Response( - {"error": "Both email and password are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Validate email - email = email.strip().lower() - try: - validate_email(email) - except ValidationError as e: - return Response( - {"error": "Please provide a valid email address."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get the user - user = User.objects.filter(email=email).first() - - # Existing user - if user: - # Check user password - if not user.check_password(password): - return Response( - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - status=status.HTTP_403_FORBIDDEN, - ) - - # Create the user - else: - ENABLE_SIGNUP, = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP"), - }, - ] - ) - # Create the user - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter( - email=email, - ).exists() - ): - return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.create( - email=email, - username=uuid.uuid4().hex, - password=make_password(password), - is_password_autoset=False, - ) - - # settings last active for the user - user.is_active = True - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - # Check if user has any accepted invites for workspace and add them to workspace - workspace_member_invites = WorkspaceMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=workspace_member_invite.workspace_id, - member=user, - role=workspace_member_invite.role, - ) - for workspace_member_invite in workspace_member_invites - ], - ignore_conflicts=True, - ) - - # Check if user has any project invites - project_member_invites = ProjectMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - # Add user to workspace - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=project_member_invite.workspace_id, - role=project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15, - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Now add the users to project - ProjectMember.objects.bulk_create( - [ - ProjectMember( - workspace_id=project_member_invite.workspace_id, - role=project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15, - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Delete all the invites - workspace_member_invites.delete() - project_member_invites.delete() - # Send event - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", - first_time=False, - ) - - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - return Response(data, status=status.HTTP_200_OK) - - -class SignOutEndpoint(BaseAPIView): - def post(self, request): - refresh_token = request.data.get("refresh_token", False) - - if not refresh_token: - capture_message("No refresh token provided") - return Response( - {"error": "No refresh token provided"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.get(pk=request.user.id) - - user.last_logout_time = timezone.now() - user.last_logout_ip = request.META.get("REMOTE_ADDR") - - user.save() - - token = RefreshToken(refresh_token) - token.blacklist() - return Response({"message": "success"}, status=status.HTTP_200_OK) - - -class MagicSignInEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - # Check if the instance configuration is done - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user_token = request.data.get("token", "").strip() - key = request.data.get("key", False).strip().lower() - - if not key or user_token == "": - return Response( - {"error": "User token and key are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - ri = redis_instance() - - if ri.exists(key): - data = json.loads(ri.get(key)) - - token = data["token"] - email = data["email"] - - if str(token) == str(user_token): - user = User.objects.get(email=email) - # Send event - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", - first_time=False, - ) - - user.is_active = True - user.is_email_verified = True - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - # Check if user has any accepted invites for workspace and add them to workspace - workspace_member_invites = WorkspaceMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=workspace_member_invite.workspace_id, - member=user, - role=workspace_member_invite.role, - ) - for workspace_member_invite in workspace_member_invites - ], - ignore_conflicts=True, - ) - - # Check if user has any project invites - project_member_invites = ProjectMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - # Add user to workspace - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=project_member_invite.workspace_id, - role=project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15, - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Now add the users to project - ProjectMember.objects.bulk_create( - [ - ProjectMember( - workspace_id=project_member_invite.workspace_id, - role=project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15, - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Delete all the invites - workspace_member_invites.delete() - project_member_invites.delete() - - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - - return Response(data, status=status.HTTP_200_OK) - - else: - return Response( - {"error": "Your login code was incorrect. Please try again."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - else: - return Response( - {"error": "The magic code/link has expired please try again"}, - status=status.HTTP_400_BAD_REQUEST, - ) diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 32449597b..8f21f5fe1 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -1,30 +1,27 @@ # Python imports import zoneinfo -import json +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.conf import settings from django.utils import timezone -from django.db import IntegrityError -from django.core.exceptions import ObjectDoesNotExist, ValidationError -from django.core.serializers.json import DjangoJSONEncoder +from django_filters.rest_framework import DjangoFilterBackend # Third part imports from rest_framework import status -from rest_framework import status -from rest_framework.viewsets import ModelViewSet -from rest_framework.response import Response from rest_framework.exceptions import APIException -from rest_framework.views import APIView from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated -from sentry_sdk import capture_exception -from django_filters.rest_framework import DjangoFilterBackend +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.bgtasks.webhook_task import send_webhook class TimezoneMixin: @@ -41,32 +38,6 @@ class TimezoneMixin: timezone.deactivate() -class WebhookMixin: - webhook_event = None - bulk = False - - def finalize_response(self, request, response, *args, **kwargs): - response = super().finalize_response(request, response, *args, **kwargs) - - # Check for the case should webhook be sent - if ( - self.webhook_event - and self.request.method in ["POST", "PATCH", "DELETE"] - and response.status_code in [200, 201, 204] - ): - # Push the object to delay - send_webhook.delay( - event=self.webhook_event, - payload=response.data, - kw=self.kwargs, - action=self.request.method, - slug=self.workspace_slug, - bulk=self.bulk, - ) - - return response - - class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): model = None @@ -79,6 +50,10 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): SearchFilter, ) + authentication_classes = [ + BaseSessionAuthentication, + ] + filterset_fields = [] search_fields = [] @@ -87,8 +62,10 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): try: return self.model.objects.all() except Exception as e: - capture_exception(e) - raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) + log_exception(e) + raise APIException( + "Please check the view", status.HTTP_400_BAD_REQUEST + ) def handle_exception(self, exc): """ @@ -99,6 +76,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): response = super().handle_exception(exc) return response except Exception as e: + print(e) if settings.DEBUG else print("Server Error") if isinstance(e, IntegrityError): return Response( {"error": "The payload is not valid"}, @@ -112,23 +90,23 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": "The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): - capture_exception(e) + log_exception(e) return Response( - {"error": f"key {e} does not exist"}, + {"error": "The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST, ) - - print(e) if settings.DEBUG else print("Server Error") - capture_exception(e) - return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + 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: @@ -159,6 +137,24 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): 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, APIView, BasePaginator): permission_classes = [ @@ -170,6 +166,10 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): SearchFilter, ) + authentication_classes = [ + BaseSessionAuthentication, + ] + filterset_fields = [] search_fields = [] @@ -201,20 +201,22 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": "The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) - + if isinstance(e, KeyError): - return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) - - if settings.DEBUG: - print(e) - capture_exception(e) - return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + 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: @@ -239,3 +241,21 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): @property def project_id(self): return self.kwargs.get("project_id", None) + + @property + def fields(self): + fields = [ + field + for field in self.request.GET.get("fields", "").split(",") + if field + ] + return fields if fields else None + + @property + def expand(self): + expand = [ + expand + for expand in self.request.GET.get("expand", "").split(",") + if expand + ] + return expand if expand else None diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py deleted file mode 100644 index c53b30495..000000000 --- a/apiserver/plane/app/views/config.py +++ /dev/null @@ -1,120 +0,0 @@ -# Python imports -import os - -# Django imports -from django.conf import settings - -# Third party imports -from rest_framework.permissions import AllowAny -from rest_framework import status -from rest_framework.response import Response - -# Module imports -from .base import BaseAPIView -from plane.license.utils.instance_value import get_configuration_value - - -class ConfigurationEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def get(self, request): - - # Get all the configuration - ( - GOOGLE_CLIENT_ID, - GITHUB_CLIENT_ID, - GITHUB_APP_NAME, - EMAIL_HOST_USER, - EMAIL_HOST_PASSWORD, - ENABLE_MAGIC_LINK_LOGIN, - ENABLE_EMAIL_PASSWORD, - SLACK_CLIENT_ID, - POSTHOG_API_KEY, - POSTHOG_HOST, - UNSPLASH_ACCESS_KEY, - OPENAI_API_KEY, - ) = get_configuration_value( - [ - { - "key": "GOOGLE_CLIENT_ID", - "default": os.environ.get("GOOGLE_CLIENT_ID", None), - }, - { - "key": "GITHUB_CLIENT_ID", - "default": os.environ.get("GITHUB_CLIENT_ID", None), - }, - { - "key": "GITHUB_APP_NAME", - "default": os.environ.get("GITHUB_APP_NAME", None), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER", None), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD", None), - }, - { - "key": "ENABLE_MAGIC_LINK_LOGIN", - "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), - }, - { - "key": "ENABLE_EMAIL_PASSWORD", - "default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), - }, - { - "key": "SLACK_CLIENT_ID", - "default": os.environ.get("SLACK_CLIENT_ID", "1"), - }, - { - "key": "POSTHOG_API_KEY", - "default": os.environ.get("POSTHOG_API_KEY", "1"), - }, - { - "key": "POSTHOG_HOST", - "default": os.environ.get("POSTHOG_HOST", "1"), - }, - { - "key": "UNSPLASH_ACCESS_KEY", - "default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"), - }, - { - "key": "OPENAI_API_KEY", - "default": os.environ.get("OPENAI_API_KEY", "1"), - }, - ] - ) - - data = {} - # Authentication - data["google_client_id"] = GOOGLE_CLIENT_ID if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != "\"\"" else None - data["github_client_id"] = GITHUB_CLIENT_ID if GITHUB_CLIENT_ID and GITHUB_CLIENT_ID != "\"\"" else None - data["github_app_name"] = GITHUB_APP_NAME - data["magic_login"] = ( - bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) - ) and ENABLE_MAGIC_LINK_LOGIN == "1" - - data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1" - # Slack client - data["slack_client_id"] = SLACK_CLIENT_ID - - # Posthog - data["posthog_api_key"] = POSTHOG_API_KEY - data["posthog_host"] = POSTHOG_HOST - - # Unsplash - data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY) - - # Open AI settings - data["has_openai_configured"] = bool(OPENAI_API_KEY) - - # File size settings - data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) - - # is self managed - data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1"))) - - return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py deleted file mode 100644 index d2f82d75b..000000000 --- a/apiserver/plane/app/views/cycle.py +++ /dev/null @@ -1,808 +0,0 @@ -# Python imports -import json - -# Django imports -from django.db.models import ( - Func, - F, - Q, - Exists, - OuterRef, - Count, - Prefetch, - Sum, -) -from django.core import serializers -from django.utils import timezone -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page - -# Third party imports -from rest_framework.response import Response -from rest_framework import status - -# Module imports -from . import BaseViewSet, BaseAPIView, WebhookMixin -from plane.app.serializers import ( - CycleSerializer, - CycleIssueSerializer, - CycleFavoriteSerializer, - IssueStateSerializer, - CycleWriteSerializer, -) -from plane.app.permissions import ProjectEntityPermission -from plane.db.models import ( - User, - Cycle, - CycleIssue, - Issue, - CycleFavorite, - IssueLink, - IssueAttachment, - Label, -) -from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results -from plane.utils.issue_filters import issue_filters -from plane.utils.analytics_plot import burndown_plot - - -class CycleViewSet(WebhookMixin, BaseViewSet): - serializer_class = CycleSerializer - model = Cycle - webhook_event = "cycle" - permission_classes = [ - ProjectEntityPermission, - ] - - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), owned_by=self.request.user - ) - - def get_queryset(self): - subquery = CycleFavorite.objects.filter( - user=self.request.user, - cycle_id=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - ) - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("owned_by") - .annotate(is_favorite=Exists(subquery)) - .annotate( - total_issues=Count( - "issue_cycle", - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - completed_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - cancelled_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="cancelled", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - started_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - unstarted_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="unstarted", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - backlog_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="backlog", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) - .annotate( - completed_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - started_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .prefetch_related( - Prefetch( - "issue_cycle__issue__assignees", - queryset=User.objects.only("avatar", "first_name", "id").distinct(), - ) - ) - .prefetch_related( - Prefetch( - "issue_cycle__issue__labels", - queryset=Label.objects.only("name", "color", "id").distinct(), - ) - ) - .order_by("-is_favorite", "name") - .distinct() - ) - - def list(self, request, slug, project_id): - queryset = self.get_queryset() - cycle_view = request.GET.get("cycle_view", "all") - - queryset = queryset.order_by("-is_favorite","-created_at") - - # Current Cycle - if cycle_view == "current": - queryset = queryset.filter( - start_date__lte=timezone.now(), - end_date__gte=timezone.now(), - ) - - data = CycleSerializer(queryset, many=True).data - - if len(data): - assignee_distribution = ( - Issue.objects.filter( - issue_cycle__cycle_id=data[0]["id"], - workspace__slug=slug, - project_id=project_id, - ) - .annotate(display_name=F("assignees__display_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") - .annotate( - total_issues=Count( - "assignee_id", - filter=Q(archived_at__isnull=True, is_draft=False), - ), - ) - .annotate( - completed_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("display_name") - ) - - label_distribution = ( - Issue.objects.filter( - issue_cycle__cycle_id=data[0]["id"], - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "label_id", - filter=Q(archived_at__isnull=True, is_draft=False), - ) - ) - .annotate( - completed_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - data[0]["distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - if data[0]["start_date"] and data[0]["end_date"]: - data[0]["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset.first(), - slug=slug, - project_id=project_id, - cycle_id=data[0]["id"], - ) - - return Response(data, status=status.HTTP_200_OK) - - # Upcoming Cycles - if cycle_view == "upcoming": - queryset = queryset.filter(start_date__gt=timezone.now()) - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - # Completed Cycles - if cycle_view == "completed": - queryset = queryset.filter(end_date__lt=timezone.now()) - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - # Draft Cycles - if cycle_view == "draft": - queryset = queryset.filter( - end_date=None, - start_date=None, - ) - - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - # Incomplete Cycles - if cycle_view == "incomplete": - queryset = queryset.filter( - Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True), - ) - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - # If no matching view is found return all cycles - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - def create(self, request, slug, project_id): - if ( - request.data.get("start_date", None) is None - and request.data.get("end_date", None) is None - ) or ( - request.data.get("start_date", None) is not None - and request.data.get("end_date", None) is not None - ): - serializer = CycleSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_id, - owned_by=request.user, - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - return Response( - { - "error": "Both start date and end date are either required or are to be null" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - def partial_update(self, request, slug, project_id, pk): - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - - request_data = request.data - - if cycle.end_date is not None and cycle.end_date < timezone.now().date(): - if "sort_order" in request_data: - # Can only change sort order - request_data = { - "sort_order": request_data.get("sort_order", cycle.sort_order) - } - else: - return Response( - { - "error": "The Cycle has already been completed so it cannot be edited" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = CycleWriteSerializer(cycle, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().get(pk=pk) - - # Assignee Distribution - assignee_distribution = ( - Issue.objects.filter( - issue_cycle__cycle_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(first_name=F("assignees__first_name")) - .annotate(last_name=F("assignees__last_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .annotate(display_name=F("assignees__display_name")) - .values("first_name", "last_name", "assignee_id", "avatar", "display_name") - .annotate( - total_issues=Count( - "assignee_id", - filter=Q(archived_at__isnull=True, is_draft=False), - ), - ) - .annotate( - completed_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("first_name", "last_name") - ) - - # Label Distribution - label_distribution = ( - Issue.objects.filter( - issue_cycle__cycle_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "label_id", - filter=Q(archived_at__isnull=True, is_draft=False), - ), - ) - .annotate( - completed_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - data = CycleSerializer(queryset).data - data["distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - - if queryset.start_date and queryset.end_date: - data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset, slug=slug, project_id=project_id, cycle_id=pk - ) - - return Response( - data, - status=status.HTTP_200_OK, - ) - - def destroy(self, request, slug, project_id, pk): - cycle_issues = list( - CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( - "issue", flat=True - ) - ) - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - - issue_activity.delay( - type="cycle.activity.deleted", - requested_data=json.dumps( - { - "cycle_id": str(pk), - "cycle_name": str(cycle.name), - "issues": [str(issue_id) for issue_id in cycle_issues], - } - ), - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - # Delete the cycle - cycle.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class CycleIssueViewSet(WebhookMixin, BaseViewSet): - serializer_class = CycleIssueSerializer - model = CycleIssue - - webhook_event = "cycle_issue" - bulk = True - - permission_classes = [ - ProjectEntityPermission, - ] - - filterset_fields = [ - "issue__labels__id", - "issue__assignees__id", - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) - .filter(cycle_id=self.kwargs.get("cycle_id")) - .select_related("project") - .select_related("workspace") - .select_related("cycle") - .select_related("issue", "issue__state", "issue__project") - .prefetch_related("issue__assignees", "issue__labels") - .distinct() - ) - - @method_decorator(gzip_page) - def list(self, request, slug, project_id, cycle_id): - fields = [field for field in request.GET.get("fields", "").split(",") if field] - order_by = request.GET.get("order_by", "created_at") - filters = issue_filters(request.query_params, "GET") - issues = ( - Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate(bridge_id=F("issue_cycle__id")) - .filter(project_id=project_id) - .filter(workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .order_by(order_by) - .filter(**filters) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - - issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) - - def create(self, request, slug, project_id, cycle_id): - issues = request.data.get("issues", []) - - if not len(issues): - return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST - ) - - cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ) - - if cycle.end_date is not None and cycle.end_date < timezone.now().date(): - return Response( - { - "error": "The Cycle has already been completed so no new issues can be added" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get all CycleIssues already created - cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues)) - update_cycle_issue_activity = [] - record_to_create = [] - records_to_update = [] - - for issue in issues: - cycle_issue = [ - cycle_issue - for cycle_issue in cycle_issues - if str(cycle_issue.issue_id) in issues - ] - # Update only when cycle changes - if len(cycle_issue): - if cycle_issue[0].cycle_id != cycle_id: - update_cycle_issue_activity.append( - { - "old_cycle_id": str(cycle_issue[0].cycle_id), - "new_cycle_id": str(cycle_id), - "issue_id": str(cycle_issue[0].issue_id), - } - ) - cycle_issue[0].cycle_id = cycle_id - records_to_update.append(cycle_issue[0]) - else: - record_to_create.append( - CycleIssue( - project_id=project_id, - workspace=cycle.workspace, - created_by=request.user, - updated_by=request.user, - cycle=cycle, - issue_id=issue, - ) - ) - - CycleIssue.objects.bulk_create( - record_to_create, - batch_size=10, - ignore_conflicts=True, - ) - CycleIssue.objects.bulk_update( - records_to_update, - ["cycle"], - batch_size=10, - ) - - # Capture Issue Activity - issue_activity.delay( - type="cycle.activity.created", - requested_data=json.dumps({"cycles_list": issues}), - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "updated_cycle_issues": update_cycle_issue_activity, - "created_cycle_issues": serializers.serialize( - "json", record_to_create - ), - } - ), - epoch=int(timezone.now().timestamp()), - ) - - # Return all Cycle Issues - return Response( - CycleIssueSerializer(self.get_queryset(), many=True).data, - status=status.HTTP_200_OK, - ) - - def destroy(self, request, slug, project_id, cycle_id, pk): - cycle_issue = CycleIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id - ) - issue_id = cycle_issue.issue_id - issue_activity.delay( - type="cycle.activity.deleted", - requested_data=json.dumps( - { - "cycle_id": str(self.kwargs.get("cycle_id")), - "issues": [str(issue_id)], - } - ), - actor_id=str(self.request.user.id), - issue_id=str(cycle_issue.issue_id), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - cycle_issue.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class CycleDateCheckEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] - - def post(self, request, slug, project_id): - start_date = request.data.get("start_date", False) - end_date = request.data.get("end_date", False) - cycle_id = request.data.get("cycle_id") - if not start_date or not end_date: - return Response( - {"error": "Start date and end date both are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - cycles = Cycle.objects.filter( - Q(workspace__slug=slug) - & Q(project_id=project_id) - & ( - Q(start_date__lte=start_date, end_date__gte=start_date) - | Q(start_date__lte=end_date, end_date__gte=end_date) - | Q(start_date__gte=start_date, end_date__lte=end_date) - ) - ).exclude(pk=cycle_id) - - if cycles.exists(): - return Response( - { - "error": "You have a cycle already on the given dates, if you want to create a draft cycle you can do that by removing dates", - "status": False, - } - ) - else: - return Response({"status": True}, status=status.HTTP_200_OK) - - -class CycleFavoriteViewSet(BaseViewSet): - serializer_class = CycleFavoriteSerializer - model = CycleFavorite - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(user=self.request.user) - .select_related("cycle", "cycle__owned_by") - ) - - def create(self, request, slug, project_id): - serializer = CycleFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=request.user, project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, cycle_id): - cycle_favorite = CycleFavorite.objects.get( - project=project_id, - user=request.user, - workspace__slug=slug, - cycle_id=cycle_id, - ) - cycle_favorite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class TransferCycleIssueEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] - - def post(self, request, slug, project_id, cycle_id): - new_cycle_id = request.data.get("new_cycle_id", False) - - if not new_cycle_id: - return Response( - {"error": "New Cycle Id is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - new_cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=new_cycle_id - ) - - if ( - new_cycle.end_date is not None - and new_cycle.end_date < timezone.now().date() - ): - return Response( - { - "error": "The cycle where the issues are transferred is already completed" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - cycle_issues = CycleIssue.objects.filter( - cycle_id=cycle_id, - project_id=project_id, - workspace__slug=slug, - issue__state__group__in=["backlog", "unstarted", "started"], - ) - - updated_cycles = [] - for cycle_issue in cycle_issues: - cycle_issue.cycle_id = new_cycle_id - updated_cycles.append(cycle_issue) - - cycle_issues = CycleIssue.objects.bulk_update( - updated_cycles, ["cycle_id"], batch_size=100 - ) - - return Response({"message": "Success"}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py new file mode 100644 index 000000000..5e1241b08 --- /dev/null +++ b/apiserver/plane/app/views/cycle/archive.py @@ -0,0 +1,410 @@ +# 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, +) +from django.db.models.functions import Coalesce +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + Cycle, + UserFavorite, + Issue, + Label, + User, +) +from plane.utils.analytics_plot import burndown_plot + +# Module imports +from .. import BaseAPIView + + +class CycleArchiveUnarchiveEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + 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"), + ) + 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", "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, + ), + ) + ) + .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, + ), + ) + ) + .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, + ), + ) + ) + .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, + ), + ) + ) + .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, + ), + ) + ) + .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, + ), + ) + ) + .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())), + ) + ) + .order_by("-is_favorite", "name") + .distinct() + ) + + def get(self, request, slug, project_id, pk=None): + if pk is None: + queryset = ( + self.get_queryset() + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .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, + ) + .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", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) + .first() + ) + queryset = queryset.first() + + if data is None: + return Response( + {"error": "Cycle does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Assignee Distribution + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .annotate(display_name=F("assignees__display_name")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_issues=Count( + "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.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "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, + cycle_id=pk, + ) + + return Response( + data, + status=status.HTTP_200_OK, + ) + + 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().date(): + return Response( + {"error": "Only completed cycles can be archived"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + cycle.archived_at = timezone.now() + cycle.save() + return Response( + {"archived_at": str(cycle.archived_at)}, + status=status.HTTP_200_OK, + ) + + 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/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py new file mode 100644 index 000000000..5982daf7f --- /dev/null +++ b/apiserver/plane/app/views/cycle/base.py @@ -0,0 +1,1092 @@ +# 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 ( + Case, + CharField, + Count, + Exists, + F, + Func, + OuterRef, + Prefetch, + Q, + UUIDField, + Value, + When, +) +from django.db.models.functions import Coalesce +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 ( + ProjectEntityPermission, + ProjectLitePermission, +) +from plane.app.serializers import ( + CycleSerializer, + CycleUserPropertiesSerializer, + CycleWriteSerializer, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Cycle, + CycleIssue, + UserFavorite, + CycleUserProperties, + Issue, + Label, + User, +) +from plane.utils.analytics_plot import burndown_plot + +# Module imports +from .. import BaseAPIView, BaseViewSet +from plane.bgtasks.webhook_task import model_activity + + +class CycleViewSet(BaseViewSet): + serializer_class = CycleSerializer + model = Cycle + webhook_event = "cycle" + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + owned_by=self.request.user, + ) + + def get_queryset(self): + 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"), + ) + 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", "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, + ), + ) + ) + .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, + ), + ) + ) + .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, + ), + ) + ) + .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, + ), + ) + ) + .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, + ), + ) + ) + .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, + ), + ) + ) + .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())), + ) + ) + .order_by("-is_favorite", "name") + .distinct() + ) + + 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") + + # Current Cycle + if cycle_view == "current": + queryset = queryset.filter( + start_date__lte=timezone.now(), + end_date__gte=timezone.now(), + ) + + 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", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + "created_by", + ) + + if data: + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_issues=Count( + "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") + ) + + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "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[0]["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if data[0]["start_date"] and data[0]["end_date"]: + data[0]["distribution"]["completion_chart"] = ( + burndown_plot( + queryset=queryset.first(), + slug=slug, + project_id=project_id, + cycle_id=data[0]["id"], + ) + ) + + return Response(data, status=status.HTTP_200_OK) + + 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", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + "created_by", + ) + return Response(data, status=status.HTTP_200_OK) + + def create(self, request, slug, project_id): + if ( + request.data.get("start_date", None) is None + and request.data.get("end_date", None) is None + ) or ( + request.data.get("start_date", None) is not None + and request.data.get("end_date", None) is not None + ): + serializer = CycleWriteSerializer(data=request.data) + 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", + # meta fields + "is_favorite", + "cancelled_issues", + "total_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) + .first() + ) + + # 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=request.META.get("HTTP_ORIGIN"), + ) + 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, + ) + + 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().date() + ): + 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 + ) + 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", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ).first() + + # 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=request.META.get("HTTP_ORIGIN"), + ) + + return Response(cycle, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, slug, project_id, pk): + queryset = ( + self.get_queryset().filter(archived_at__isnull=True).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, + ) + .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 + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + "created_by", + ) + .first() + ) + queryset = queryset.first() + + if data is None: + return Response( + {"error": "Cycle does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Assignee Distribution + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .annotate(display_name=F("assignees__display_name")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_issues=Count( + "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.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "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, + cycle_id=pk, + ) + + return Response( + data, + status=status.HTTP_200_OK, + ) + + def destroy(self, request, slug, project_id, pk): + cycle_issues = list( + CycleIssue.objects.filter( + cycle_id=self.kwargs.get("pk") + ).values_list("issue", flat=True) + ) + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + + issue_activity.delay( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(pk), + "cycle_name": str(cycle.name), + "issues": [str(issue_id) for issue_id in cycle_issues], + } + ), + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + # Delete the cycle + cycle.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CycleDateCheckEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id): + start_date = request.data.get("start_date", False) + end_date = request.data.get("end_date", False) + cycle_id = request.data.get("cycle_id") + if not start_date or not end_date: + return Response( + {"error": "Start date and end date both are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # 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", + "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") + ) + + 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) + + 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() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TransferCycleIssueEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id, cycle_id): + new_cycle_id = request.data.get("new_cycle_id", False) + + if not new_cycle_id: + return Response( + {"error": "New Cycle Id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + new_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=new_cycle_id + ).first() + + 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, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + ) + + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + cycle_id=cycle_id, + ) + + # Get the assignee distribution + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_issues=Count( + "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": item["avatar"], + "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.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "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") + ) + + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) if item["assignee_id"] else None + ), + "avatar": item["avatar"], + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + + # Label distribution serilization + 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 + ] + + current_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ).first() + + current_cycle.progress_snapshot = { + "total_issues": old_cycle.first().total_issues, + "completed_issues": old_cycle.first().completed_issues, + "cancelled_issues": old_cycle.first().cancelled_issues, + "started_issues": old_cycle.first().started_issues, + "unstarted_issues": old_cycle.first().unstarted_issues, + "backlog_issues": old_cycle.first().backlog_issues, + "distribution": { + "labels": label_distribution_data, + "assignees": assignee_distribution_data, + "completion_chart": completion_chart, + }, + } + current_cycle.save(update_fields=["progress_snapshot"]) + + if ( + new_cycle.end_date is not None + and new_cycle.end_date < timezone.now().date() + ): + return Response( + { + "error": "The cycle where the issues are transferred is already completed" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + cycle_issues = CycleIssue.objects.filter( + cycle_id=cycle_id, + project_id=project_id, + workspace__slug=slug, + issue__state__group__in=["backlog", "unstarted", "started"], + ) + + updated_cycles = [] + for cycle_issue in cycle_issues: + cycle_issue.cycle_id = new_cycle_id + updated_cycles.append(cycle_issue) + + cycle_issues = CycleIssue.objects.bulk_update( + updated_cycles, ["cycle_id"], batch_size=100 + ) + + return Response({"message": "Success"}, status=status.HTTP_200_OK) + + +class CycleUserPropertiesEndpoint(BaseAPIView): + permission_classes = [ + ProjectLitePermission, + ] + + 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.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) + + 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) diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py new file mode 100644 index 000000000..fdc998f6d --- /dev/null +++ b/apiserver/plane/app/views/cycle/issue.py @@ -0,0 +1,320 @@ +# Python imports +import json + +# Django imports +from django.db.models import ( + Func, + F, + Q, + OuterRef, + Value, + UUIDField, +) +from django.core import serializers +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page +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 BaseViewSet +from plane.app.serializers import ( + IssueSerializer, + CycleIssueSerializer, +) +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + Cycle, + CycleIssue, + Issue, + IssueLink, + IssueAttachment, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter + +class CycleIssueViewSet(BaseViewSet): + serializer_class = CycleIssueSerializer + model = CycleIssue + + webhook_event = "cycle_issue" + bulk = True + + permission_classes = [ + ProjectEntityPermission, + ] + + filterset_fields = [ + "issue__labels__id", + "issue__assignees__id", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("issue_id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + 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() + ) + + @method_decorator(gzip_page) + def list(self, request, slug, project_id, cycle_id): + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] + order_by = request.GET.get("order_by", "created_at") + filters = issue_filters(request.query_params, "GET") + queryset = ( + Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .filter(**filters) + .select_related("workspace", "project", "state", "parent") + .prefetch_related( + "assignees", + "labels", + "issue_module__module", + "issue_cycle__cycle", + ) + .order_by(order_by) + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .order_by(order_by) + ) + if self.fields: + issues = IssueSerializer( + queryset, many=True, fields=fields if fields else None + ).data + else: + issues = queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + + return Response(issues, status=status.HTTP_200_OK) + + 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().date() + ): + return Response( + { + "error": "The Cycle has already been completed so no new issues can be added" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get all CycleIssues already created + cycle_issues = list( + CycleIssue.objects.filter( + ~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=request.META.get("HTTP_ORIGIN"), + ) + return Response({"message": "success"}, status=status.HTTP_201_CREATED) + + def destroy(self, request, slug, project_id, cycle_id, issue_id): + cycle_issue = CycleIssue.objects.get( + 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=request.META.get("HTTP_ORIGIN"), + ) + cycle_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py new file mode 100644 index 000000000..9558348d9 --- /dev/null +++ b/apiserver/plane/app/views/dashboard/base.py @@ -0,0 +1,772 @@ +# Django imports +from django.db.models import ( + Q, + Case, + When, + Value, + CharField, + Count, + F, + Exists, + OuterRef, + Subquery, + JSONField, + Func, + Prefetch, + IntegerField, +) +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import UUIDField +from django.db.models.functions import Coalesce +from django.utils import timezone + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseAPIView +from plane.db.models import ( + Issue, + IssueActivity, + ProjectMember, + Widget, + DashboardWidget, + Dashboard, + Project, + IssueLink, + IssueAttachment, + IssueRelation, + User, +) +from plane.app.serializers import ( + IssueActivitySerializer, + IssueSerializer, + DashboardSerializer, + WidgetSerializer, +) +from plane.utils.issue_filters import issue_filters + + +def dashboard_overview_stats(self, request, slug): + assigned_issues = Issue.issue_objects.filter( + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + workspace__slug=slug, + assignees__in=[request.user], + ).count() + + pending_issues_count = Issue.issue_objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + target_date__lt=timezone.now().date(), + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + workspace__slug=slug, + assignees__in=[request.user], + ).count() + + created_issues_count = Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + created_by_id=request.user.id, + ).count() + + completed_issues_count = Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + assignees__in=[request.user], + state__group="completed", + ).count() + + return Response( + { + "assigned_issues_count": assigned_issues, + "pending_issues_count": pending_issues_count, + "completed_issues_count": completed_issues_count, + "created_issues_count": created_issues_count, + }, + status=status.HTTP_200_OK, + ) + + +def dashboard_assigned_issues(self, request, slug): + filters = issue_filters(request.query_params, "GET") + issue_type = request.GET.get("issue_type", None) + + # get all the assigned issues + assigned_issues = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + assignees__in=[request.user], + ) + .filter(**filters) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .prefetch_related( + Prefetch( + "issue_relation", + queryset=IssueRelation.objects.select_related( + "related_issue" + ).select_related("issue"), + ) + ) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ) + + # Priority Ordering + priority_order = ["urgent", "high", "medium", "low", "none"] + assigned_issues = assigned_issues.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + if issue_type == "pending": + pending_issues_count = assigned_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + ).count() + pending_issues = assigned_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + )[:5] + return Response( + { + "issues": IssueSerializer( + pending_issues, many=True, expand=self.expand + ).data, + "count": pending_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "completed": + completed_issues_count = assigned_issues.filter( + state__group__in=["completed"] + ).count() + completed_issues = assigned_issues.filter( + state__group__in=["completed"] + )[:5] + return Response( + { + "issues": IssueSerializer( + completed_issues, many=True, expand=self.expand + ).data, + "count": completed_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "overdue": + overdue_issues_count = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now(), + ).count() + overdue_issues = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now(), + )[:5] + return Response( + { + "issues": IssueSerializer( + overdue_issues, many=True, expand=self.expand + ).data, + "count": overdue_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "upcoming": + upcoming_issues_count = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now(), + ).count() + upcoming_issues = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now(), + )[:5] + return Response( + { + "issues": IssueSerializer( + upcoming_issues, many=True, expand=self.expand + ).data, + "count": upcoming_issues_count, + }, + status=status.HTTP_200_OK, + ) + + return Response( + {"error": "Please specify a valid issue type"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +def dashboard_created_issues(self, request, slug): + filters = issue_filters(request.query_params, "GET") + issue_type = request.GET.get("issue_type", None) + + # get all the assigned issues + created_issues = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + created_by=request.user, + ) + .filter(**filters) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .order_by("created_at") + ) + + # Priority Ordering + priority_order = ["urgent", "high", "medium", "low", "none"] + created_issues = created_issues.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + if issue_type == "pending": + pending_issues_count = created_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + ).count() + pending_issues = created_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + )[:5] + return Response( + { + "issues": IssueSerializer( + pending_issues, many=True, expand=self.expand + ).data, + "count": pending_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "completed": + completed_issues_count = created_issues.filter( + state__group__in=["completed"] + ).count() + completed_issues = created_issues.filter( + state__group__in=["completed"] + )[:5] + return Response( + { + "issues": IssueSerializer(completed_issues, many=True).data, + "count": completed_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "overdue": + overdue_issues_count = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now(), + ).count() + overdue_issues = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now(), + )[:5] + return Response( + { + "issues": IssueSerializer(overdue_issues, many=True).data, + "count": overdue_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "upcoming": + upcoming_issues_count = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now(), + ).count() + upcoming_issues = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now(), + )[:5] + return Response( + { + "issues": IssueSerializer(upcoming_issues, many=True).data, + "count": upcoming_issues_count, + }, + status=status.HTTP_200_OK, + ) + + return Response( + {"error": "Please specify a valid issue type"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +def dashboard_issues_by_state_groups(self, request, slug): + filters = issue_filters(request.query_params, "GET") + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + issues_by_state_groups = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + assignees__in=[request.user], + ) + .filter(**filters) + .values("state__group") + .annotate(count=Count("id")) + ) + + # default state + all_groups = {state: 0 for state in state_order} + + # Update counts for existing groups + for entry in issues_by_state_groups: + all_groups[entry["state__group"]] = entry["count"] + + # Prepare output including all groups with their counts + output_data = [ + {"state": group, "count": count} for group, count in all_groups.items() + ] + + return Response(output_data, status=status.HTTP_200_OK) + + +def dashboard_issues_by_priority(self, request, slug): + filters = issue_filters(request.query_params, "GET") + priority_order = ["urgent", "high", "medium", "low", "none"] + + issues_by_priority = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + assignees__in=[request.user], + ) + .filter(**filters) + .values("priority") + .annotate(count=Count("id")) + ) + + # default priority + all_groups = {priority: 0 for priority in priority_order} + + # Update counts for existing groups + for entry in issues_by_priority: + all_groups[entry["priority"]] = entry["count"] + + # Prepare output including all groups with their counts + output_data = [ + {"priority": group, "count": count} + for group, count in all_groups.items() + ] + + return Response(output_data, status=status.HTTP_200_OK) + + +def dashboard_recent_activity(self, request, slug): + 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=request.user, + ).select_related("actor", "workspace", "issue", "project")[:8] + + return Response( + IssueActivitySerializer(queryset, many=True).data, + status=status.HTTP_200_OK, + ) + + +def dashboard_recent_projects(self, request, slug): + project_ids = ( + IssueActivity.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + actor=request.user, + ) + .values_list("project_id", flat=True) + .distinct() + ) + + # Extract project IDs from the recent projects + unique_project_ids = set(project_id for project_id in project_ids) + + # Fetch additional projects only if needed + if len(unique_project_ids) < 4: + additional_projects = Project.objects.filter( + project_projectmember__member=request.user, + project_projectmember__is_active=True, + archived_at__isnull=True, + workspace__slug=slug, + ).exclude(id__in=unique_project_ids) + + # Append additional project IDs to the existing list + unique_project_ids.update( + additional_projects.values_list("id", flat=True) + ) + + return Response( + list(unique_project_ids)[:4], + status=status.HTTP_200_OK, + ) + + +def dashboard_recent_collaborators(self, request, slug): + # Subquery to count activities for each project member + activity_count_subquery = ( + IssueActivity.objects.filter( + workspace__slug=slug, + actor=OuterRef("member"), + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .values("actor") + .annotate(num_activities=Count("pk")) + .values("num_activities") + ) + + # Get all project members and annotate them with activity counts + project_members_with_activities = ( + ProjectMember.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .annotate( + num_activities=Coalesce( + Subquery(activity_count_subquery), + Value(0), + output_field=IntegerField(), + ), + is_current_user=Case( + When(member=request.user, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ), + ) + .values_list("member", flat=True) + .order_by("is_current_user", "-num_activities") + .distinct() + ) + search = request.query_params.get("search", None) + if search: + project_members_with_activities = ( + project_members_with_activities.filter( + Q(member__display_name__icontains=search) + | Q(member__first_name__icontains=search) + | Q(member__last_name__icontains=search) + ) + ) + + return self.paginate( + request=request, + queryset=project_members_with_activities, + controller=lambda qs: self.get_results_controller(qs, slug), + ) + + +class DashboardEndpoint(BaseAPIView): + def get_results_controller(self, project_members_with_activities, slug): + user_active_issue_counts = ( + User.objects.filter( + id__in=project_members_with_activities, + ) + .annotate( + active_issue_count=Count( + Case( + When( + issue_assignee__issue__state__group__in=[ + "unstarted", + "started", + ], + issue_assignee__issue__workspace__slug=slug, + issue_assignee__issue__project__project_projectmember__is_active=True, + then=F("issue_assignee__issue__id"), + ), + output_field=IntegerField(), + ), + distinct=True, + ) + ) + .values("active_issue_count", user_id=F("id")) + ) + # Create a dictionary to store the active issue counts by user ID + active_issue_counts_dict = { + user["user_id"]: user["active_issue_count"] + for user in user_active_issue_counts + } + + # Preserve the sequence of project members with activities + paginated_results = [ + { + "user_id": member_id, + "active_issue_count": active_issue_counts_dict.get( + member_id, 0 + ), + } + for member_id in project_members_with_activities + ] + return paginated_results + + def create(self, request, slug): + serializer = DashboardSerializer(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 patch(self, request, slug, pk): + serializer = DashboardSerializer(data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, pk): + serializer = DashboardSerializer(data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_204_NO_CONTENT) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def get(self, request, slug, dashboard_id=None): + if not dashboard_id: + dashboard_type = request.GET.get("dashboard_type", None) + if dashboard_type == "home": + dashboard, created = Dashboard.objects.get_or_create( + type_identifier=dashboard_type, + owned_by=request.user, + is_default=True, + ) + + if created: + widgets_to_fetch = [ + "overview_stats", + "assigned_issues", + "created_issues", + "issues_by_state_groups", + "issues_by_priority", + "recent_activity", + "recent_projects", + "recent_collaborators", + ] + + updated_dashboard_widgets = [] + for widget_key in widgets_to_fetch: + widget = Widget.objects.filter( + key=widget_key + ).values_list("id", flat=True) + if widget: + updated_dashboard_widgets.append( + DashboardWidget( + widget_id=widget, + dashboard_id=dashboard.id, + ) + ) + + DashboardWidget.objects.bulk_create( + updated_dashboard_widgets, batch_size=100 + ) + + widgets = ( + Widget.objects.annotate( + is_visible=Exists( + DashboardWidget.objects.filter( + widget_id=OuterRef("pk"), + dashboard_id=dashboard.id, + is_visible=True, + ) + ) + ) + .annotate( + dashboard_filters=Subquery( + DashboardWidget.objects.filter( + widget_id=OuterRef("pk"), + dashboard_id=dashboard.id, + filters__isnull=False, + ) + .exclude(filters={}) + .values("filters")[:1] + ) + ) + .annotate( + widget_filters=Case( + When( + dashboard_filters__isnull=False, + then=F("dashboard_filters"), + ), + default=F("filters"), + output_field=JSONField(), + ) + ) + ) + return Response( + { + "dashboard": DashboardSerializer(dashboard).data, + "widgets": WidgetSerializer(widgets, many=True).data, + }, + status=status.HTTP_200_OK, + ) + return Response( + {"error": "Please specify a valid dashboard type"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + widget_key = request.GET.get("widget_key", "overview_stats") + + WIDGETS_MAPPER = { + "overview_stats": dashboard_overview_stats, + "assigned_issues": dashboard_assigned_issues, + "created_issues": dashboard_created_issues, + "issues_by_state_groups": dashboard_issues_by_state_groups, + "issues_by_priority": dashboard_issues_by_priority, + "recent_activity": dashboard_recent_activity, + "recent_projects": dashboard_recent_projects, + "recent_collaborators": dashboard_recent_collaborators, + } + + func = WIDGETS_MAPPER.get(widget_key) + if func is not None: + response = func( + self, + request=request, + slug=slug, + ) + if isinstance(response, Response): + return response + + return Response( + {"error": "Please specify a valid widget key"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class WidgetsEndpoint(BaseAPIView): + def patch(self, request, dashboard_id, widget_id): + dashboard_widget = DashboardWidget.objects.filter( + widget_id=widget_id, + dashboard_id=dashboard_id, + ).first() + dashboard_widget.is_visible = request.data.get( + "is_visible", dashboard_widget.is_visible + ) + dashboard_widget.sort_order = request.data.get( + "sort_order", dashboard_widget.sort_order + ) + dashboard_widget.filters = request.data.get( + "filters", dashboard_widget.filters + ) + dashboard_widget.save() + return Response( + {"message": "successfully updated"}, status=status.HTTP_200_OK + ) diff --git a/apiserver/plane/app/views/error_404.py b/apiserver/plane/app/views/error_404.py new file mode 100644 index 000000000..3c31474e0 --- /dev/null +++ b/apiserver/plane/app/views/error_404.py @@ -0,0 +1,5 @@ +# views.py +from django.http import JsonResponse + +def custom_404_view(request, exception=None): + return JsonResponse({"error": "Page not found."}, status=404) diff --git a/apiserver/plane/app/views/estimate.py b/apiserver/plane/app/views/estimate/base.py similarity index 74% rename from apiserver/plane/app/views/estimate.py rename to apiserver/plane/app/views/estimate/base.py index ec9393f5b..7ac3035a9 100644 --- a/apiserver/plane/app/views/estimate.py +++ b/apiserver/plane/app/views/estimate/base.py @@ -3,7 +3,7 @@ from rest_framework.response import Response from rest_framework import status # Module imports -from .base import BaseViewSet, BaseAPIView +from ..base import BaseViewSet, BaseAPIView from plane.app.permissions import ProjectEntityPermission from plane.db.models import Project, Estimate, EstimatePoint from plane.app.serializers import ( @@ -11,7 +11,7 @@ from plane.app.serializers import ( EstimatePointSerializer, EstimateReadSerializer, ) - +from plane.utils.cache import invalidate_cache class ProjectEstimatePointEndpoint(BaseAPIView): permission_classes = [ @@ -19,16 +19,16 @@ class ProjectEstimatePointEndpoint(BaseAPIView): ] 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) + 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): @@ -39,12 +39,17 @@ class BulkEstimatePointEndpoint(BaseViewSet): 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") + 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): if not request.data.get("estimate", False): return Response( @@ -54,13 +59,17 @@ class BulkEstimatePointEndpoint(BaseViewSet): estimate_points = request.data.get("estimate_points", []) - if not len(estimate_points) or len(estimate_points) > 8: + serializer = EstimatePointSerializer( + data=request.data.get("estimate_points"), many=True + ) + if not serializer.is_valid(): return Response( - {"error": "Estimate points are required"}, - status=status.HTTP_400_BAD_REQUEST, + serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - estimate_serializer = EstimateSerializer(data=request.data.get("estimate")) + estimate_serializer = EstimateSerializer( + data=request.data.get("estimate") + ) if not estimate_serializer.is_valid(): return Response( estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST @@ -106,6 +115,7 @@ class BulkEstimatePointEndpoint(BaseViewSet): 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 request.data.get("estimate", False): return Response( @@ -135,7 +145,8 @@ class BulkEstimatePointEndpoint(BaseViewSet): estimate_points = EstimatePoint.objects.filter( pk__in=[ - estimate_point.get("id") for estimate_point in estimate_points_data + estimate_point.get("id") + for estimate_point in estimate_points_data ], workspace__slug=slug, project_id=project_id, @@ -157,10 +168,14 @@ class BulkEstimatePointEndpoint(BaseViewSet): updated_estimate_points.append(estimate_point) EstimatePoint.objects.bulk_update( - updated_estimate_points, ["value"], batch_size=10, + updated_estimate_points, + ["value"], + batch_size=10, ) - estimate_point_serializer = EstimatePointSerializer(estimate_points, many=True) + estimate_point_serializer = EstimatePointSerializer( + estimate_points, many=True + ) return Response( { "estimate": estimate_serializer.data, @@ -169,6 +184,7 @@ class BulkEstimatePointEndpoint(BaseViewSet): 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 diff --git a/apiserver/plane/app/views/exporter.py b/apiserver/plane/app/views/exporter/base.py similarity index 85% rename from apiserver/plane/app/views/exporter.py rename to apiserver/plane/app/views/exporter/base.py index b709a599d..698d9eb99 100644 --- a/apiserver/plane/app/views/exporter.py +++ b/apiserver/plane/app/views/exporter/base.py @@ -3,7 +3,7 @@ from rest_framework.response import Response from rest_framework import status # Module imports -from . import BaseAPIView +from .. import BaseAPIView from plane.app.permissions import WorkSpaceAdminPermission from plane.bgtasks.export_task import issue_export_task from plane.db.models import Project, ExporterHistory, Workspace @@ -21,15 +21,18 @@ class ExportIssuesEndpoint(BaseAPIView): 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 + 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] @@ -50,7 +53,7 @@ class ExportIssuesEndpoint(BaseAPIView): ) return Response( { - "message": f"Once the export is ready you will be able to download it" + "message": "Once the export is ready you will be able to download it" }, status=status.HTTP_200_OK, ) @@ -63,9 +66,11 @@ class ExportIssuesEndpoint(BaseAPIView): def get(self, request, slug): exporter_history = ExporterHistory.objects.filter( workspace__slug=slug - ).select_related("workspace","initiated_by") + ).select_related("workspace", "initiated_by") - if request.GET.get("per_page", False) and request.GET.get("cursor", False): + if request.GET.get("per_page", False) and request.GET.get( + "cursor", False + ): return self.paginate( request=request, queryset=exporter_history, diff --git a/apiserver/plane/app/views/external.py b/apiserver/plane/app/views/external/base.py similarity index 86% rename from apiserver/plane/app/views/external.py rename to apiserver/plane/app/views/external/base.py index 97d509c1e..2d5d2c7aa 100644 --- a/apiserver/plane/app/views/external.py +++ b/apiserver/plane/app/views/external/base.py @@ -8,14 +8,15 @@ from rest_framework.response import Response from rest_framework import status # Django imports -from django.conf import settings # Module imports -from .base import BaseAPIView +from ..base import BaseAPIView from plane.app.permissions import ProjectEntityPermission from plane.db.models import Workspace, Project -from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer -from plane.utils.integrations.github import get_release_notes +from plane.app.serializers import ( + ProjectLiteSerializer, + WorkspaceLiteSerializer, +) from plane.license.utils.instance_value import get_configuration_value @@ -51,7 +52,8 @@ class GPTIntegrationEndpoint(BaseAPIView): if not task: return Response( - {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Task is required"}, + status=status.HTTP_400_BAD_REQUEST, ) final_text = task + "\n" + prompt @@ -81,15 +83,9 @@ class GPTIntegrationEndpoint(BaseAPIView): ) -class ReleaseNotesEndpoint(BaseAPIView): - def get(self, request): - release_notes = get_release_notes() - return Response(release_notes, status=status.HTTP_200_OK) - - class UnsplashEndpoint(BaseAPIView): def get(self, request): - UNSPLASH_ACCESS_KEY, = get_configuration_value( + (UNSPLASH_ACCESS_KEY,) = get_configuration_value( [ { "key": "UNSPLASH_ACCESS_KEY", diff --git a/apiserver/plane/app/views/importer.py b/apiserver/plane/app/views/importer.py deleted file mode 100644 index b99d663e2..000000000 --- a/apiserver/plane/app/views/importer.py +++ /dev/null @@ -1,525 +0,0 @@ -# Python imports -import uuid - -# Third party imports -from rest_framework import status -from rest_framework.response import Response - -# Django imports -from django.db.models import Max, Q - -# Module imports -from plane.app.views import BaseAPIView -from plane.db.models import ( - WorkspaceIntegration, - Importer, - APIToken, - Project, - State, - IssueSequence, - Issue, - IssueActivity, - IssueComment, - IssueLink, - IssueLabel, - Workspace, - IssueAssignee, - Module, - ModuleLink, - ModuleIssue, - Label, -) -from plane.app.serializers import ( - ImporterSerializer, - IssueFlatSerializer, - ModuleSerializer, -) -from plane.utils.integrations.github import get_github_repo_details -from plane.utils.importers.jira import jira_project_issue_summary -from plane.bgtasks.importer_task import service_importer -from plane.utils.html_processor import strip_tags -from plane.app.permissions import WorkSpaceAdminPermission - - -class ServiceIssueImportSummaryEndpoint(BaseAPIView): - - def get(self, request, slug, service): - if service == "github": - owner = request.GET.get("owner", False) - repo = request.GET.get("repo", False) - - if not owner or not repo: - return Response( - {"error": "Owner and repo are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - workspace_integration = WorkspaceIntegration.objects.get( - integration__provider="github", workspace__slug=slug - ) - - access_tokens_url = workspace_integration.metadata.get( - "access_tokens_url", False - ) - - if not access_tokens_url: - return Response( - { - "error": "There was an error during the installation of the GitHub app. To resolve this issue, we recommend reinstalling the GitHub app." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - issue_count, labels, collaborators = get_github_repo_details( - access_tokens_url, owner, repo - ) - return Response( - { - "issue_count": issue_count, - "labels": labels, - "collaborators": collaborators, - }, - status=status.HTTP_200_OK, - ) - - if service == "jira": - # Check for all the keys - params = { - "project_key": "Project key is required", - "api_token": "API token is required", - "email": "Email is required", - "cloud_hostname": "Cloud hostname is required", - } - - for key, error_message in params.items(): - if not request.GET.get(key, False): - return Response( - {"error": error_message}, status=status.HTTP_400_BAD_REQUEST - ) - - project_key = request.GET.get("project_key", "") - api_token = request.GET.get("api_token", "") - email = request.GET.get("email", "") - cloud_hostname = request.GET.get("cloud_hostname", "") - - response = jira_project_issue_summary( - email, api_token, project_key, cloud_hostname - ) - if "error" in response: - return Response(response, status=status.HTTP_400_BAD_REQUEST) - else: - return Response( - response, - status=status.HTTP_200_OK, - ) - return Response( - {"error": "Service not supported yet"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - -class ImportServiceEndpoint(BaseAPIView): - permission_classes = [ - WorkSpaceAdminPermission, - ] - def post(self, request, slug, service): - project_id = request.data.get("project_id", False) - - if not project_id: - return Response( - {"error": "Project ID is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - workspace = Workspace.objects.get(slug=slug) - - if service == "github": - data = request.data.get("data", False) - metadata = request.data.get("metadata", False) - config = request.data.get("config", False) - if not data or not metadata or not config: - return Response( - {"error": "Data, config and metadata are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - api_token = APIToken.objects.filter( - user=request.user, workspace=workspace - ).first() - if api_token is None: - api_token = APIToken.objects.create( - user=request.user, - label="Importer", - workspace=workspace, - ) - - importer = Importer.objects.create( - service=service, - project_id=project_id, - status="queued", - initiated_by=request.user, - data=data, - metadata=metadata, - token=api_token, - config=config, - created_by=request.user, - updated_by=request.user, - ) - - service_importer.delay(service, importer.id) - serializer = ImporterSerializer(importer) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - if service == "jira": - data = request.data.get("data", False) - metadata = request.data.get("metadata", False) - config = request.data.get("config", False) - if not data or not metadata: - return Response( - {"error": "Data, config and metadata are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - api_token = APIToken.objects.filter( - user=request.user, workspace=workspace - ).first() - if api_token is None: - api_token = APIToken.objects.create( - user=request.user, - label="Importer", - workspace=workspace, - ) - - importer = Importer.objects.create( - service=service, - project_id=project_id, - status="queued", - initiated_by=request.user, - data=data, - metadata=metadata, - token=api_token, - config=config, - created_by=request.user, - updated_by=request.user, - ) - - service_importer.delay(service, importer.id) - serializer = ImporterSerializer(importer) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response( - {"error": "Servivce not supported yet"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - def get(self, request, slug): - imports = ( - Importer.objects.filter(workspace__slug=slug) - .order_by("-created_at") - .select_related("initiated_by", "project", "workspace") - ) - serializer = ImporterSerializer(imports, many=True) - return Response(serializer.data) - - def delete(self, request, slug, service, pk): - importer = Importer.objects.get( - pk=pk, service=service, workspace__slug=slug - ) - - if importer.imported_data is not None: - # Delete all imported Issues - imported_issues = importer.imported_data.get("issues", []) - Issue.issue_objects.filter(id__in=imported_issues).delete() - - # Delete all imported Labels - imported_labels = importer.imported_data.get("labels", []) - Label.objects.filter(id__in=imported_labels).delete() - - if importer.service == "jira": - imported_modules = importer.imported_data.get("modules", []) - Module.objects.filter(id__in=imported_modules).delete() - importer.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - def patch(self, request, slug, service, pk): - importer = Importer.objects.get( - pk=pk, service=service, workspace__slug=slug - ) - serializer = ImporterSerializer(importer, 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 UpdateServiceImportStatusEndpoint(BaseAPIView): - def post(self, request, slug, project_id, service, importer_id): - importer = Importer.objects.get( - pk=importer_id, - workspace__slug=slug, - project_id=project_id, - service=service, - ) - importer.status = request.data.get("status", "processing") - importer.save() - return Response(status.HTTP_200_OK) - - -class BulkImportIssuesEndpoint(BaseAPIView): - def post(self, request, slug, project_id, service): - # Get the project - project = Project.objects.get(pk=project_id, workspace__slug=slug) - - # Get the default state - default_state = State.objects.filter( - ~Q(name="Triage"), project_id=project_id, default=True - ).first() - # if there is no default state assign any random state - if default_state is None: - default_state = State.objects.filter( - ~Q(name="Triage"), project_id=project_id - ).first() - - # Get the maximum sequence_id - last_id = IssueSequence.objects.filter(project_id=project_id).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_id=project_id, state=default_state - ).aggregate(largest=Max("sort_order"))["largest"] - - largest_sort_order = ( - 65535 if largest_sort_order is None else largest_sort_order + 10000 - ) - - # Get the issues_data - issues_data = request.data.get("issues_data", []) - - if not len(issues_data): - return Response( - {"error": "Issue data is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Issues - bulk_issues = [] - for issue_data in issues_data: - bulk_issues.append( - Issue( - project_id=project_id, - workspace_id=project.workspace_id, - state_id=issue_data.get("state") - if issue_data.get("state", False) - else default_state.id, - name=issue_data.get("name", "Issue Created through Bulk"), - description_html=issue_data.get("description_html", "

    "), - description_stripped=( - None - if ( - issue_data.get("description_html") == "" - or issue_data.get("description_html") is None - ) - else strip_tags(issue_data.get("description_html")) - ), - sequence_id=last_id, - sort_order=largest_sort_order, - start_date=issue_data.get("start_date", None), - target_date=issue_data.get("target_date", None), - priority=issue_data.get("priority", "none"), - created_by=request.user, - ) - ) - - largest_sort_order = largest_sort_order + 10000 - last_id = last_id + 1 - - issues = Issue.objects.bulk_create( - bulk_issues, - batch_size=100, - ignore_conflicts=True, - ) - - # Sequences - _ = IssueSequence.objects.bulk_create( - [ - IssueSequence( - issue=issue, - sequence=issue.sequence_id, - project_id=project_id, - workspace_id=project.workspace_id, - ) - for issue in issues - ], - batch_size=100, - ) - - # Attach Labels - bulk_issue_labels = [] - for issue, issue_data in zip(issues, issues_data): - labels_list = issue_data.get("labels_list", []) - bulk_issue_labels = bulk_issue_labels + [ - IssueLabel( - issue=issue, - label_id=label_id, - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - ) - for label_id in labels_list - ] - - _ = IssueLabel.objects.bulk_create( - bulk_issue_labels, batch_size=100, ignore_conflicts=True - ) - - # Attach Assignees - bulk_issue_assignees = [] - for issue, issue_data in zip(issues, issues_data): - assignees_list = issue_data.get("assignees_list", []) - bulk_issue_assignees = bulk_issue_assignees + [ - IssueAssignee( - issue=issue, - assignee_id=assignee_id, - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - ) - for assignee_id in assignees_list - ] - - _ = IssueAssignee.objects.bulk_create( - bulk_issue_assignees, batch_size=100, ignore_conflicts=True - ) - - # Track the issue activities - IssueActivity.objects.bulk_create( - [ - IssueActivity( - issue=issue, - actor=request.user, - project_id=project_id, - workspace_id=project.workspace_id, - comment=f"imported the issue from {service}", - verb="created", - created_by=request.user, - ) - for issue in issues - ], - batch_size=100, - ) - - # Create Comments - bulk_issue_comments = [] - for issue, issue_data in zip(issues, issues_data): - comments_list = issue_data.get("comments_list", []) - bulk_issue_comments = bulk_issue_comments + [ - IssueComment( - issue=issue, - comment_html=comment.get("comment_html", "

    "), - actor=request.user, - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - ) - for comment in comments_list - ] - - _ = IssueComment.objects.bulk_create(bulk_issue_comments, batch_size=100) - - # Attach Links - _ = IssueLink.objects.bulk_create( - [ - IssueLink( - issue=issue, - url=issue_data.get("link", {}).get("url", "https://github.com"), - title=issue_data.get("link", {}).get("title", "Original Issue"), - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - ) - for issue, issue_data in zip(issues, issues_data) - ] - ) - - return Response( - {"issues": IssueFlatSerializer(issues, many=True).data}, - status=status.HTTP_201_CREATED, - ) - - -class BulkImportModulesEndpoint(BaseAPIView): - def post(self, request, slug, project_id, service): - modules_data = request.data.get("modules_data", []) - project = Project.objects.get(pk=project_id, workspace__slug=slug) - - modules = Module.objects.bulk_create( - [ - Module( - name=module.get("name", uuid.uuid4().hex), - description=module.get("description", ""), - start_date=module.get("start_date", None), - target_date=module.get("target_date", None), - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - ) - for module in modules_data - ], - batch_size=100, - ignore_conflicts=True, - ) - - modules = Module.objects.filter(id__in=[module.id for module in modules]) - - if len(modules) == len(modules_data): - _ = ModuleLink.objects.bulk_create( - [ - ModuleLink( - module=module, - url=module_data.get("link", {}).get( - "url", "https://plane.so" - ), - title=module_data.get("link", {}).get( - "title", "Original Issue" - ), - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - ) - for module, module_data in zip(modules, modules_data) - ], - batch_size=100, - ignore_conflicts=True, - ) - - bulk_module_issues = [] - for module, module_data in zip(modules, modules_data): - module_issues_list = module_data.get("module_issues_list", []) - bulk_module_issues = bulk_module_issues + [ - ModuleIssue( - issue_id=issue, - module=module, - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - ) - for issue in module_issues_list - ] - - _ = ModuleIssue.objects.bulk_create( - bulk_module_issues, batch_size=100, ignore_conflicts=True - ) - - serializer = ModuleSerializer(modules, many=True) - return Response( - {"modules": serializer.data}, status=status.HTTP_201_CREATED - ) - - else: - return Response( - {"message": "Modules created but issues could not be imported"}, - status=status.HTTP_200_OK, - ) diff --git a/apiserver/plane/app/views/inbox.py b/apiserver/plane/app/views/inbox.py deleted file mode 100644 index 331ee2175..000000000 --- a/apiserver/plane/app/views/inbox.py +++ /dev/null @@ -1,358 +0,0 @@ -# Python imports -import json - -# Django import -from django.utils import timezone -from django.db.models import Q, Count, OuterRef, Func, F, Prefetch -from django.core.serializers.json import DjangoJSONEncoder - -# Third party imports -from rest_framework import status -from rest_framework.response import Response - -# Module imports -from .base import BaseViewSet -from plane.app.permissions import ProjectBasePermission, ProjectLitePermission -from plane.db.models import ( - Inbox, - InboxIssue, - Issue, - State, - IssueLink, - IssueAttachment, - ProjectMember, -) -from plane.app.serializers import ( - IssueSerializer, - InboxSerializer, - InboxIssueSerializer, - IssueCreateSerializer, - IssueStateInboxSerializer, -) -from plane.utils.issue_filters import issue_filters -from plane.bgtasks.issue_activites_task import issue_activity - - -class InboxViewSet(BaseViewSet): - permission_classes = [ - ProjectBasePermission, - ] - - serializer_class = InboxSerializer - model = Inbox - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - .annotate( - pending_issue_count=Count( - "issue_inbox", - filter=Q(issue_inbox__status=-2), - ) - ) - .select_related("workspace", "project") - ) - - def perform_create(self, serializer): - serializer.save(project_id=self.kwargs.get("project_id")) - - def destroy(self, request, slug, project_id, pk): - inbox = Inbox.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - # Handle default inbox delete - if inbox.is_default: - return Response( - {"error": "You cannot delete the default inbox"}, - status=status.HTTP_400_BAD_REQUEST, - ) - inbox.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class InboxIssueViewSet(BaseViewSet): - permission_classes = [ - ProjectLitePermission, - ] - - serializer_class = InboxIssueSerializer - model = InboxIssue - - filterset_fields = [ - "status", - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - inbox_id=self.kwargs.get("inbox_id"), - ) - .select_related("issue", "workspace", "project") - ) - - def list(self, request, slug, project_id, inbox_id): - filters = issue_filters(request.query_params, "GET") - issues = ( - Issue.objects.filter( - issue_inbox__inbox_id=inbox_id, - workspace__slug=slug, - project_id=project_id, - ) - .filter(**filters) - .annotate(bridge_id=F("issue_inbox__id")) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels") - .order_by("issue_inbox__snoozed_till", "issue_inbox__status") - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .prefetch_related( - Prefetch( - "issue_inbox", - queryset=InboxIssue.objects.only( - "status", "duplicate_to", "snoozed_till", "source" - ), - ) - ) - ) - issues_data = IssueStateInboxSerializer(issues, many=True).data - return Response( - issues_data, - status=status.HTTP_200_OK, - ) - - def create(self, request, slug, project_id, inbox_id): - if not request.data.get("issue", {}).get("name", False): - return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - # Check for valid priority - if not request.data.get("issue", {}).get("priority", "none") in [ - "low", - "medium", - "high", - "urgent", - "none", - ]: - return Response( - {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST - ) - - # Create or get state - state, _ = State.objects.get_or_create( - name="Triage", - group="backlog", - description="Default state for managing all Inbox Issues", - project_id=project_id, - color="#ff7700", - ) - - # create an issue - issue = Issue.objects.create( - name=request.data.get("issue", {}).get("name"), - description=request.data.get("issue", {}).get("description", {}), - description_html=request.data.get("issue", {}).get( - "description_html", "

    " - ), - priority=request.data.get("issue", {}).get("priority", "low"), - project_id=project_id, - state=state, - ) - - # Create an Issue Activity - issue_activity.delay( - type="issue.activity.created", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(issue.id), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - # create an inbox issue - InboxIssue.objects.create( - inbox_id=inbox_id, - project_id=project_id, - issue=issue, - source=request.data.get("source", "in-app"), - ) - - serializer = IssueStateInboxSerializer(issue) - return Response(serializer.data, status=status.HTTP_200_OK) - - def partial_update(self, request, slug, project_id, inbox_id, pk): - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id - ) - # Get the project member - project_member = ProjectMember.objects.get( - workspace__slug=slug, - project_id=project_id, - member=request.user, - is_active=True, - ) - # Only project members admins and created_by users can access this endpoint - if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( - request.user.id - ): - return Response( - {"error": "You cannot edit inbox issues"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get issue data - issue_data = request.data.pop("issue", False) - - if bool(issue_data): - issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id - ) - # Only allow guests and viewers to edit name and description - if project_member.role <= 10: - # viewers and guests since only viewers and guests - issue_data = { - "name": issue_data.get("name", issue.name), - "description_html": issue_data.get( - "description_html", issue.description_html - ), - "description": issue_data.get("description", issue.description), - } - - issue_serializer = IssueCreateSerializer( - issue, data=issue_data, partial=True - ) - - if issue_serializer.is_valid(): - current_instance = issue - # Log all the updates - requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder) - if issue is not None: - issue_activity.delay( - type="issue.activity.updated", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(issue.id), - project_id=str(project_id), - current_instance=json.dumps( - IssueSerializer(current_instance).data, - cls=DjangoJSONEncoder, - ), - epoch=int(timezone.now().timestamp()), - ) - issue_serializer.save() - else: - return Response( - issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) - - # Only project admins and members can edit inbox issue attributes - if project_member.role > 10: - serializer = InboxIssueSerializer( - inbox_issue, data=request.data, partial=True - ) - - if serializer.is_valid(): - serializer.save() - # Update the issue state if the issue is rejected or marked as duplicate - if serializer.data["status"] in [-1, 2]: - issue = Issue.objects.get( - pk=inbox_issue.issue_id, - workspace__slug=slug, - project_id=project_id, - ) - state = State.objects.filter( - group="cancelled", workspace__slug=slug, project_id=project_id - ).first() - if state is not None: - issue.state = state - issue.save() - - # Update the issue state if it is accepted - if serializer.data["status"] in [1]: - issue = Issue.objects.get( - pk=inbox_issue.issue_id, - workspace__slug=slug, - project_id=project_id, - ) - - # Update the issue state only if it is in triage state - if issue.state.name == "Triage": - # Move to default state - state = State.objects.filter( - workspace__slug=slug, project_id=project_id, default=True - ).first() - if state is not None: - issue.state = state - issue.save() - - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - return Response( - InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK - ) - - def retrieve(self, request, slug, project_id, inbox_id, pk): - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id - ) - issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id - ) - serializer = IssueStateInboxSerializer(issue) - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request, slug, project_id, inbox_id, pk): - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id - ) - # Get the project member - project_member = ProjectMember.objects.get( - workspace__slug=slug, - project_id=project_id, - member=request.user, - is_active=True, - ) - - if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( - request.user.id - ): - return Response( - {"error": "You cannot delete inbox issue"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check the issue status - if inbox_issue.status in [-2, -1, 0, 2]: - # Delete the issue also - Issue.objects.filter( - workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id - ).delete() - - inbox_issue.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py new file mode 100644 index 000000000..7919899fa --- /dev/null +++ b/apiserver/plane/app/views/inbox/base.py @@ -0,0 +1,580 @@ +# Python imports +import json + +# Django import +from django.utils import timezone +from django.db.models import Q, Count, OuterRef, Func, F, Prefetch +from django.core.serializers.json import DjangoJSONEncoder +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 ProjectBasePermission, ProjectLitePermission +from plane.db.models import ( + Inbox, + InboxIssue, + Issue, + State, + IssueLink, + IssueAttachment, + Project, + ProjectMember, +) +from plane.app.serializers import ( + IssueCreateSerializer, + IssueSerializer, + InboxSerializer, + InboxIssueSerializer, + InboxIssueDetailSerializer, +) +from plane.utils.issue_filters import issue_filters +from plane.bgtasks.issue_activites_task import issue_activity + + +class InboxViewSet(BaseViewSet): + permission_classes = [ + ProjectBasePermission, + ] + + serializer_class = InboxSerializer + model = Inbox + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + .annotate( + pending_issue_count=Count( + "issue_inbox", + filter=Q(issue_inbox__status=-2), + ) + ) + .select_related("workspace", "project") + ) + + def list(self, request, slug, project_id): + inbox = self.get_queryset().first() + return Response( + InboxSerializer(inbox).data, + status=status.HTTP_200_OK, + ) + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def destroy(self, request, slug, project_id, pk): + inbox = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id, pk=pk + ).first() + # Handle default inbox delete + if inbox.is_default: + return Response( + {"error": "You cannot delete the default inbox"}, + status=status.HTTP_400_BAD_REQUEST, + ) + inbox.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class InboxIssueViewSet(BaseViewSet): + permission_classes = [ + ProjectLitePermission, + ] + + serializer_class = InboxIssueSerializer + model = InboxIssue + + filterset_fields = [ + "status", + ] + + def get_queryset(self): + return ( + 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_inbox", + queryset=InboxIssue.objects.only( + "status", "duplicate_to", "snoozed_till", "source" + ), + ) + ) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ).distinct() + + def list(self, request, slug, project_id): + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + filters = issue_filters(request.GET, "GET", "issue__") + inbox_issue = ( + InboxIssue.objects.filter( + inbox_id=inbox_id.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(issue__labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + ).order_by(request.GET.get("order_by", "-issue__created_at")) + # inbox status filter + inbox_status = [ + item + for item in request.GET.get("status", "-2").split(",") + if item != "null" + ] + if inbox_status: + inbox_issue = inbox_issue.filter(status__in=inbox_status) + + return self.paginate( + request=request, + queryset=(inbox_issue), + on_results=lambda inbox_issues: InboxIssueSerializer( + inbox_issues, + many=True, + ).data, + ) + + 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 or get state + state, _ = State.objects.get_or_create( + name="Triage", + group="triage", + description="Default state for managing all Inbox Issues", + project_id=project_id, + color="#ff7700", + is_triage=True, + ) + + # create an issue + project = Project.objects.get(pk=project_id) + serializer = IssueCreateSerializer( + 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() + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + # create an inbox issue + inbox_issue = InboxIssue.objects.create( + inbox_id=inbox_id.id, + project_id=project_id, + issue_id=serializer.data["id"], + source=request.data.get("source", "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=request.META.get("HTTP_ORIGIN"), + inbox=str(inbox_issue.id), + ) + inbox_issue = ( + InboxIssue.objects.select_related("issue") + .prefetch_related( + "issue__labels", + "issue__assignees", + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "issue__labels__id", + distinct=True, + filter=~Q(issue__labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "issue__assignees__id", + distinct=True, + filter=~Q(issue__assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .get( + inbox_id=inbox_id.id, + issue_id=serializer.data["id"], + project_id=project_id, + ) + ) + serializer = InboxIssueDetailSerializer(inbox_issue) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + def partial_update(self, request, slug, project_id, issue_id): + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + inbox_issue = InboxIssue.objects.get( + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox_id, + ) + # Get the project member + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) + # Only project members admins and created_by users can access this endpoint + if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( + request.user.id + ): + return Response( + {"error": "You cannot edit inbox issues"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get issue data + issue_data = request.data.pop("issue", False) + if bool(issue_data): + issue = Issue.objects.annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ).get( + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, + ) + # Only allow guests and viewers to edit name and description + if project_member.role <= 10: + # viewers and guests since only viewers and guests + issue_data = { + "name": issue_data.get("name", issue.name), + "description_html": issue_data.get( + "description_html", issue.description_html + ), + "description": issue_data.get( + "description", issue.description + ), + } + + issue_serializer = IssueCreateSerializer( + issue, data=issue_data, partial=True + ) + + if issue_serializer.is_valid(): + current_instance = issue + # Log all the updates + requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder) + if issue is not None: + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + inbox=str(inbox_issue.id), + ) + issue_serializer.save() + else: + return Response( + issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + # Only project admins and members can edit inbox issue attributes + if project_member.role > 10: + serializer = InboxIssueSerializer( + inbox_issue, data=request.data, partial=True + ) + current_instance = json.dumps( + InboxIssueSerializer(inbox_issue).data, cls=DjangoJSONEncoder + ) + if serializer.is_valid(): + serializer.save() + # Update the issue state if the issue is rejected or marked as duplicate + if serializer.data["status"] in [-1, 2]: + issue = Issue.objects.get( + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, + ) + state = State.objects.filter( + group="cancelled", + workspace__slug=slug, + project_id=project_id, + ).first() + if state is not None: + issue.state = state + issue.save() + + # Update the issue state if it is accepted + if serializer.data["status"] in [1]: + issue = Issue.objects.get( + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, + ) + + # Update the issue state only if it is in triage state + if issue.state.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="inbox.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=request.META.get("HTTP_ORIGIN"), + inbox=(inbox_issue.id), + ) + + inbox_issue = ( + InboxIssue.objects.select_related("issue") + .prefetch_related( + "issue__labels", + "issue__assignees", + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "issue__labels__id", + distinct=True, + filter=~Q(issue__labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "issue__assignees__id", + distinct=True, + filter=~Q(issue__assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .get( + inbox_id=inbox_id.id, + issue_id=issue_id, + project_id=project_id, + ) + ) + serializer = InboxIssueDetailSerializer(inbox_issue).data + return Response(serializer, status=status.HTTP_200_OK) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + else: + serializer = InboxIssueDetailSerializer(inbox_issue).data + return Response(serializer, status=status.HTTP_200_OK) + + def retrieve(self, request, slug, project_id, issue_id): + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + inbox_issue = ( + InboxIssue.objects.select_related("issue") + .prefetch_related( + "issue__labels", + "issue__assignees", + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "issue__labels__id", + distinct=True, + filter=~Q(issue__labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "issue__assignees__id", + distinct=True, + filter=~Q(issue__assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .get( + inbox_id=inbox_id.id, issue_id=issue_id, project_id=project_id + ) + ) + issue = InboxIssueDetailSerializer(inbox_issue).data + return Response( + issue, + status=status.HTTP_200_OK, + ) + + def destroy(self, request, slug, project_id, issue_id): + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + inbox_issue = InboxIssue.objects.get( + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox_id, + ) + # Get the project member + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) + + if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( + request.user.id + ): + return Response( + {"error": "You cannot delete inbox issue"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check the issue status + if inbox_issue.status in [-2, -1, 0, 2]: + # Delete the issue also + Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk=issue_id + ).delete() + + inbox_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/integration/__init__.py b/apiserver/plane/app/views/integration/__init__.py deleted file mode 100644 index ea20d96ea..000000000 --- a/apiserver/plane/app/views/integration/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .base import IntegrationViewSet, WorkspaceIntegrationViewSet -from .github import ( - GithubRepositorySyncViewSet, - GithubIssueSyncViewSet, - BulkCreateGithubIssueSyncEndpoint, - GithubCommentSyncViewSet, - GithubRepositoriesEndpoint, -) -from .slack import SlackProjectSyncViewSet diff --git a/apiserver/plane/app/views/integration/base.py b/apiserver/plane/app/views/integration/base.py deleted file mode 100644 index b82957dfb..000000000 --- a/apiserver/plane/app/views/integration/base.py +++ /dev/null @@ -1,171 +0,0 @@ -# Python improts -import uuid -import requests -# Django imports -from django.contrib.auth.hashers import make_password - -# Third party imports -from rest_framework.response import Response -from rest_framework import status -from sentry_sdk import capture_exception - -# Module imports -from plane.app.views import BaseViewSet -from plane.db.models import ( - Integration, - WorkspaceIntegration, - Workspace, - User, - WorkspaceMember, - APIToken, -) -from plane.app.serializers import IntegrationSerializer, WorkspaceIntegrationSerializer -from plane.utils.integrations.github import ( - get_github_metadata, - delete_github_installation, -) -from plane.app.permissions import WorkSpaceAdminPermission -from plane.utils.integrations.slack import slack_oauth - -class IntegrationViewSet(BaseViewSet): - serializer_class = IntegrationSerializer - model = Integration - - def create(self, request): - serializer = IntegrationSerializer(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 partial_update(self, request, pk): - integration = Integration.objects.get(pk=pk) - if integration.verified: - return Response( - {"error": "Verified integrations cannot be updated"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = IntegrationSerializer( - integration, data=request.data, partial=True - ) - - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, pk): - integration = Integration.objects.get(pk=pk) - if integration.verified: - return Response( - {"error": "Verified integrations cannot be updated"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - integration.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class WorkspaceIntegrationViewSet(BaseViewSet): - serializer_class = WorkspaceIntegrationSerializer - model = WorkspaceIntegration - - permission_classes = [ - WorkSpaceAdminPermission, - ] - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("integration") - ) - - def create(self, request, slug, provider): - workspace = Workspace.objects.get(slug=slug) - integration = Integration.objects.get(provider=provider) - config = {} - if provider == "github": - installation_id = request.data.get("installation_id", None) - if not installation_id: - return Response( - {"error": "Installation ID is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - metadata = get_github_metadata(installation_id) - config = {"installation_id": installation_id} - - if provider == "slack": - code = request.data.get("code", False) - - if not code: - return Response({"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST) - - slack_response = slack_oauth(code=code) - - metadata = slack_response - access_token = metadata.get("access_token", False) - team_id = metadata.get("team", {}).get("id", False) - if not metadata or not access_token or not team_id: - return Response( - {"error": "Slack could not be installed. Please try again later"}, - status=status.HTTP_400_BAD_REQUEST, - ) - config = {"team_id": team_id, "access_token": access_token} - - # Create a bot user - bot_user = User.objects.create( - email=f"{uuid.uuid4().hex}@plane.so", - username=uuid.uuid4().hex, - password=make_password(uuid.uuid4().hex), - is_password_autoset=True, - is_bot=True, - first_name=integration.title, - avatar=integration.avatar_url - if integration.avatar_url is not None - else "", - ) - - # Create an API Token for the bot user - api_token = APIToken.objects.create( - user=bot_user, - user_type=1, # bot user - workspace=workspace, - ) - - workspace_integration = WorkspaceIntegration.objects.create( - workspace=workspace, - integration=integration, - actor=bot_user, - api_token=api_token, - metadata=metadata, - config=config, - ) - - # Add bot user as a member of workspace - _ = WorkspaceMember.objects.create( - workspace=workspace_integration.workspace, - member=bot_user, - role=20, - ) - return Response( - WorkspaceIntegrationSerializer(workspace_integration).data, - status=status.HTTP_201_CREATED, - ) - - def destroy(self, request, slug, pk): - workspace_integration = WorkspaceIntegration.objects.get( - pk=pk, workspace__slug=slug - ) - - if workspace_integration.integration.provider == "github": - installation_id = workspace_integration.config.get( - "installation_id", False - ) - if installation_id: - delete_github_installation(installation_id=installation_id) - - workspace_integration.delete() - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/integration/github.py b/apiserver/plane/app/views/integration/github.py deleted file mode 100644 index 29b7a9b2f..000000000 --- a/apiserver/plane/app/views/integration/github.py +++ /dev/null @@ -1,200 +0,0 @@ -# Third party imports -from rest_framework import status -from rest_framework.response import Response -from sentry_sdk import capture_exception - -# Module imports -from plane.app.views import BaseViewSet, BaseAPIView -from plane.db.models import ( - GithubIssueSync, - GithubRepositorySync, - GithubRepository, - WorkspaceIntegration, - ProjectMember, - Label, - GithubCommentSync, - Project, -) -from plane.app.serializers import ( - GithubIssueSyncSerializer, - GithubRepositorySyncSerializer, - GithubCommentSyncSerializer, -) -from plane.utils.integrations.github import get_github_repos -from plane.app.permissions import ProjectBasePermission, ProjectEntityPermission - - -class GithubRepositoriesEndpoint(BaseAPIView): - permission_classes = [ - ProjectBasePermission, - ] - - def get(self, request, slug, workspace_integration_id): - page = request.GET.get("page", 1) - workspace_integration = WorkspaceIntegration.objects.get( - workspace__slug=slug, pk=workspace_integration_id - ) - - if workspace_integration.integration.provider != "github": - return Response( - {"error": "Not a github integration"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - access_tokens_url = workspace_integration.metadata["access_tokens_url"] - repositories_url = ( - workspace_integration.metadata["repositories_url"] - + f"?per_page=100&page={page}" - ) - repositories = get_github_repos(access_tokens_url, repositories_url) - return Response(repositories, status=status.HTTP_200_OK) - - -class GithubRepositorySyncViewSet(BaseViewSet): - permission_classes = [ - ProjectBasePermission, - ] - - serializer_class = GithubRepositorySyncSerializer - model = GithubRepositorySync - - def perform_create(self, serializer): - serializer.save(project_id=self.kwargs.get("project_id")) - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - ) - - def create(self, request, slug, project_id, workspace_integration_id): - name = request.data.get("name", False) - url = request.data.get("url", False) - config = request.data.get("config", {}) - repository_id = request.data.get("repository_id", False) - owner = request.data.get("owner", False) - - if not name or not url or not repository_id or not owner: - return Response( - {"error": "Name, url, repository_id and owner are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get the workspace integration - workspace_integration = WorkspaceIntegration.objects.get( - pk=workspace_integration_id - ) - - # Delete the old repository object - GithubRepositorySync.objects.filter( - project_id=project_id, workspace__slug=slug - ).delete() - GithubRepository.objects.filter( - project_id=project_id, workspace__slug=slug - ).delete() - - # Create repository - repo = GithubRepository.objects.create( - name=name, - url=url, - config=config, - repository_id=repository_id, - owner=owner, - project_id=project_id, - ) - - # Create a Label for github - label = Label.objects.filter( - name="GitHub", - project_id=project_id, - ).first() - - if label is None: - label = Label.objects.create( - name="GitHub", - project_id=project_id, - description="Label to sync Plane issues with GitHub issues", - color="#003773", - ) - - # Create repo sync - repo_sync = GithubRepositorySync.objects.create( - repository=repo, - workspace_integration=workspace_integration, - actor=workspace_integration.actor, - credentials=request.data.get("credentials", {}), - project_id=project_id, - label=label, - ) - - # Add bot as a member in the project - _ = ProjectMember.objects.get_or_create( - member=workspace_integration.actor, role=20, project_id=project_id - ) - - # Return Response - return Response( - GithubRepositorySyncSerializer(repo_sync).data, - status=status.HTTP_201_CREATED, - ) - - -class GithubIssueSyncViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - - serializer_class = GithubIssueSyncSerializer - model = GithubIssueSync - - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - repository_sync_id=self.kwargs.get("repo_sync_id"), - ) - - -class BulkCreateGithubIssueSyncEndpoint(BaseAPIView): - def post(self, request, slug, project_id, repo_sync_id): - project = Project.objects.get(pk=project_id, workspace__slug=slug) - - github_issue_syncs = request.data.get("github_issue_syncs", []) - github_issue_syncs = GithubIssueSync.objects.bulk_create( - [ - GithubIssueSync( - issue_id=github_issue_sync.get("issue"), - repo_issue_id=github_issue_sync.get("repo_issue_id"), - issue_url=github_issue_sync.get("issue_url"), - github_issue_id=github_issue_sync.get("github_issue_id"), - repository_sync_id=repo_sync_id, - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - updated_by=request.user, - ) - for github_issue_sync in github_issue_syncs - ], - batch_size=100, - ignore_conflicts=True, - ) - - serializer = GithubIssueSyncSerializer(github_issue_syncs, many=True) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - -class GithubCommentSyncViewSet(BaseViewSet): - - permission_classes = [ - ProjectEntityPermission, - ] - - serializer_class = GithubCommentSyncSerializer - model = GithubCommentSync - - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - issue_sync_id=self.kwargs.get("issue_sync_id"), - ) diff --git a/apiserver/plane/app/views/integration/slack.py b/apiserver/plane/app/views/integration/slack.py deleted file mode 100644 index 3f18a2ab2..000000000 --- a/apiserver/plane/app/views/integration/slack.py +++ /dev/null @@ -1,79 +0,0 @@ -# Django import -from django.db import IntegrityError - -# Third party imports -from rest_framework import status -from rest_framework.response import Response -from sentry_sdk import capture_exception - -# Module imports -from plane.app.views import BaseViewSet, BaseAPIView -from plane.db.models import SlackProjectSync, WorkspaceIntegration, ProjectMember -from plane.app.serializers import SlackProjectSyncSerializer -from plane.app.permissions import ProjectBasePermission, ProjectEntityPermission -from plane.utils.integrations.slack import slack_oauth - - -class SlackProjectSyncViewSet(BaseViewSet): - permission_classes = [ - ProjectBasePermission, - ] - serializer_class = SlackProjectSyncSerializer - model = SlackProjectSync - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - .filter(project__project_projectmember__member=self.request.user) - ) - - def create(self, request, slug, project_id, workspace_integration_id): - try: - code = request.data.get("code", False) - - if not code: - return Response( - {"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - slack_response = slack_oauth(code=code) - - workspace_integration = WorkspaceIntegration.objects.get( - workspace__slug=slug, pk=workspace_integration_id - ) - - workspace_integration = WorkspaceIntegration.objects.get( - pk=workspace_integration_id, workspace__slug=slug - ) - slack_project_sync = SlackProjectSync.objects.create( - access_token=slack_response.get("access_token"), - scopes=slack_response.get("scope"), - bot_user_id=slack_response.get("bot_user_id"), - webhook_url=slack_response.get("incoming_webhook", {}).get("url"), - data=slack_response, - team_id=slack_response.get("team", {}).get("id"), - team_name=slack_response.get("team", {}).get("name"), - workspace_integration=workspace_integration, - project_id=project_id, - ) - _ = ProjectMember.objects.get_or_create( - member=workspace_integration.actor, role=20, project_id=project_id - ) - serializer = SlackProjectSyncSerializer(slack_project_sync) - return Response(serializer.data, status=status.HTTP_200_OK) - except IntegrityError as e: - if "already exists" in str(e): - return Response( - {"error": "Slack is already installed for the project"}, - status=status.HTTP_410_GONE, - ) - capture_exception(e) - return Response( - {"error": "Slack could not be installed. Please try again later"}, - status=status.HTTP_400_BAD_REQUEST, - ) diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py deleted file mode 100644 index d489629ba..000000000 --- a/apiserver/plane/app/views/issue.py +++ /dev/null @@ -1,1629 +0,0 @@ -# Python imports -import json -import random -from itertools import chain - -# Django imports -from django.db import models -from django.utils import timezone -from django.db.models import ( - Prefetch, - OuterRef, - Func, - F, - Q, - Count, - Case, - Value, - CharField, - When, - Exists, - Max, - IntegerField, -) -from django.core.serializers.json import DjangoJSONEncoder -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page -from django.db import IntegrityError - -# Third Party imports -from rest_framework.response import Response -from rest_framework import status -from rest_framework.parsers import MultiPartParser, FormParser - -# Module imports -from . import BaseViewSet, BaseAPIView, WebhookMixin -from plane.app.serializers import ( - IssueCreateSerializer, - IssueActivitySerializer, - IssueCommentSerializer, - IssuePropertySerializer, - IssueSerializer, - LabelSerializer, - IssueFlatSerializer, - IssueLinkSerializer, - IssueLiteSerializer, - IssueAttachmentSerializer, - IssueSubscriberSerializer, - ProjectMemberLiteSerializer, - IssueReactionSerializer, - CommentReactionSerializer, - IssueVoteSerializer, - IssueRelationSerializer, - RelatedIssueSerializer, - IssuePublicSerializer, -) -from plane.app.permissions import ( - ProjectEntityPermission, - WorkSpaceAdminPermission, - ProjectMemberPermission, - ProjectLitePermission, -) -from plane.db.models import ( - Project, - Issue, - IssueActivity, - IssueComment, - IssueProperty, - Label, - IssueLink, - IssueAttachment, - State, - IssueSubscriber, - ProjectMember, - IssueReaction, - CommentReaction, - ProjectDeployBoard, - IssueVote, - IssueRelation, - ProjectPublicMember, -) -from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results -from plane.utils.issue_filters import issue_filters - - -class IssueViewSet(WebhookMixin, BaseViewSet): - def get_serializer_class(self): - return ( - IssueCreateSerializer - if self.action in ["create", "update", "partial_update"] - else IssueSerializer - ) - - model = Issue - webhook_event = "issue" - permission_classes = [ - ProjectEntityPermission, - ] - - search_fields = [ - "name", - ] - - filterset_fields = [ - "state__name", - "assignees__id", - "workspace__id", - ] - - def get_queryset(self): - return ( - Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - ).distinct() - - @method_decorator(gzip_page) - def list(self, request, slug, project_id): - fields = [field for field in request.GET.get("fields", "").split(",") if field] - filters = issue_filters(request.query_params, "GET") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - self.get_queryset() - .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) - - def create(self, request, slug, project_id): - project = Project.objects.get(pk=project_id) - - serializer = IssueCreateSerializer( - data=request.data, - context={ - "project_id": project_id, - "workspace_id": project.workspace_id, - "default_assignee_id": project.default_assignee_id, - }, - ) - - if serializer.is_valid(): - serializer.save() - - # Track the issue - issue_activity.delay( - type="issue.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(serializer.data.get("id", None)), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def retrieve(self, request, slug, project_id, pk=None): - issue = Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ).get(workspace__slug=slug, project_id=project_id, pk=pk) - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) - - def partial_update(self, request, slug, project_id, pk=None): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) - requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - serializer = IssueCreateSerializer(issue, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - issue_activity.delay( - type="issue.activity.updated", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, pk=None): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) - issue.delete() - issue_activity.delay( - type="issue.activity.deleted", - requested_data=json.dumps({"issue_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class UserWorkSpaceIssues(BaseAPIView): - @method_decorator(gzip_page) - def get(self, request, slug): - filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - Issue.issue_objects.filter( - ( - Q(assignees__in=[request.user]) - | Q(created_by=request.user) - | Q(issue_subscribers__subscriber=request.user) - ), - workspace__slug=slug, - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .order_by(order_by_param) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - .filter(**filters) - ).distinct() - - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssueLiteSerializer(issue_queryset, many=True).data - - ## Grouping the results - group_by = request.GET.get("group_by", False) - sub_group_by = request.GET.get("sub_group_by", False) - if sub_group_by and sub_group_by == group_by: - return Response( - {"error": "Group by and sub group by cannot be same"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if group_by: - grouped_results = group_results(issues, group_by, sub_group_by) - return Response( - grouped_results, - status=status.HTTP_200_OK, - ) - - return Response(issues, status=status.HTTP_200_OK) - - -class WorkSpaceIssuesEndpoint(BaseAPIView): - permission_classes = [ - WorkSpaceAdminPermission, - ] - - @method_decorator(gzip_page) - def get(self, request, slug): - issues = ( - Issue.issue_objects.filter(workspace__slug=slug) - .filter(project__project_projectmember__member=self.request.user) - .order_by("-created_at") - ) - serializer = IssueSerializer(issues, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class IssueActivityEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] - - @method_decorator(gzip_page) - def get(self, request, slug, project_id, issue_id): - issue_activities = ( - IssueActivity.objects.filter(issue_id=issue_id) - .filter( - ~Q(field__in=["comment", "vote", "reaction", "draft"]), - project__project_projectmember__member=self.request.user, - ) - .select_related("actor", "workspace", "issue", "project") - ).order_by("created_at") - issue_comments = ( - IssueComment.objects.filter(issue_id=issue_id) - .filter(project__project_projectmember__member=self.request.user) - .order_by("created_at") - .select_related("actor", "issue", "project", "workspace") - .prefetch_related( - Prefetch( - "comment_reactions", - queryset=CommentReaction.objects.select_related("actor"), - ) - ) - ) - issue_activities = IssueActivitySerializer(issue_activities, many=True).data - issue_comments = IssueCommentSerializer(issue_comments, many=True).data - - result_list = sorted( - chain(issue_activities, issue_comments), - key=lambda instance: instance["created_at"], - ) - - return Response(result_list, status=status.HTTP_200_OK) - - -class IssueCommentViewSet(WebhookMixin, BaseViewSet): - serializer_class = IssueCommentSerializer - model = IssueComment - webhook_event = "issue_comment" - permission_classes = [ - ProjectLitePermission, - ] - - filterset_fields = [ - "issue__id", - "workspace__id", - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("issue") - .annotate( - is_member=Exists( - ProjectMember.objects.filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - member_id=self.request.user.id, - is_active=True, - ) - ) - ) - .distinct() - ) - - def create(self, request, slug, project_id, issue_id): - serializer = IssueCommentSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_id, - issue_id=issue_id, - actor=request.user, - ) - issue_activity.delay( - type="comment.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id")), - project_id=str(self.kwargs.get("project_id")), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def partial_update(self, request, slug, project_id, issue_id, pk): - issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, - cls=DjangoJSONEncoder, - ) - serializer = IssueCommentSerializer( - issue_comment, data=request.data, partial=True - ) - if serializer.is_valid(): - serializer.save() - issue_activity.delay( - type="comment.activity.updated", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, issue_id, pk): - issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, - cls=DjangoJSONEncoder, - ) - issue_comment.delete() - issue_activity.delay( - type="comment.activity.deleted", - requested_data=json.dumps({"comment_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueUserDisplayPropertyEndpoint(BaseAPIView): - permission_classes = [ - ProjectLitePermission, - ] - - def post(self, request, slug, project_id): - issue_property, created = IssueProperty.objects.get_or_create( - user=request.user, - project_id=project_id, - ) - - if not created: - issue_property.properties = request.data.get("properties", {}) - issue_property.save() - issue_property.properties = request.data.get("properties", {}) - issue_property.save() - serializer = IssuePropertySerializer(issue_property) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def get(self, request, slug, project_id): - issue_property, _ = IssueProperty.objects.get_or_create( - user=request.user, project_id=project_id - ) - serializer = IssuePropertySerializer(issue_property) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class LabelViewSet(BaseViewSet): - serializer_class = LabelSerializer - model = Label - permission_classes = [ - ProjectMemberPermission, - ] - - def create(self, request, slug, project_id): - try: - serializer = LabelSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - except IntegrityError: - return Response( - {"error": "Label with the same name already exists in the project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("parent") - .distinct() - .order_by("sort_order") - ) - - -class BulkDeleteIssuesEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] - - def delete(self, request, slug, project_id): - issue_ids = request.data.get("issue_ids", []) - - if not len(issue_ids): - return Response( - {"error": "Issue IDs are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - issues = Issue.issue_objects.filter( - workspace__slug=slug, project_id=project_id, pk__in=issue_ids - ) - - total_issues = len(issues) - - issues.delete() - - return Response( - {"message": f"{total_issues} issues were deleted"}, - status=status.HTTP_200_OK, - ) - - -class SubIssuesEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] - - @method_decorator(gzip_page) - def get(self, request, slug, project_id, issue_id): - sub_issues = ( - Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - ) - - state_distribution = ( - State.objects.filter(workspace__slug=slug, state_issue__parent_id=issue_id) - .annotate(state_group=F("group")) - .values("state_group") - .annotate(state_count=Count("state_group")) - .order_by("state_group") - ) - - result = { - item["state_group"]: item["state_count"] for item in state_distribution - } - - serializer = IssueLiteSerializer( - sub_issues, - many=True, - ) - return Response( - { - "sub_issues": serializer.data, - "state_distribution": result, - }, - status=status.HTTP_200_OK, - ) - - # Assign multiple sub issues - def post(self, request, slug, project_id, issue_id): - parent_issue = Issue.issue_objects.get(pk=issue_id) - sub_issue_ids = request.data.get("sub_issue_ids", []) - - if not len(sub_issue_ids): - return Response( - {"error": "Sub Issue IDs are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) - - for sub_issue in sub_issues: - sub_issue.parent = parent_issue - - _ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10) - - updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) - - # Track the issue - _ = [ - issue_activity.delay( - type="issue.activity.updated", - requested_data=json.dumps({"parent": str(issue_id)}), - actor_id=str(request.user.id), - issue_id=str(sub_issue_id), - project_id=str(project_id), - current_instance=json.dumps({"parent": str(sub_issue_id)}), - epoch=int(timezone.now().timestamp()), - ) - for sub_issue_id in sub_issue_ids - ] - - return Response( - IssueFlatSerializer(updated_sub_issues, many=True).data, - status=status.HTTP_200_OK, - ) - - -class IssueLinkViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - - model = IssueLink - serializer_class = IssueLinkSerializer - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) - .order_by("-created_at") - .distinct() - ) - - def create(self, request, slug, project_id, issue_id): - serializer = IssueLinkSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_id, - issue_id=issue_id, - ) - issue_activity.delay( - type="link.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id")), - project_id=str(self.kwargs.get("project_id")), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def partial_update(self, request, slug, project_id, issue_id, pk): - issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) - current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, - cls=DjangoJSONEncoder, - ) - serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - issue_activity.delay( - type="link.activity.updated", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, issue_id, pk): - issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, - cls=DjangoJSONEncoder, - ) - issue_activity.delay( - type="link.activity.deleted", - requested_data=json.dumps({"link_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - issue_link.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class BulkCreateIssueLabelsEndpoint(BaseAPIView): - def post(self, request, slug, project_id): - label_data = request.data.get("label_data", []) - project = Project.objects.get(pk=project_id) - - labels = Label.objects.bulk_create( - [ - Label( - name=label.get("name", "Migrated"), - description=label.get("description", "Migrated Issue"), - color="#" + "%06x" % random.randint(0, 0xFFFFFF), - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - updated_by=request.user, - ) - for label in label_data - ], - batch_size=50, - ignore_conflicts=True, - ) - - return Response( - {"labels": LabelSerializer(labels, many=True).data}, - status=status.HTTP_201_CREATED, - ) - - -class IssueAttachmentEndpoint(BaseAPIView): - serializer_class = IssueAttachmentSerializer - permission_classes = [ - ProjectEntityPermission, - ] - model = IssueAttachment - parser_classes = (MultiPartParser, FormParser) - - def post(self, request, slug, project_id, issue_id): - serializer = IssueAttachmentSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(project_id=project_id, issue_id=issue_id) - issue_activity.delay( - type="attachment.activity.created", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - serializer.data, - cls=DjangoJSONEncoder, - ), - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = IssueAttachment.objects.get(pk=pk) - issue_attachment.asset.delete(save=False) - issue_attachment.delete() - issue_activity.delay( - type="attachment.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - - return Response(status=status.HTTP_204_NO_CONTENT) - - def get(self, request, slug, project_id, issue_id): - issue_attachments = IssueAttachment.objects.filter( - issue_id=issue_id, workspace__slug=slug, project_id=project_id - ) - serializer = IssueAttachmentSerializer(issue_attachments, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class IssueArchiveViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - serializer_class = IssueFlatSerializer - model = Issue - - def get_queryset(self): - return ( - Issue.objects.annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(archived_at__isnull=False) - .filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - ) - - @method_decorator(gzip_page) - def list(self, request, slug, project_id): - fields = [field for field in request.GET.get("fields", "").split(",") if field] - filters = issue_filters(request.query_params, "GET") - show_sub_issues = request.GET.get("show_sub_issues", "true") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - self.get_queryset() - .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issue_queryset = ( - issue_queryset - if show_sub_issues == "true" - else issue_queryset.filter(parent__isnull=True) - ) - - issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) - - def retrieve(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, - project_id=project_id, - archived_at__isnull=False, - pk=pk, - ) - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) - - def unarchive(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, - project_id=project_id, - archived_at__isnull=False, - pk=pk, - ) - issue_activity.delay( - type="issue.activity.updated", - requested_data=json.dumps({"archived_at": None}), - actor_id=str(request.user.id), - issue_id=str(issue.id), - project_id=str(project_id), - current_instance=json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ), - epoch=int(timezone.now().timestamp()), - ) - issue.archived_at = None - issue.save() - - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) - - -class IssueSubscriberViewSet(BaseViewSet): - serializer_class = IssueSubscriberSerializer - model = IssueSubscriber - - permission_classes = [ - ProjectEntityPermission, - ] - - def get_permissions(self): - if self.action in ["subscribe", "unsubscribe", "subscription_status"]: - self.permission_classes = [ - ProjectLitePermission, - ] - else: - self.permission_classes = [ - ProjectEntityPermission, - ] - - return super(IssueSubscriberViewSet, self).get_permissions() - - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - issue_id=self.kwargs.get("issue_id"), - ) - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) - .order_by("-created_at") - .distinct() - ) - - def list(self, request, slug, project_id, issue_id): - members = ( - ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - is_active=True, - ) - .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - subscriber=OuterRef("member"), - ) - ) - ) - .select_related("member") - ) - serializer = ProjectMemberLiteSerializer(members, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request, slug, project_id, issue_id, subscriber_id): - issue_subscriber = IssueSubscriber.objects.get( - project=project_id, - subscriber=subscriber_id, - workspace__slug=slug, - issue=issue_id, - ) - issue_subscriber.delete() - return Response( - status=status.HTTP_204_NO_CONTENT, - ) - - def subscribe(self, request, slug, project_id, issue_id): - if IssueSubscriber.objects.filter( - issue_id=issue_id, - subscriber=request.user, - workspace__slug=slug, - project=project_id, - ).exists(): - return Response( - {"message": "User already subscribed to the issue."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - subscriber = IssueSubscriber.objects.create( - issue_id=issue_id, - subscriber_id=request.user.id, - project_id=project_id, - ) - serializer = IssueSubscriberSerializer(subscriber) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def unsubscribe(self, request, slug, project_id, issue_id): - issue_subscriber = IssueSubscriber.objects.get( - project=project_id, - subscriber=request.user, - workspace__slug=slug, - issue=issue_id, - ) - issue_subscriber.delete() - return Response( - status=status.HTTP_204_NO_CONTENT, - ) - - def subscription_status(self, request, slug, project_id, issue_id): - issue_subscriber = IssueSubscriber.objects.filter( - issue=issue_id, - subscriber=request.user, - workspace__slug=slug, - project=project_id, - ).exists() - return Response({"subscribed": issue_subscriber}, status=status.HTTP_200_OK) - - -class IssueReactionViewSet(BaseViewSet): - serializer_class = IssueReactionSerializer - model = IssueReaction - permission_classes = [ - ProjectLitePermission, - ] - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) - .order_by("-created_at") - .distinct() - ) - - def create(self, request, slug, project_id, issue_id): - serializer = IssueReactionSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - issue_id=issue_id, - project_id=project_id, - actor=request.user, - ) - issue_activity.delay( - type="issue_reaction.activity.created", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, issue_id, reaction_code): - issue_reaction = IssueReaction.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - reaction=reaction_code, - actor=request.user, - ) - issue_activity.delay( - type="issue_reaction.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "reaction": str(reaction_code), - "identifier": str(issue_reaction.id), - } - ), - epoch=int(timezone.now().timestamp()), - ) - issue_reaction.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class CommentReactionViewSet(BaseViewSet): - serializer_class = CommentReactionSerializer - model = CommentReaction - permission_classes = [ - ProjectLitePermission, - ] - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(comment_id=self.kwargs.get("comment_id")) - .filter(project__project_projectmember__member=self.request.user) - .order_by("-created_at") - .distinct() - ) - - def create(self, request, slug, project_id, comment_id): - serializer = CommentReactionSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_id, - actor_id=request.user.id, - comment_id=comment_id, - ) - issue_activity.delay( - type="comment_reaction.activity.created", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=None, - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, comment_id, reaction_code): - comment_reaction = CommentReaction.objects.get( - workspace__slug=slug, - project_id=project_id, - comment_id=comment_id, - reaction=reaction_code, - actor=request.user, - ) - issue_activity.delay( - type="comment_reaction.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "reaction": str(reaction_code), - "identifier": str(comment_reaction.id), - "comment_id": str(comment_id), - } - ), - epoch=int(timezone.now().timestamp()), - ) - comment_reaction.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueRelationViewSet(BaseViewSet): - serializer_class = IssueRelationSerializer - model = IssueRelation - permission_classes = [ - ProjectEntityPermission, - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("issue") - .distinct() - ) - - def create(self, request, slug, project_id, issue_id): - related_list = request.data.get("related_list", []) - relation = request.data.get("relation", None) - project = Project.objects.get(pk=project_id) - - issue_relation = IssueRelation.objects.bulk_create( - [ - IssueRelation( - issue_id=related_issue["issue"], - related_issue_id=related_issue["related_issue"], - relation_type=related_issue["relation_type"], - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - updated_by=request.user, - ) - for related_issue in related_list - ], - batch_size=10, - ignore_conflicts=True, - ) - - issue_activity.delay( - type="issue_relation.activity.created", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - - if relation == "blocking": - return Response( - RelatedIssueSerializer(issue_relation, many=True).data, - status=status.HTTP_201_CREATED, - ) - else: - return Response( - IssueRelationSerializer(issue_relation, many=True).data, - status=status.HTTP_201_CREATED, - ) - - def destroy(self, request, slug, project_id, issue_id, pk): - issue_relation = IssueRelation.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - current_instance = json.dumps( - IssueRelationSerializer(issue_relation).data, - cls=DjangoJSONEncoder, - ) - issue_relation.delete() - issue_activity.delay( - type="issue_relation.activity.deleted", - requested_data=json.dumps({"related_list": None}), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueDraftViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - serializer_class = IssueFlatSerializer - model = Issue - - def get_queryset(self): - return ( - Issue.objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(is_draft=True) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - ) - - @method_decorator(gzip_page) - def list(self, request, slug, project_id): - filters = issue_filters(request.query_params, "GET") - fields = [field for field in request.GET.get("fields", "").split(",") if field] - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - self.get_queryset() - .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) - - def create(self, request, slug, project_id): - project = Project.objects.get(pk=project_id) - - serializer = IssueCreateSerializer( - data=request.data, - context={ - "project_id": project_id, - "workspace_id": project.workspace_id, - "default_assignee_id": project.default_assignee_id, - }, - ) - - if serializer.is_valid(): - serializer.save(is_draft=True) - - # Track the issue - issue_activity.delay( - type="issue_draft.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(serializer.data.get("id", None)), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def partial_update(self, request, slug, project_id, pk): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - serializer = IssueSerializer(issue, data=request.data, partial=True) - - if serializer.is_valid(): - if request.data.get("is_draft") is not None and not request.data.get( - "is_draft" - ): - serializer.save(created_at=timezone.now(), updated_at=timezone.now()) - else: - serializer.save() - issue_activity.delay( - type="issue_draft.activity.updated", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - IssueSerializer(issue).data, - cls=DjangoJSONEncoder, - ), - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def retrieve(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True - ) - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) - - def destroy(self, request, slug, project_id, pk=None): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) - issue.delete() - issue_activity.delay( - type="issue_draft.activity.deleted", - requested_data=json.dumps({"issue_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/apiserver/plane/app/views/issue/activity.py b/apiserver/plane/app/views/issue/activity.py new file mode 100644 index 000000000..6815b254e --- /dev/null +++ b/apiserver/plane/app/views/issue/activity.py @@ -0,0 +1,87 @@ +# 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 +from plane.db.models import ( + IssueActivity, + IssueComment, + CommentReaction, +) + + +class IssueActivityEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + @method_decorator(gzip_page) + 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"), + ) + ) + ) + issue_activities = IssueActivitySerializer( + issue_activities, many=True + ).data + issue_comments = IssueCommentSerializer(issue_comments, many=True).data + + if request.GET.get("activity_type", None) == "issue-property": + return Response(issue_activities, status=status.HTTP_200_OK) + + if request.GET.get("activity_type", None) == "issue-comment": + 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/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py new file mode 100644 index 000000000..cc3a343d2 --- /dev/null +++ b/apiserver/plane/app/views/issue/archive.py @@ -0,0 +1,353 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.db.models import ( + Prefetch, + OuterRef, + Func, + F, + Q, + Case, + Value, + CharField, + When, + Exists, + Max, + UUIDField, +) +from django.core.serializers.json import DjangoJSONEncoder +from django.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 BaseViewSet +from plane.app.serializers import ( + IssueSerializer, + IssueFlatSerializer, + IssueDetailSerializer, +) +from plane.app.permissions import ( + ProjectEntityPermission, +) +from plane.db.models import ( + Issue, + IssueLink, + IssueAttachment, + IssueSubscriber, + IssueReaction, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter + +class IssueArchiveViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + serializer_class = IssueFlatSerializer + model = Issue + + def get_queryset(self): + return ( + Issue.objects.annotate( + sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(archived_at__isnull=False) + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ) + + @method_decorator(gzip_page) + def list(self, request, slug, project_id): + filters = issue_filters(request.query_params, "GET") + show_sub_issues = request.GET.get("show_sub_issues", "true") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = self.get_queryset().filter(**filters) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" + if order_by_param.startswith("-") + else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issue_queryset = ( + issue_queryset + if show_sub_issues == "true" + else issue_queryset.filter(parent__isnull=True) + ) + if self.expand or self.fields: + issues = IssueSerializer( + issue_queryset, + many=True, + fields=self.fields, + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + + return Response(issues, status=status.HTTP_200_OK) + + 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_attachment", + queryset=IssueAttachment.objects.select_related("issue"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=OuterRef("pk"), + subscriber=request.user, + ) + ) + ) + ).first() + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + def 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=request.META.get("HTTP_ORIGIN"), + ) + issue.archived_at = timezone.now().date() + issue.save() + + return Response( + {"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK + ) + + def unarchive(self, request, slug, project_id, pk=None): + issue = Issue.objects.get( + workspace__slug=slug, + project_id=project_id, + archived_at__isnull=False, + pk=pk, + ) + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"archived_at": None}), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + issue.archived_at = None + issue.save() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/attachment.py b/apiserver/plane/app/views/issue/attachment.py new file mode 100644 index 000000000..c2b8ad6ff --- /dev/null +++ b/apiserver/plane/app/views/issue/attachment.py @@ -0,0 +1,73 @@ +# 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 +from rest_framework.parsers import MultiPartParser, FormParser + +# Module imports +from .. import BaseAPIView +from plane.app.serializers import IssueAttachmentSerializer +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import IssueAttachment +from plane.bgtasks.issue_activites_task import issue_activity + + +class IssueAttachmentEndpoint(BaseAPIView): + serializer_class = IssueAttachmentSerializer + permission_classes = [ + ProjectEntityPermission, + ] + model = IssueAttachment + parser_classes = (MultiPartParser, FormParser) + + def post(self, request, slug, project_id, issue_id): + serializer = IssueAttachmentSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id, issue_id=issue_id) + issue_activity.delay( + type="attachment.activity.created", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + serializer.data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, issue_id, pk): + issue_attachment = IssueAttachment.objects.get(pk=pk) + issue_attachment.asset.delete(save=False) + issue_attachment.delete() + issue_activity.delay( + type="attachment.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def get(self, request, slug, project_id, issue_id): + issue_attachments = IssueAttachment.objects.filter( + issue_id=issue_id, workspace__slug=slug, project_id=project_id + ) + serializer = IssueAttachmentSerializer(issue_attachments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py new file mode 100644 index 000000000..fad85b79d --- /dev/null +++ b/apiserver/plane/app/views/issue/base.py @@ -0,0 +1,676 @@ +# Python imports +import json + +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + Case, + CharField, + Exists, + F, + Func, + Max, + OuterRef, + Prefetch, + Q, + UUIDField, + Value, + When, +) +from django.db.models.functions import Coalesce + +# Django imports +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page +from rest_framework import status + +# Third Party imports +from rest_framework.response import Response + +from plane.app.permissions import ( + ProjectEntityPermission, + ProjectLitePermission, +) +from plane.app.serializers import ( + IssueCreateSerializer, + IssueDetailSerializer, + IssuePropertySerializer, + IssueSerializer, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Issue, + IssueAttachment, + IssueLink, + IssueProperty, + IssueReaction, + IssueSubscriber, + Project, +) +from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter + +# Module imports +from .. import BaseAPIView, BaseViewSet + + +class IssueListEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + 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 != "" + ] + + queryset = ( + Issue.issue_objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ).distinct() + + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = queryset.filter(**filters) + + # 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) + + 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", + ) + 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): + def get_serializer_class(self): + return ( + IssueCreateSerializer + if self.action in ["create", "update", "partial_update"] + else IssueSerializer + ) + + model = Issue + webhook_event = "issue" + permission_classes = [ + ProjectEntityPermission, + ] + + search_fields = [ + "name", + ] + + filterset_fields = [ + "state__name", + "assignees__id", + "workspace__id", + ] + + def get_queryset(self): + return ( + Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ).distinct() + + @method_decorator(gzip_page) + def list(self, request, slug, project_id): + filters = issue_filters(request.query_params, "GET") + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = self.get_queryset().filter(**filters) + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # 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) + + # Only use serializer when expand or fields else return by values + if self.expand or self.fields: + issues = IssueSerializer( + issue_queryset, + many=True, + fields=self.fields, + expand=self.expand, + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + return Response(issues, status=status.HTTP_200_OK) + + def create(self, request, slug, project_id): + project = Project.objects.get(pk=project_id) + + 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=request.META.get("HTTP_ORIGIN"), + ) + issue = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + .first() + ) + datetime_fields = ["created_at", "updated_at"] + issue = user_timezone_converter( + issue, datetime_fields, request.user.user_timezone + ) + return Response(issue, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, slug, project_id, pk=None): + issue = ( + self.get_queryset() + .filter(pk=pk) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related( + "issue", "actor" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_attachment", + queryset=IssueAttachment.objects.select_related("issue"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=OuterRef("pk"), + subscriber=request.user, + ) + ) + ) + ).first() + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, slug, project_id, pk=None): + issue = self.get_queryset().filter(pk=pk).first() + + if not issue: + return Response( + {"error": "Issue not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + current_instance = json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ) + + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + serializer = IssueCreateSerializer( + issue, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + issue = self.get_queryset().filter(pk=pk).first() + return Response(status=status.HTTP_204_NO_CONTENT) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, pk=None): + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + 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={}, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueUserDisplayPropertyEndpoint(BaseAPIView): + permission_classes = [ + ProjectLitePermission, + ] + + def patch(self, request, slug, project_id): + issue_property = IssueProperty.objects.get( + user=request.user, + project_id=project_id, + ) + + 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 = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request, slug, project_id): + issue_property, _ = IssueProperty.objects.get_or_create( + user=request.user, project_id=project_id + ) + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class BulkDeleteIssuesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def delete(self, request, slug, project_id): + issue_ids = request.data.get("issue_ids", []) + + if not len(issue_ids): + return Response( + {"error": "Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issues = Issue.issue_objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ) + + total_issues = len(issues) + + issues.delete() + + return Response( + {"message": f"{total_issues} issues were deleted"}, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py new file mode 100644 index 000000000..1698efef8 --- /dev/null +++ b/apiserver/plane/app/views/issue/comment.py @@ -0,0 +1,221 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.db.models import Exists +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 ( + IssueCommentSerializer, + CommentReactionSerializer, +) +from plane.app.permissions import ProjectLitePermission +from plane.db.models import ( + IssueComment, + ProjectMember, + CommentReaction, +) +from plane.bgtasks.issue_activites_task import issue_activity + + +class IssueCommentViewSet(BaseViewSet): + serializer_class = IssueCommentSerializer + model = IssueComment + webhook_event = "issue_comment" + permission_classes = [ + ProjectLitePermission, + ] + + filterset_fields = [ + "issue__id", + "workspace__id", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + 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() + ) + + def create(self, request, slug, project_id, issue_id): + serializer = IssueCommentSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, + issue_id=issue_id, + actor=request.user, + ) + issue_activity.delay( + type="comment.activity.created", + requested_data=json.dumps( + serializer.data, cls=DjangoJSONEncoder + ), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id")), + project_id=str(self.kwargs.get("project_id")), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, slug, project_id, issue_id, pk): + issue_comment = IssueComment.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, + ) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + current_instance = json.dumps( + IssueCommentSerializer(issue_comment).data, + cls=DjangoJSONEncoder, + ) + serializer = IssueCommentSerializer( + issue_comment, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="comment.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, issue_id, pk): + issue_comment = IssueComment.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, + ) + current_instance = json.dumps( + IssueCommentSerializer(issue_comment).data, + cls=DjangoJSONEncoder, + ) + issue_comment.delete() + issue_activity.delay( + type="comment.activity.deleted", + requested_data=json.dumps({"comment_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CommentReactionViewSet(BaseViewSet): + serializer_class = CommentReactionSerializer + model = CommentReaction + permission_classes = [ + ProjectLitePermission, + ] + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(comment_id=self.kwargs.get("comment_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .order_by("-created_at") + .distinct() + ) + + def create(self, request, slug, project_id, comment_id): + serializer = CommentReactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, + actor_id=request.user.id, + comment_id=comment_id, + ) + issue_activity.delay( + type="comment_reaction.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=None, + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + 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=request.META.get("HTTP_ORIGIN"), + ) + comment_reaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py new file mode 100644 index 000000000..610c3c468 --- /dev/null +++ b/apiserver/plane/app/views/issue/draft.py @@ -0,0 +1,368 @@ +# Python imports +import json + +# Django imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + Case, + CharField, + Exists, + F, + Func, + Max, + OuterRef, + Prefetch, + Q, + UUIDField, + Value, + When, +) +from django.db.models.functions import Coalesce +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 ( + IssueCreateSerializer, + IssueDetailSerializer, + IssueFlatSerializer, + IssueSerializer, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Issue, + IssueAttachment, + IssueLink, + IssueReaction, + IssueSubscriber, + Project, +) +from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter + +# Module imports +from .. import BaseViewSet + + +class IssueDraftViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + serializer_class = IssueFlatSerializer + model = Issue + + def get_queryset(self): + return ( + Issue.objects.filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(is_draft=True) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ).distinct() + + @method_decorator(gzip_page) + def list(self, request, slug, project_id): + filters = issue_filters(request.query_params, "GET") + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = self.get_queryset().filter(**filters) + + # 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) + + # Only use serializer when expand else return by values + if self.expand or self.fields: + issues = IssueSerializer( + issue_queryset, + many=True, + fields=self.fields, + expand=self.expand, + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + return Response(issues, status=status.HTTP_200_OK) + + def create(self, request, slug, project_id): + project = Project.objects.get(pk=project_id) + + serializer = IssueCreateSerializer( + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, + ) + + if serializer.is_valid(): + serializer.save(is_draft=True) + + # Track the issue + issue_activity.delay( + type="issue_draft.activity.created", + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), + actor_id=str(request.user.id), + issue_id=str(serializer.data.get("id", None)), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + issue = ( + self.get_queryset().filter(pk=serializer.data["id"]).first() + ) + return Response( + IssueSerializer(issue).data, status=status.HTTP_201_CREATED + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, slug, project_id, pk): + issue = self.get_queryset().filter(pk=pk).first() + + if not issue: + return Response( + {"error": "Issue does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = IssueCreateSerializer( + issue, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="issue_draft.activity.updated", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("pk", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + IssueSerializer(issue).data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, slug, project_id, pk=None): + issue = ( + self.get_queryset() + .filter(pk=pk) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related( + "issue", "actor" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_attachment", + queryset=IssueAttachment.objects.select_related("issue"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=OuterRef("pk"), + subscriber=request.user, + ) + ) + ) + ).first() + + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, slug, project_id, pk=None): + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + issue.delete() + issue_activity.delay( + type="issue_draft.activity.deleted", + requested_data=json.dumps({"issue_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance={}, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/label.py b/apiserver/plane/app/views/issue/label.py new file mode 100644 index 000000000..c5dc35809 --- /dev/null +++ b/apiserver/plane/app/views/issue/label.py @@ -0,0 +1,105 @@ +# 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 ( + ProjectMemberPermission, +) +from plane.db.models import ( + Project, + Label, +) +from plane.utils.cache import invalidate_cache + + +class LabelViewSet(BaseViewSet): + serializer_class = LabelSerializer + model = Label + permission_classes = [ + ProjectMemberPermission, + ] + + 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 + ) + 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 + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @invalidate_cache( + path="/api/workspaces/:slug/labels/", url_params=True, user=False + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + +class BulkCreateIssueLabelsEndpoint(BaseAPIView): + def post(self, request, slug, project_id): + label_data = request.data.get("label_data", []) + project = Project.objects.get(pk=project_id) + + labels = Label.objects.bulk_create( + [ + Label( + name=label.get("name", "Migrated"), + description=label.get("description", "Migrated Issue"), + color=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/apiserver/plane/app/views/issue/link.py b/apiserver/plane/app/views/issue/link.py new file mode 100644 index 000000000..c965a7d4d --- /dev/null +++ b/apiserver/plane/app/views/issue/link.py @@ -0,0 +1,121 @@ +# 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_activites_task import issue_activity + + +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, + ) + 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=request.META.get("HTTP_ORIGIN"), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, slug, project_id, issue_id, pk): + issue_link = IssueLink.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, + ) + requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) + current_instance = json.dumps( + IssueLinkSerializer(issue_link).data, + cls=DjangoJSONEncoder, + ) + serializer = IssueLinkSerializer( + issue_link, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="link.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + 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=request.META.get("HTTP_ORIGIN"), + ) + issue_link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/reaction.py b/apiserver/plane/app/views/issue/reaction.py new file mode 100644 index 000000000..da8f6ebb5 --- /dev/null +++ b/apiserver/plane/app/views/issue/reaction.py @@ -0,0 +1,90 @@ +# 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 ProjectLitePermission +from plane.db.models import IssueReaction +from plane.bgtasks.issue_activites_task import issue_activity + + +class IssueReactionViewSet(BaseViewSet): + serializer_class = IssueReactionSerializer + model = IssueReaction + permission_classes = [ + ProjectLitePermission, + ] + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + 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 = 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=request.META.get("HTTP_ORIGIN"), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, issue_id, reaction_code): + issue_reaction = IssueReaction.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + reaction=reaction_code, + actor=request.user, + ) + issue_activity.delay( + type="issue_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(issue_reaction.id), + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + issue_reaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/relation.py b/apiserver/plane/app/views/issue/relation.py new file mode 100644 index 000000000..eb5aff9af --- /dev/null +++ b/apiserver/plane/app/views/issue/relation.py @@ -0,0 +1,205 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.db.models import Q +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 ( + IssueRelationSerializer, + RelatedIssueSerializer, +) +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + Project, + IssueRelation, +) +from plane.bgtasks.issue_activites_task import issue_activity + + +class IssueRelationViewSet(BaseViewSet): + serializer_class = IssueRelationSerializer + model = IssueRelation + permission_classes = [ + ProjectEntityPermission, + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .distinct() + ) + + 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() + ) + + blocking_issues = issue_relations.filter( + relation_type="blocked_by", related_issue_id=issue_id + ) + blocked_by_issues = issue_relations.filter( + relation_type="blocked_by", issue_id=issue_id + ) + duplicate_issues = issue_relations.filter( + issue_id=issue_id, relation_type="duplicate" + ) + duplicate_issues_related = issue_relations.filter( + related_issue_id=issue_id, relation_type="duplicate" + ) + relates_to_issues = issue_relations.filter( + issue_id=issue_id, relation_type="relates_to" + ) + relates_to_issues_related = issue_relations.filter( + related_issue_id=issue_id, relation_type="relates_to" + ) + + blocked_by_issues_serialized = IssueRelationSerializer( + blocked_by_issues, many=True + ).data + duplicate_issues_serialized = IssueRelationSerializer( + duplicate_issues, many=True + ).data + relates_to_issues_serialized = IssueRelationSerializer( + relates_to_issues, many=True + ).data + + # revere relation for blocked by issues + blocking_issues_serialized = RelatedIssueSerializer( + blocking_issues, many=True + ).data + # reverse relation for duplicate issues + duplicate_issues_related_serialized = RelatedIssueSerializer( + duplicate_issues_related, many=True + ).data + # reverse relation for related issues + relates_to_issues_related_serialized = RelatedIssueSerializer( + relates_to_issues_related, many=True + ).data + + response_data = { + "blocking": blocking_issues_serialized, + "blocked_by": blocked_by_issues_serialized, + "duplicate": duplicate_issues_serialized + + duplicate_issues_related_serialized, + "relates_to": relates_to_issues_serialized + + relates_to_issues_related_serialized, + } + + 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) + 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 == "blocking" else issue_id + ), + related_issue_id=( + issue_id if relation_type == "blocking" else issue + ), + relation_type=( + "blocked_by" + if relation_type == "blocking" + else relation_type + ), + 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=request.META.get("HTTP_ORIGIN"), + ) + + if relation_type == "blocking": + return Response( + RelatedIssueSerializer(issue_relation, many=True).data, + status=status.HTTP_201_CREATED, + ) + else: + return Response( + IssueRelationSerializer(issue_relation, many=True).data, + status=status.HTTP_201_CREATED, + ) + + def remove_relation(self, request, slug, project_id, issue_id): + relation_type = request.data.get("relation_type", None) + related_issue = request.data.get("related_issue", None) + + if relation_type == "blocking": + issue_relation = IssueRelation.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=related_issue, + related_issue_id=issue_id, + ) + else: + issue_relation = IssueRelation.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + related_issue_id=related_issue, + ) + current_instance = json.dumps( + IssueRelationSerializer(issue_relation).data, + cls=DjangoJSONEncoder, + ) + issue_relation.delete() + issue_activity.delay( + type="issue_relation.activity.deleted", + requested_data=json.dumps(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=request.META.get("HTTP_ORIGIN"), + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py new file mode 100644 index 000000000..2ee4574eb --- /dev/null +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -0,0 +1,201 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.db.models import ( + OuterRef, + Func, + F, + Q, + Value, + UUIDField, +) +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, + IssueAttachment, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.user_timezone_converter import user_timezone_converter +from collections import defaultdict + + +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=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .annotate(state_group=F("state__group")) + ) + + # 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 + ) + 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=request.META.get("HTTP_ORIGIN"), + ) + 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/apiserver/plane/app/views/issue/subscriber.py b/apiserver/plane/app/views/issue/subscriber.py new file mode 100644 index 000000000..dc727de28 --- /dev/null +++ b/apiserver/plane/app/views/issue/subscriber.py @@ -0,0 +1,125 @@ +# 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/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py deleted file mode 100644 index a8a8655c3..000000000 --- a/apiserver/plane/app/views/module.py +++ /dev/null @@ -1,524 +0,0 @@ -# Python imports -import json - -# Django Imports -from django.utils import timezone -from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q -from django.core import serializers -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page - -# Third party imports -from rest_framework.response import Response -from rest_framework import status - -# Module imports -from . import BaseViewSet, BaseAPIView, WebhookMixin -from plane.app.serializers import ( - ModuleWriteSerializer, - ModuleSerializer, - ModuleIssueSerializer, - ModuleLinkSerializer, - ModuleFavoriteSerializer, - IssueStateSerializer, -) -from plane.app.permissions import ProjectEntityPermission -from plane.db.models import ( - Module, - ModuleIssue, - Project, - Issue, - ModuleLink, - ModuleFavorite, - IssueLink, - IssueAttachment, -) -from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results -from plane.utils.issue_filters import issue_filters -from plane.utils.analytics_plot import burndown_plot - - -class ModuleViewSet(WebhookMixin, BaseViewSet): - model = Module - permission_classes = [ - ProjectEntityPermission, - ] - webhook_event = "module" - - def get_serializer_class(self): - return ( - ModuleWriteSerializer - if self.action in ["create", "update", "partial_update"] - else ModuleSerializer - ) - - def get_queryset(self): - - subquery = ModuleFavorite.objects.filter( - user=self.request.user, - module_id=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - ) - return ( - super() - .get_queryset() - .filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .annotate(is_favorite=Exists(subquery)) - .select_related("project") - .select_related("workspace") - .select_related("lead") - .prefetch_related("members") - .prefetch_related( - Prefetch( - "link_module", - queryset=ModuleLink.objects.select_related("module", "created_by"), - ) - ) - .annotate( - total_issues=Count( - "issue_module", - filter=Q( - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - ), - ) - .annotate( - completed_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="completed", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - ) - ) - .annotate( - cancelled_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="cancelled", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - ) - ) - .annotate( - started_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="started", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - ) - ) - .annotate( - unstarted_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="unstarted", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - ) - ) - .annotate( - backlog_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="backlog", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - ) - ) - .order_by("-is_favorite","-created_at") - ) - - def create(self, request, slug, project_id): - project = Project.objects.get(workspace__slug=slug, pk=project_id) - serializer = ModuleWriteSerializer( - data=request.data, context={"project": project} - ) - - if serializer.is_valid(): - serializer.save() - - module = Module.objects.get(pk=serializer.data["id"]) - serializer = ModuleSerializer(module) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().get(pk=pk) - - assignee_distribution = ( - Issue.objects.filter( - issue_module__module_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(first_name=F("assignees__first_name")) - .annotate(last_name=F("assignees__last_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(display_name=F("assignees__display_name")) - .annotate(avatar=F("assignees__avatar")) - .values("first_name", "last_name", "assignee_id", "avatar", "display_name") - .annotate( - total_issues=Count( - "assignee_id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - completed_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("first_name", "last_name") - ) - - label_distribution = ( - Issue.objects.filter( - issue_module__module_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "label_id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), - ) - .annotate( - completed_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - data = ModuleSerializer(queryset).data - data["distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - - if queryset.start_date and queryset.target_date: - data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset, slug=slug, project_id=project_id, module_id=pk - ) - - return Response( - data, - status=status.HTTP_200_OK, - ) - - def destroy(self, request, slug, project_id, pk): - module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - module_issues = list( - ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True) - ) - issue_activity.delay( - type="module.activity.deleted", - requested_data=json.dumps( - { - "module_id": str(pk), - "module_name": str(module.name), - "issues": [str(issue_id) for issue_id in module_issues], - } - ), - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - module.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ModuleIssueViewSet(WebhookMixin, BaseViewSet): - serializer_class = ModuleIssueSerializer - model = ModuleIssue - webhook_event = "module_issue" - bulk = True - - - filterset_fields = [ - "issue__labels__id", - "issue__assignees__id", - ] - - permission_classes = [ - ProjectEntityPermission, - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(module_id=self.kwargs.get("module_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("module") - .select_related("issue", "issue__state", "issue__project") - .prefetch_related("issue__assignees", "issue__labels") - .prefetch_related("module__members") - .distinct() - ) - - @method_decorator(gzip_page) - def list(self, request, slug, project_id, module_id): - fields = [field for field in request.GET.get("fields", "").split(",") if field] - order_by = request.GET.get("order_by", "created_at") - filters = issue_filters(request.query_params, "GET") - issues = ( - Issue.issue_objects.filter(issue_module__module_id=module_id) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate(bridge_id=F("issue_module__id")) - .filter(project_id=project_id) - .filter(workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .order_by(order_by) - .filter(**filters) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) - - def create(self, request, slug, project_id, module_id): - issues = request.data.get("issues", []) - if not len(issues): - return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST - ) - module = Module.objects.get( - workspace__slug=slug, project_id=project_id, pk=module_id - ) - - module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues)) - - update_module_issue_activity = [] - records_to_update = [] - record_to_create = [] - - for issue in issues: - module_issue = [ - module_issue - for module_issue in module_issues - if str(module_issue.issue_id) in issues - ] - - if len(module_issue): - if module_issue[0].module_id != module_id: - update_module_issue_activity.append( - { - "old_module_id": str(module_issue[0].module_id), - "new_module_id": str(module_id), - "issue_id": str(module_issue[0].issue_id), - } - ) - module_issue[0].module_id = module_id - records_to_update.append(module_issue[0]) - else: - record_to_create.append( - ModuleIssue( - module=module, - issue_id=issue, - project_id=project_id, - workspace=module.workspace, - created_by=request.user, - updated_by=request.user, - ) - ) - - ModuleIssue.objects.bulk_create( - record_to_create, - batch_size=10, - ignore_conflicts=True, - ) - - ModuleIssue.objects.bulk_update( - records_to_update, - ["module"], - batch_size=10, - ) - - # Capture Issue Activity - issue_activity.delay( - type="module.activity.created", - requested_data=json.dumps({"modules_list": issues}), - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "updated_module_issues": update_module_issue_activity, - "created_module_issues": serializers.serialize( - "json", record_to_create - ), - } - ), - epoch=int(timezone.now().timestamp()), - ) - - return Response( - ModuleIssueSerializer(self.get_queryset(), many=True).data, - status=status.HTTP_200_OK, - ) - - def destroy(self, request, slug, project_id, module_id, pk): - module_issue = ModuleIssue.objects.get( - workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk - ) - issue_activity.delay( - type="module.activity.deleted", - requested_data=json.dumps( - { - "module_id": str(module_id), - "issues": [str(module_issue.issue_id)], - } - ), - actor_id=str(request.user.id), - issue_id=str(module_issue.issue_id), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - module_issue.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ModuleLinkViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - - model = ModuleLink - serializer_class = ModuleLinkSerializer - - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - module_id=self.kwargs.get("module_id"), - ) - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(module_id=self.kwargs.get("module_id")) - .filter(project__project_projectmember__member=self.request.user) - .order_by("-created_at") - .distinct() - ) - - -class ModuleFavoriteViewSet(BaseViewSet): - serializer_class = ModuleFavoriteSerializer - model = ModuleFavorite - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(user=self.request.user) - .select_related("module") - ) - - def create(self, request, slug, project_id): - serializer = ModuleFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=request.user, project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, module_id): - module_favorite = ModuleFavorite.objects.get( - project=project_id, - user=request.user, - workspace__slug=slug, - module_id=module_id, - ) - module_favorite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py new file mode 100644 index 000000000..2cac5f366 --- /dev/null +++ b/apiserver/plane/app/views/module/archive.py @@ -0,0 +1,358 @@ +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, +) +from django.db.models.functions import Coalesce +from django.utils import timezone + +# 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 +from plane.utils.analytics_plot import burndown_plot +from plane.utils.user_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_identifier=OuterRef("pk"), + entity_type="module", + 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"), + ) + .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"), + ) + .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"), + ) + .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"), + ) + .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"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + total_issues = ( + Issue.issue_objects.filter( + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + return ( + Module.objects.filter(workspace__slug=self.kwargs.get("slug")) + .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( + 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, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + assignee_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(display_name=F("assignees__display_name")) + .annotate(avatar=F("assignees__avatar")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_issues=Count( + "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.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "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 = ModuleDetailSerializer(queryset.first()).data + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + # Fetch the modules + modules = queryset.first() + if modules and modules.start_date and modules.target_date: + data["distribution"]["completion_chart"] = burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + 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() + 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/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py new file mode 100644 index 000000000..56267554d --- /dev/null +++ b/apiserver/plane/app/views/module/base.py @@ -0,0 +1,627 @@ +# 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, +) +from django.db.models.functions import Coalesce +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, +) +from plane.app.serializers import ( + ModuleDetailSerializer, + ModuleLinkSerializer, + ModuleSerializer, + ModuleUserPropertiesSerializer, + ModuleWriteSerializer, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Issue, + Module, + UserFavorite, + ModuleIssue, + ModuleLink, + ModuleUserProperties, + Project, +) +from plane.utils.analytics_plot import burndown_plot +from plane.utils.user_timezone_converter import user_timezone_converter +from plane.bgtasks.webhook_task import model_activity +from .. import BaseAPIView, BaseViewSet + + +class ModuleViewSet(BaseViewSet): + model = Module + permission_classes = [ + ProjectEntityPermission, + ] + webhook_event = "module" + + def get_serializer_class(self): + return ( + ModuleWriteSerializer + if self.action in ["create", "update", "partial_update"] + else ModuleSerializer + ) + + def get_queryset(self): + 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"), + ) + .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"), + ) + .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"), + ) + .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"), + ) + .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"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + total_issues = ( + Issue.issue_objects.filter( + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + 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)) + .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( + 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( + 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 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", + "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=request.META.get("HTTP_ORIGIN"), + ) + 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) + + 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 + "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) + + 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, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + assignee_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(display_name=F("assignees__display_name")) + .annotate(avatar=F("assignees__avatar")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_issues=Count( + "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.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "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 = ModuleDetailSerializer(queryset.first()).data + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + # Fetch the modules + modules = queryset.first() + if modules and modules.start_date and modules.target_date: + data["distribution"]["completion_chart"] = burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + module_id=pk, + ) + + return Response( + data, + status=status.HTTP_200_OK, + ) + + def partial_update(self, request, slug, project_id, pk): + module = self.get_queryset().filter(pk=pk) + + if module.first().archived_at: + return Response( + {"error": "Archived module cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + current_instance = json.dumps( + ModuleSerializer(module.first()).data, cls=DjangoJSONEncoder + ) + serializer = ModuleWriteSerializer( + module.first(), data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + module = module.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", + "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=request.META.get("HTTP_ORIGIN"), + ) + + 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) + + 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=request.META.get("HTTP_ORIGIN"), + ) + for issue in module_issues + ] + module.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ModuleLinkViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + model = ModuleLink + serializer_class = ModuleLinkSerializer + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + module_id=self.kwargs.get("module_id"), + ) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(module_id=self.kwargs.get("module_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .order_by("-created_at") + .distinct() + ) + + +class ModuleFavoriteViewSet(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("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() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ModuleUserPropertiesEndpoint(BaseAPIView): + permission_classes = [ + ProjectLitePermission, + ] + + 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.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) + + 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/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py new file mode 100644 index 000000000..879ab7e47 --- /dev/null +++ b/apiserver/plane/app/views/module/issue.py @@ -0,0 +1,285 @@ +# Python imports +import json + +# Django Imports +from django.utils import timezone +from django.db.models import F, OuterRef, Func, Q +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseViewSet +from plane.app.serializers import ( + ModuleIssueSerializer, + IssueSerializer, +) +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + ModuleIssue, + Project, + Issue, + IssueLink, + IssueAttachment, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter + +class ModuleIssueViewSet(BaseViewSet): + serializer_class = ModuleIssueSerializer + model = ModuleIssue + webhook_event = "module_issue" + bulk = True + + filterset_fields = [ + "issue__labels__id", + "issue__assignees__id", + ] + + permission_classes = [ + ProjectEntityPermission, + ] + + 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"), + ) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ).distinct() + + @method_decorator(gzip_page) + def list(self, request, slug, project_id, module_id): + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] + filters = issue_filters(request.query_params, "GET") + issue_queryset = self.get_queryset().filter(**filters) + if self.fields or self.expand: + issues = IssueSerializer( + issue_queryset, many=True, fields=fields if fields else None + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + + return Response(issues, status=status.HTTP_200_OK) + + # 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=request.META.get("HTTP_ORIGIN"), + ) + for issue in issues + ] + return Response({"message": "success"}, status=status.HTTP_201_CREATED) + + # 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=request.META.get("HTTP_ORIGIN"), + ) + for module in modules + ] + + for module_id in removed_modules: + module_issue = ModuleIssue.objects.get( + 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.module.name} + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + module_issue.delete() + + return Response({"message": "success"}, status=status.HTTP_201_CREATED) + + def destroy(self, request, slug, project_id, module_id, issue_id): + module_issue = ModuleIssue.objects.get( + workspace__slug=slug, + project_id=project_id, + module_id=module_id, + 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.module.name} + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + module_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/notification.py b/apiserver/plane/app/views/notification/base.py similarity index 74% rename from apiserver/plane/app/views/notification.py rename to apiserver/plane/app/views/notification/base.py index 9494ea86c..8dae618db 100644 --- a/apiserver/plane/app/views/notification.py +++ b/apiserver/plane/app/views/notification/base.py @@ -1,5 +1,5 @@ # Django imports -from django.db.models import Q +from django.db.models import Q, OuterRef, Exists from django.utils import timezone # Third party imports @@ -8,15 +8,19 @@ from rest_framework.response import Response from plane.utils.paginator import BasePaginator # Module imports -from .base import BaseViewSet, BaseAPIView +from ..base import BaseViewSet, BaseAPIView from plane.db.models import ( Notification, IssueAssignee, IssueSubscriber, Issue, WorkspaceMember, + UserNotificationPreference, +) +from plane.app.serializers import ( + NotificationSerializer, + UserNotificationPreferenceSerializer, ) -from plane.app.serializers import NotificationSerializer class NotificationViewSet(BaseViewSet, BasePaginator): @@ -51,8 +55,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # Filters based on query parameters snoozed_filters = { - "true": Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False), - "false": Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + "true": Q(snoozed_till__lt=timezone.now()) + | Q(snoozed_till__isnull=False), + "false": Q(snoozed_till__gte=timezone.now()) + | Q(snoozed_till__isnull=True), } notifications = notifications.filter(snoozed_filters[snoozed]) @@ -69,17 +75,39 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # 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) + 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) + ) + notifications = notifications.filter( + entity_identifier__in=issue_ids, + ) # Assigned Issues if type == "assigned": issue_ids = IssueAssignee.objects.filter( workspace__slug=slug, assignee_id=request.user.id ).values_list("issue_id", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) # Created issues if type == "created": @@ -94,10 +122,14 @@ class NotificationViewSet(BaseViewSet, BasePaginator): issue_ids = Issue.objects.filter( workspace__slug=slug, created_by=request.user ).values_list("pk", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) # Pagination - if request.GET.get("per_page", False) and request.GET.get("cursor", False): + if request.GET.get("per_page", False) and request.GET.get( + "cursor", False + ): return self.paginate( request=request, queryset=(notifications), @@ -227,11 +259,13 @@ class MarkAllReadNotificationViewSet(BaseViewSet): # Filter for snoozed notifications if snoozed: notifications = notifications.filter( - Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False) + Q(snoozed_till__lt=timezone.now()) + | Q(snoozed_till__isnull=False) ) else: notifications = notifications.filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + Q(snoozed_till__gte=timezone.now()) + | Q(snoozed_till__isnull=True), ) # Filter for archived or unarchive @@ -245,14 +279,18 @@ class MarkAllReadNotificationViewSet(BaseViewSet): issue_ids = IssueSubscriber.objects.filter( workspace__slug=slug, subscriber_id=request.user.id ).values_list("issue_id", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) # Assigned Issues if type == "assigned": issue_ids = IssueAssignee.objects.filter( workspace__slug=slug, assignee_id=request.user.id ).values_list("issue_id", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) # Created issues if type == "created": @@ -267,7 +305,9 @@ class MarkAllReadNotificationViewSet(BaseViewSet): issue_ids = Issue.objects.filter( workspace__slug=slug, created_by=request.user ).values_list("pk", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) updated_notifications = [] for notification in notifications: @@ -277,3 +317,31 @@ class MarkAllReadNotificationViewSet(BaseViewSet): 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/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py deleted file mode 100644 index de90e4337..000000000 --- a/apiserver/plane/app/views/oauth.py +++ /dev/null @@ -1,451 +0,0 @@ -# Python imports -import uuid -import requests -import os - -# Django imports -from django.utils import timezone -from django.conf import settings - -# Third Party modules -from rest_framework.response import Response -from rest_framework import exceptions -from rest_framework.permissions import AllowAny -from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework import status -from sentry_sdk import capture_exception - -# sso authentication -from google.oauth2 import id_token -from google.auth.transport import requests as google_auth_request - -# Module imports -from plane.db.models import ( - SocialLoginConnection, - User, - WorkspaceMemberInvite, - WorkspaceMember, - ProjectMemberInvite, - ProjectMember, -) -from plane.bgtasks.event_tracking_task import auth_events -from .base import BaseAPIView -from plane.license.models import Instance -from plane.license.utils.instance_value import get_configuration_value - - -def get_tokens_for_user(user): - refresh = RefreshToken.for_user(user) - return ( - str(refresh.access_token), - str(refresh), - ) - - -def validate_google_token(token, client_id): - try: - id_info = id_token.verify_oauth2_token( - token, google_auth_request.Request(), client_id - ) - email = id_info.get("email") - first_name = id_info.get("given_name") - last_name = id_info.get("family_name", "") - data = { - "email": email, - "first_name": first_name, - "last_name": last_name, - } - return data - except Exception as e: - capture_exception(e) - raise exceptions.AuthenticationFailed("Error with Google connection.") - - -def get_access_token(request_token: str, client_id: str) -> str: - """Obtain the request token from github. - Given the client id, client secret and request issued out by GitHub, this method - should give back an access token - Parameters - ---------- - CLIENT_ID: str - A string representing the client id issued out by github - CLIENT_SECRET: str - A string representing the client secret issued out by github - request_token: str - A string representing the request token issued out by github - Throws - ------ - ValueError: - if CLIENT_ID or CLIENT_SECRET or request_token is empty or not a string - Returns - ------- - access_token: str - A string representing the access token issued out by github - """ - - if not request_token: - raise ValueError("The request token has to be supplied!") - - (CLIENT_SECRET,) = get_configuration_value( - [ - { - "key": "GITHUB_CLIENT_SECRET", - "default": os.environ.get("GITHUB_CLIENT_SECRET", None), - }, - ] - ) - - url = f"https://github.com/login/oauth/access_token?client_id={client_id}&client_secret={CLIENT_SECRET}&code={request_token}" - headers = {"accept": "application/json"} - - res = requests.post(url, headers=headers) - - data = res.json() - access_token = data["access_token"] - - return access_token - - -def get_user_data(access_token: str) -> dict: - """ - Obtain the user data from github. - Given the access token, this method should give back the user data - """ - if not access_token: - raise ValueError("The request token has to be supplied!") - if not isinstance(access_token, str): - raise ValueError("The request token has to be a string!") - - access_token = "token " + access_token - url = "https://api.github.com/user" - headers = {"Authorization": access_token} - - resp = requests.get(url=url, headers=headers) - - user_data = resp.json() - - response = requests.get( - url="https://api.github.com/user/emails", headers=headers - ).json() - - _ = [ - user_data.update({"email": item.get("email")}) - for item in response - if item.get("primary") is True - ] - - return user_data - - -class OauthEndpoint(BaseAPIView): - permission_classes = [AllowAny] - - def post(self, request): - try: - # Check if instance is registered or not - instance = Instance.objects.first() - if instance is None and not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - medium = request.data.get("medium", False) - id_token = request.data.get("credential", False) - client_id = request.data.get("clientId", False) - - GOOGLE_CLIENT_ID, GITHUB_CLIENT_ID = get_configuration_value( - [ - { - "key": "GOOGLE_CLIENT_ID", - "default": os.environ.get("GOOGLE_CLIENT_ID"), - }, - { - "key": "GITHUB_CLIENT_ID", - "default": os.environ.get("GITHUB_CLIENT_ID"), - }, - ] - ) - - if not medium or not id_token: - return Response( - { - "error": "Something went wrong. Please try again later or contact the support team." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - if medium == "google": - if not GOOGLE_CLIENT_ID: - return Response( - {"error": "Google login is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - data = validate_google_token(id_token, client_id) - - if medium == "github": - if not GITHUB_CLIENT_ID: - return Response( - {"error": "Github login is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - access_token = get_access_token(id_token, client_id) - data = get_user_data(access_token) - - email = data.get("email", None) - if email is None: - return Response( - { - "error": "Something went wrong. Please try again later or contact the support team." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - if "@" in email: - user = User.objects.get(email=email) - email = data["email"] - mobile_number = uuid.uuid4().hex - email_verified = True - else: - return Response( - { - "error": "Something went wrong. Please try again later or contact the support team." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - user.is_active = True - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_medium = "oauth" - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.is_email_verified = email_verified - user.save() - - # Check if user has any accepted invites for workspace and add them to workspace - workspace_member_invites = WorkspaceMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=workspace_member_invite.workspace_id, - member=user, - role=workspace_member_invite.role, - ) - for workspace_member_invite in workspace_member_invites - ], - ignore_conflicts=True, - ) - - # Check if user has any project invites - project_member_invites = ProjectMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - # Add user to workspace - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=project_member_invite.workspace_id, - role=project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15, - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Now add the users to project - ProjectMember.objects.bulk_create( - [ - ProjectMember( - workspace_id=project_member_invite.workspace_id, - role=project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15, - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - # Delete all the invites - workspace_member_invites.delete() - project_member_invites.delete() - - SocialLoginConnection.objects.update_or_create( - medium=medium, - extra_data={}, - user=user, - defaults={ - "token_data": {"id_token": id_token}, - "last_login_at": timezone.now(), - }, - ) - - # Send event - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium=medium.upper(), - first_time=False, - ) - - access_token, refresh_token = get_tokens_for_user(user) - - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - return Response(data, status=status.HTTP_200_OK) - - except User.DoesNotExist: - (ENABLE_SIGNUP,) = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP", "0"), - } - ] - ) - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter( - email=email, - ).exists() - ): - return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - username = uuid.uuid4().hex - - if "@" in email: - email = data["email"] - mobile_number = uuid.uuid4().hex - email_verified = True - else: - return Response( - { - "error": "Something went wrong. Please try again later or contact the support team." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.create( - username=username, - email=email, - mobile_number=mobile_number, - first_name=data.get("first_name", ""), - last_name=data.get("last_name", ""), - is_email_verified=email_verified, - is_password_autoset=True, - ) - - user.set_password(uuid.uuid4().hex) - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_medium = "oauth" - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - # Check if user has any accepted invites for workspace and add them to workspace - workspace_member_invites = WorkspaceMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=workspace_member_invite.workspace_id, - member=user, - role=workspace_member_invite.role, - ) - for workspace_member_invite in workspace_member_invites - ], - ignore_conflicts=True, - ) - - # Check if user has any project invites - project_member_invites = ProjectMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - # Add user to workspace - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=project_member_invite.workspace_id, - role=project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15, - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Now add the users to project - ProjectMember.objects.bulk_create( - [ - ProjectMember( - workspace_id=project_member_invite.workspace_id, - role=project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15, - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - # Delete all the invites - workspace_member_invites.delete() - project_member_invites.delete() - - # Send event - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium=medium.upper(), - first_time=True, - ) - - SocialLoginConnection.objects.update_or_create( - medium=medium, - extra_data={}, - user=user, - defaults={ - "token_data": {"id_token": id_token}, - "last_login_at": timezone.now(), - }, - ) - - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - - return Response(data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page/base.py similarity index 58% rename from apiserver/plane/app/views/page.py rename to apiserver/plane/app/views/page/base.py index 9bd1f1dd4..c7f53b9fe 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page/base.py @@ -1,36 +1,38 @@ # Python imports -from datetime import timedelta, date, datetime +import json +import base64 +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 -from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.http import StreamingHttpResponse # Third party imports from rest_framework import status from rest_framework.response import Response -# Module imports -from .base import BaseViewSet, BaseAPIView from plane.app.permissions import ProjectEntityPermission +from plane.app.serializers import ( + PageLogSerializer, + PageSerializer, + SubPageSerializer, + PageDetailSerializer, +) from plane.db.models import ( Page, - PageFavorite, - Issue, - IssueAssignee, - IssueActivity, PageLog, + UserFavorite, ProjectMember, ) -from plane.app.serializers import ( - PageSerializer, - PageFavoriteSerializer, - PageLogSerializer, - IssueLiteSerializer, - SubPageSerializer, -) + +# Module imports +from ..base import BaseAPIView, BaseViewSet + +from plane.bgtasks.page_transaction_task import page_transaction def unarchive_archive_page_and_descendants(page_id, archived_at): @@ -60,9 +62,10 @@ class PageViewSet(BaseViewSet): ] def get_queryset(self): - subquery = PageFavorite.objects.filter( + subquery = UserFavorite.objects.filter( user=self.request.user, - page_id=OuterRef("pk"), + entity_type="page", + entity_identifier=OuterRef("pk"), project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) @@ -71,7 +74,11 @@ class PageViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) .filter(parent__isnull=True) .filter(Q(owned_by=self.request.user) | Q(access=0)) .select_related("project") @@ -87,17 +94,29 @@ class PageViewSet(BaseViewSet): def create(self, request, slug, project_id): serializer = PageSerializer( data=request.data, - context={"project_id": project_id, "owned_by_id": request.user.id}, + context={ + "project_id": project_id, + "owned_by_id": request.user.id, + "description_html": request.data.get( + "description_html", "

    " + ), + }, ) if serializer.is_valid(): serializer.save() + # capture the page transaction + page_transaction.delay(request.data, None, serializer.data["id"]) + page = Page.objects.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, pk): try: - page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) if page.is_locked: return Response( @@ -123,11 +142,29 @@ class PageViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - serializer = PageSerializer(page, data=request.data, partial=True) + serializer = PageDetailSerializer( + page, data=request.data, partial=True + ) + page_description = page.description_html if serializer.is_valid(): serializer.save() + # capture the page transaction + if request.data.get("description_html"): + page_transaction.delay( + new_value=request.data, + old_value=json.dumps( + { + "description_html": page_description, + }, + cls=DjangoJSONEncoder, + ), + page_id=pk, + ) + return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) except Page.DoesNotExist: return Response( { @@ -136,18 +173,30 @@ class PageViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - def lock(self, request, slug, project_id, page_id): + def retrieve(self, request, slug, project_id, pk=None): + page = self.get_queryset().filter(pk=pk).first() + if page is None: + return Response( + {"error": "Page not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + else: + return Response( + PageDetailSerializer(page).data, status=status.HTTP_200_OK + ) + + def lock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=page_id, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, project_id=project_id ).first() page.is_locked = True page.save() return Response(status=status.HTTP_204_NO_CONTENT) - def unlock(self, request, slug, project_id, page_id): + def unlock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=page_id, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, project_id=project_id ).first() page.is_locked = False @@ -156,42 +205,54 @@ class PageViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) def list(self, request, slug, project_id): - queryset = self.get_queryset().filter(archived_at__isnull=True) - return Response( - PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK + queryset = self.get_queryset() + pages = PageSerializer(queryset, many=True).data + return Response(pages, status=status.HTTP_200_OK) + + def archive(self, request, slug, project_id, pk): + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id ) - def archive(self, request, slug, project_id, page_id): - page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id) - - # only the owner and admin can archive the page + # only the owner or admin can archive the page if ( ProjectMember.objects.filter( - project_id=project_id, member=request.user, is_active=True, role__gt=20 + project_id=project_id, + member=request.user, + is_active=True, + role__lte=15, ).exists() - or request.user.id != page.owned_by_id + and request.user.id != page.owned_by_id ): return Response( - {"error": "Only the owner and admin can archive the page"}, + {"error": "Only the owner or admin can archive the page"}, status=status.HTTP_400_BAD_REQUEST, ) - unarchive_archive_page_and_descendants(page_id, datetime.now()) + unarchive_archive_page_and_descendants(pk, datetime.now()) - return Response(status=status.HTTP_204_NO_CONTENT) + 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, project_id=project_id) + def unarchive(self, request, slug, project_id, pk): + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) - # only the owner and admin can un archive the page + # 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__gt=20 + project_id=project_id, + member=request.user, + is_active=True, + role__lte=15, ).exists() - or request.user.id != page.owned_by_id + and request.user.id != page.owned_by_id ): return Response( - {"error": "Only the owner and admin can un archive the page"}, + {"error": "Only the owner or admin can un archive the page"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -200,27 +261,22 @@ class PageViewSet(BaseViewSet): page.parent = None page.save(update_fields=["parent"]) - unarchive_archive_page_and_descendants(page_id, None) + unarchive_archive_page_and_descendants(pk, None) return Response(status=status.HTTP_204_NO_CONTENT) - def archive_list(self, request, slug, project_id): - pages = Page.objects.filter( - project_id=project_id, - workspace__slug=slug, - ).filter(archived_at__isnull=False) - - return Response( - PageSerializer(pages, many=True).data, status=status.HTTP_200_OK - ) - def destroy(self, request, slug, project_id, pk): - page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) # only the owner and admin can delete the page if ( ProjectMember.objects.filter( - project_id=project_id, member=request.user, is_active=True, role__gt=20 + project_id=project_id, + member=request.user, + is_active=True, + role__gt=20, ).exists() or request.user.id != page.owned_by_id ): @@ -249,32 +305,24 @@ class PageFavoriteViewSet(BaseViewSet): ProjectEntityPermission, ] - serializer_class = PageFavoriteSerializer - model = PageFavorite + model = UserFavorite - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(archived_at__isnull=True) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(user=self.request.user) - .select_related("page", "page__owned_by") + def create(self, request, slug, project_id, pk): + _ = UserFavorite.objects.create( + project_id=project_id, + entity_identifier=pk, + entity_type="page", + user=request.user, ) + return Response(status=status.HTTP_204_NO_CONTENT) - def create(self, request, slug, project_id): - serializer = PageFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=request.user, project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, page_id): - page_favorite = PageFavorite.objects.get( + def destroy(self, request, slug, project_id, pk): + page_favorite = UserFavorite.objects.get( project=project_id, user=request.user, workspace__slug=slug, - page_id=page_id, + entity_identifier=pk, + entity_type="page", ) page_favorite.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -342,3 +390,48 @@ class SubPagesEndpoint(BaseAPIView): return Response( SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK ) + + +class PagesDescriptionViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + def retrieve(self, request, slug, project_id, pk): + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + 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, pk): + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + + base64_data = request.data.get("description_binary") + + if base64_data: + # Decode the base64 data to bytes + new_binary_data = base64.b64decode(base64_data) + + # Store the updated binary data + page.description_binary = new_binary_data + page.description_html = request.data.get("description_html") + page.save() + return Response({"message": "Updated successfully"}) + else: + return Response({"error": "No binary data provided"}) diff --git a/apiserver/plane/app/views/project.py b/apiserver/plane/app/views/project.py deleted file mode 100644 index 5b88e3652..000000000 --- a/apiserver/plane/app/views/project.py +++ /dev/null @@ -1,1118 +0,0 @@ -# Python imports -import jwt -import boto3 -from datetime import datetime - -# Django imports -from django.core.exceptions import ValidationError -from django.db import IntegrityError -from django.db.models import ( - Prefetch, - Q, - Exists, - OuterRef, - F, - Func, - Subquery, -) -from django.core.validators import validate_email -from django.conf import settings -from django.utils import timezone - -# Third Party imports -from rest_framework.response import Response -from rest_framework import status -from rest_framework import serializers -from rest_framework.permissions import AllowAny - -# Module imports -from .base import BaseViewSet, BaseAPIView, WebhookMixin -from plane.app.serializers import ( - ProjectSerializer, - ProjectListSerializer, - ProjectMemberSerializer, - ProjectDetailSerializer, - ProjectMemberInviteSerializer, - ProjectFavoriteSerializer, - ProjectDeployBoardSerializer, - ProjectMemberAdminSerializer, -) - -from plane.app.permissions import ( - WorkspaceUserPermission, - ProjectBasePermission, - ProjectMemberPermission, - ProjectLitePermission, -) - -from plane.db.models import ( - Project, - ProjectMember, - Workspace, - ProjectMemberInvite, - User, - WorkspaceMember, - State, - TeamMember, - ProjectFavorite, - ProjectIdentifier, - Module, - Cycle, - Inbox, - ProjectDeployBoard, - IssueProperty, -) - -from plane.bgtasks.project_invitation_task import project_invitation - - -class ProjectViewSet(WebhookMixin, BaseViewSet): - serializer_class = ProjectSerializer - model = Project - webhook_event = "project" - - permission_classes = [ - ProjectBasePermission, - ] - - def get_serializer_class(self, *args, **kwargs): - if self.action in ["update", "partial_update"]: - return ProjectSerializer - return ProjectDetailSerializer - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(Q(project_projectmember__member=self.request.user) | Q(network=2)) - .select_related( - "workspace", "workspace__owner", "default_assignee", "project_lead" - ) - .annotate( - is_favorite=Exists( - ProjectFavorite.objects.filter( - user=self.request.user, - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - ) - ) - ) - .annotate( - is_member=Exists( - ProjectMember.objects.filter( - member=self.request.user, - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - is_active=True, - ) - ) - ) - .annotate( - total_members=ProjectMember.objects.filter( - project_id=OuterRef("id"), - member__is_bot=False, - is_active=True, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - total_cycles=Cycle.objects.filter(project_id=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - total_modules=Module.objects.filter(project_id=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - member_role=ProjectMember.objects.filter( - project_id=OuterRef("pk"), - member_id=self.request.user.id, - is_active=True, - ).values("role") - ) - .annotate( - is_deployed=Exists( - ProjectDeployBoard.objects.filter( - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - ) - ) - ) - .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() - ) - - def list(self, request, slug): - fields = [field for field in request.GET.get("fields", "").split(",") if field] - - sort_order_query = ProjectMember.objects.filter( - member=request.user, - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - is_active=True, - ).values("sort_order") - projects = ( - self.get_queryset() - .annotate(sort_order=Subquery(sort_order_query)) - .order_by("sort_order", "name") - ) - if request.GET.get("per_page", False) and request.GET.get("cursor", False): - return self.paginate( - request=request, - queryset=(projects), - on_results=lambda projects: ProjectListSerializer( - projects, many=True - ).data, - ) - - return Response( - ProjectListSerializer( - projects, many=True, fields=fields if fields else None - ).data - ) - - def create(self, request, slug): - try: - workspace = Workspace.objects.get(slug=slug) - - serializer = ProjectSerializer( - data={**request.data}, context={"workspace_id": workspace.id} - ) - if serializer.is_valid(): - serializer.save() - - # Add the user as Administrator to the project - project_member = ProjectMember.objects.create( - project_id=serializer.data["id"], member=request.user, role=20 - ) - # Also create the issue property for the user - _ = IssueProperty.objects.create( - project_id=serializer.data["id"], - user=request.user, - ) - - if serializer.data["project_lead"] is not None and str( - serializer.data["project_lead"] - ) != str(request.user.id): - ProjectMember.objects.create( - project_id=serializer.data["id"], - member_id=serializer.data["project_lead"], - role=20, - ) - # Also create the issue property for the user - IssueProperty.objects.create( - project_id=serializer.data["id"], - user_id=serializer.data["project_lead"], - ) - - # Default states - states = [ - { - "name": "Backlog", - "color": "#A3A3A3", - "sequence": 15000, - "group": "backlog", - "default": True, - }, - { - "name": "Todo", - "color": "#3A3A3A", - "sequence": 25000, - "group": "unstarted", - }, - { - "name": "In Progress", - "color": "#F59E0B", - "sequence": 35000, - "group": "started", - }, - { - "name": "Done", - "color": "#16A34A", - "sequence": 45000, - "group": "completed", - }, - { - "name": "Cancelled", - "color": "#EF4444", - "sequence": 55000, - "group": "cancelled", - }, - ] - - State.objects.bulk_create( - [ - State( - name=state["name"], - color=state["color"], - project=serializer.instance, - sequence=state["sequence"], - workspace=serializer.instance.workspace, - group=state["group"], - default=state.get("default", False), - created_by=request.user, - ) - for state in states - ] - ) - - project = self.get_queryset().filter(pk=serializer.data["id"]).first() - serializer = ProjectListSerializer(project) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response( - serializer.errors, - status=status.HTTP_400_BAD_REQUEST, - ) - except IntegrityError as e: - if "already exists" in str(e): - return Response( - {"name": "The project name is already taken"}, - status=status.HTTP_410_GONE, - ) - except Workspace.DoesNotExist as e: - return Response( - {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND - ) - except serializers.ValidationError as e: - return Response( - {"identifier": "The project identifier is already taken"}, - status=status.HTTP_410_GONE, - ) - - def partial_update(self, request, slug, pk=None): - try: - workspace = Workspace.objects.get(slug=slug) - - project = Project.objects.get(pk=pk) - - serializer = ProjectSerializer( - project, - data={**request.data}, - context={"workspace_id": workspace.id}, - partial=True, - ) - - if serializer.is_valid(): - serializer.save() - if serializer.data["inbox_view"]: - Inbox.objects.get_or_create( - name=f"{project.name} Inbox", project=project, is_default=True - ) - - # Create the triage state in Backlog group - State.objects.get_or_create( - name="Triage", - group="backlog", - description="Default state for managing all Inbox Issues", - project_id=pk, - color="#ff7700", - ) - - project = self.get_queryset().filter(pk=serializer.data["id"]).first() - serializer = ProjectListSerializer(project) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - except IntegrityError as e: - if "already exists" in str(e): - return Response( - {"name": "The project name is already taken"}, - status=status.HTTP_410_GONE, - ) - except (Project.DoesNotExist, Workspace.DoesNotExist): - return Response( - {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND - ) - except serializers.ValidationError as e: - return Response( - {"identifier": "The project identifier is already taken"}, - status=status.HTTP_410_GONE, - ) - - -class ProjectInvitationsViewset(BaseViewSet): - serializer_class = ProjectMemberInviteSerializer - model = ProjectMemberInvite - - search_fields = [] - - permission_classes = [ - ProjectBasePermission, - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .select_related("project") - .select_related("workspace", "workspace__owner") - ) - - def create(self, request, slug, project_id): - emails = request.data.get("emails", []) - - # Check if email is provided - if not emails: - return Response( - {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST - ) - - requesting_user = ProjectMember.objects.get( - workspace__slug=slug, project_id=project_id, member_id=request.user.id - ) - - # Check if any invited user has an higher role - if len( - [ - email - for email in emails - if int(email.get("role", 10)) > requesting_user.role - ] - ): - return Response( - {"error": "You cannot invite a user with higher role"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - workspace = Workspace.objects.get(slug=slug) - - project_invitations = [] - for email in emails: - try: - validate_email(email.get("email")) - project_invitations.append( - ProjectMemberInvite( - email=email.get("email").strip().lower(), - project_id=project_id, - workspace_id=workspace.id, - token=jwt.encode( - { - "email": email, - "timestamp": datetime.now().timestamp(), - }, - settings.SECRET_KEY, - algorithm="HS256", - ), - role=email.get("role", 10), - created_by=request.user, - ) - ) - except ValidationError: - return Response( - { - "error": f"Invalid email - {email} provided a valid email address is required to send the invite" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Create workspace member invite - project_invitations = ProjectMemberInvite.objects.bulk_create( - project_invitations, batch_size=10, ignore_conflicts=True - ) - current_site = request.META.get("HTTP_ORIGIN") - - # Send invitations - for invitation in project_invitations: - project_invitations.delay( - invitation.email, - project_id, - invitation.token, - current_site, - request.user.email, - ) - - return Response( - { - "message": "Email sent successfully", - }, - status=status.HTTP_200_OK, - ) - - -class UserProjectInvitationsViewset(BaseViewSet): - serializer_class = ProjectMemberInviteSerializer - model = ProjectMemberInvite - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(email=self.request.user.email) - .select_related("workspace", "workspace__owner", "project") - ) - - def create(self, request, slug): - project_ids = request.data.get("project_ids", []) - - # Get the workspace user role - workspace_member = WorkspaceMember.objects.get( - member=request.user, - workspace__slug=slug, - is_active=True, - ) - - workspace_role = workspace_member.role - workspace = workspace_member.workspace - - # If the user was already part of workspace - _ = ProjectMember.objects.filter( - workspace__slug=slug, - project_id__in=project_ids, - member=request.user, - ).update(is_active=True) - - ProjectMember.objects.bulk_create( - [ - ProjectMember( - project_id=project_id, - member=request.user, - role=15 if workspace_role >= 15 else 10, - workspace=workspace, - created_by=request.user, - ) - for project_id in project_ids - ], - ignore_conflicts=True, - ) - - IssueProperty.objects.bulk_create( - [ - IssueProperty( - project_id=project_id, - user=request.user, - workspace=workspace, - created_by=request.user, - ) - for project_id in project_ids - ], - ignore_conflicts=True, - ) - - return Response( - {"message": "Projects joined successfully"}, - status=status.HTTP_201_CREATED, - ) - - -class ProjectJoinEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request, slug, project_id, pk): - project_invite = ProjectMemberInvite.objects.get( - pk=pk, - project_id=project_id, - workspace__slug=slug, - ) - - email = request.data.get("email", "") - - if email == "" or project_invite.email != email: - return Response( - {"error": "You do not have permission to join the project"}, - status=status.HTTP_403_FORBIDDEN, - ) - - if project_invite.responded_at is None: - project_invite.accepted = request.data.get("accepted", False) - project_invite.responded_at = timezone.now() - project_invite.save() - - if project_invite.accepted: - # Check if the user account exists - user = User.objects.filter(email=email).first() - - # Check if user is a part of workspace - workspace_member = WorkspaceMember.objects.filter( - workspace__slug=slug, member=user - ).first() - # Add him to workspace - if workspace_member is None: - _ = WorkspaceMember.objects.create( - workspace_id=project_invite.workspace_id, - member=user, - role=15 if project_invite.role >= 15 else project_invite.role, - ) - else: - # Else make him active - workspace_member.is_active = True - workspace_member.save() - - # Check if the user was already a member of project then activate the user - project_member = ProjectMember.objects.filter( - workspace_id=project_invite.workspace_id, member=user - ).first() - if project_member is None: - # Create a Project Member - _ = ProjectMember.objects.create( - workspace_id=project_invite.workspace_id, - member=user, - role=project_invite.role, - ) - else: - project_member.is_active = True - project_member.role = project_member.role - project_member.save() - - return Response( - {"message": "Project Invitation Accepted"}, - status=status.HTTP_200_OK, - ) - - return Response( - {"message": "Project Invitation was not accepted"}, - status=status.HTTP_200_OK, - ) - - return Response( - {"error": "You have already responded to the invitation request"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - def get(self, request, slug, project_id, pk): - project_invitation = ProjectMemberInvite.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) - serializer = ProjectMemberInviteSerializer(project_invitation) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class ProjectMemberViewSet(BaseViewSet): - serializer_class = ProjectMemberAdminSerializer - model = ProjectMember - permission_classes = [ - ProjectMemberPermission, - ] - - def get_permissions(self): - if self.action == "leave": - self.permission_classes = [ - ProjectLitePermission, - ] - else: - self.permission_classes = [ - ProjectMemberPermission, - ] - - return super(ProjectMemberViewSet, self).get_permissions() - - search_fields = [ - "member__display_name", - "member__first_name", - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(member__is_bot=False) - .filter() - .select_related("project") - .select_related("member") - .select_related("workspace", "workspace__owner") - ) - - def create(self, request, slug, project_id): - members = request.data.get("members", []) - - # get the project - project = Project.objects.get(pk=project_id, workspace__slug=slug) - - if not len(members): - return Response( - {"error": "Atleast one member is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - bulk_project_members = [] - bulk_issue_props = [] - - project_members = ( - ProjectMember.objects.filter( - workspace__slug=slug, - member_id__in=[member.get("member_id") for member in members], - ) - .values("member_id", "sort_order") - .order_by("sort_order") - ) - - for member in members: - sort_order = [ - project_member.get("sort_order") - for project_member in project_members - if str(project_member.get("member_id")) == str(member.get("member_id")) - ] - bulk_project_members.append( - ProjectMember( - member_id=member.get("member_id"), - role=member.get("role", 10), - project_id=project_id, - workspace_id=project.workspace_id, - sort_order=sort_order[0] - 10000 if len(sort_order) else 65535, - ) - ) - bulk_issue_props.append( - IssueProperty( - user_id=member.get("member_id"), - project_id=project_id, - workspace_id=project.workspace_id, - ) - ) - - # Check if the user is already a member of the project and is inactive - if ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - member_id=member.get("member_id"), - is_active=False, - ).exists(): - member_detail = ProjectMember.objects.get( - workspace__slug=slug, - project_id=project_id, - member_id=member.get("member_id"), - is_active=False, - ) - # Check if the user has not deactivated the account - user = User.objects.filter(pk=member.get("member_id")).first() - if user.is_active: - member_detail.is_active = True - member_detail.save(update_fields=["is_active"]) - - project_members = ProjectMember.objects.bulk_create( - bulk_project_members, - batch_size=10, - ignore_conflicts=True, - ) - - _ = IssueProperty.objects.bulk_create( - bulk_issue_props, batch_size=10, ignore_conflicts=True - ) - - serializer = ProjectMemberSerializer(project_members, many=True) - - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def list(self, request, slug, project_id): - project_member = ProjectMember.objects.get( - member=request.user, - workspace__slug=slug, - project_id=project_id, - is_active=True, - ) - - project_members = ProjectMember.objects.filter( - project_id=project_id, - workspace__slug=slug, - member__is_bot=False, - is_active=True, - ).select_related("project", "member", "workspace") - - if project_member.role > 10: - serializer = ProjectMemberAdminSerializer(project_members, many=True) - else: - serializer = ProjectMemberSerializer(project_members, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def partial_update(self, request, slug, project_id, pk): - project_member = ProjectMember.objects.get( - pk=pk, - workspace__slug=slug, - project_id=project_id, - is_active=True, - ) - if request.user.id == project_member.member_id: - return Response( - {"error": "You cannot update your own role"}, - status=status.HTTP_400_BAD_REQUEST, - ) - # Check while updating user roles - requested_project_member = ProjectMember.objects.get( - project_id=project_id, - workspace__slug=slug, - member=request.user, - is_active=True, - ) - if ( - "role" in request.data - and int(request.data.get("role", project_member.role)) - > requested_project_member.role - ): - return Response( - {"error": "You cannot update a role that is higher than your own role"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = ProjectMemberSerializer( - project_member, data=request.data, partial=True - ) - - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, pk): - project_member = ProjectMember.objects.get( - workspace__slug=slug, - project_id=project_id, - pk=pk, - member__is_bot=False, - is_active=True, - ) - # check requesting user role - requesting_project_member = ProjectMember.objects.get( - workspace__slug=slug, - member=request.user, - project_id=project_id, - is_active=True, - ) - # User cannot remove himself - if str(project_member.id) == str(requesting_project_member.id): - return Response( - { - "error": "You cannot remove yourself from the workspace. Please use leave workspace" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - # User cannot deactivate higher role - if requesting_project_member.role < project_member.role: - return Response( - {"error": "You cannot remove a user having role higher than you"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - project_member.is_active = False - project_member.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - def leave(self, request, slug, project_id): - project_member = ProjectMember.objects.get( - workspace__slug=slug, - project_id=project_id, - member=request.user, - is_active=True, - ) - - # Check if the leaving user is the only admin of the project - if ( - project_member.role == 20 - and not ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - role=20, - is_active=True, - ).count() - > 1 - ): - return Response( - { - "error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin", - }, - status=status.HTTP_400_BAD_REQUEST, - ) - # Deactivate the user - project_member.is_active = False - project_member.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class AddTeamToProjectEndpoint(BaseAPIView): - permission_classes = [ - ProjectBasePermission, - ] - - def post(self, request, slug, project_id): - team_members = TeamMember.objects.filter( - workspace__slug=slug, team__in=request.data.get("teams", []) - ).values_list("member", flat=True) - - if len(team_members) == 0: - return Response( - {"error": "No such team exists"}, status=status.HTTP_400_BAD_REQUEST - ) - - workspace = Workspace.objects.get(slug=slug) - - project_members = [] - issue_props = [] - for member in team_members: - project_members.append( - ProjectMember( - project_id=project_id, - member_id=member, - workspace=workspace, - created_by=request.user, - ) - ) - issue_props.append( - IssueProperty( - project_id=project_id, - user_id=member, - workspace=workspace, - created_by=request.user, - ) - ) - - ProjectMember.objects.bulk_create( - project_members, batch_size=10, ignore_conflicts=True - ) - - _ = IssueProperty.objects.bulk_create( - issue_props, batch_size=10, ignore_conflicts=True - ) - - serializer = ProjectMemberSerializer(project_members, many=True) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - -class ProjectIdentifierEndpoint(BaseAPIView): - permission_classes = [ - ProjectBasePermission, - ] - - def get(self, request, slug): - name = request.GET.get("name", "").strip().upper() - - if name == "": - return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - exists = ProjectIdentifier.objects.filter( - name=name, workspace__slug=slug - ).values("id", "name", "project") - - return Response( - {"exists": len(exists), "identifiers": exists}, - status=status.HTTP_200_OK, - ) - - def delete(self, request, slug): - name = request.data.get("name", "").strip().upper() - - if name == "": - return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - if Project.objects.filter(identifier=name, workspace__slug=slug).exists(): - return Response( - {"error": "Cannot delete an identifier of an existing project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).delete() - - return Response( - status=status.HTTP_204_NO_CONTENT, - ) - - -class ProjectUserViewsEndpoint(BaseAPIView): - def post(self, request, slug, project_id): - project = Project.objects.get(pk=project_id, workspace__slug=slug) - - project_member = ProjectMember.objects.filter( - member=request.user, - project=project, - is_active=True, - ).first() - - if project_member is None: - return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) - - view_props = project_member.view_props - default_props = project_member.default_props - preferences = project_member.preferences - sort_order = project_member.sort_order - - project_member.view_props = request.data.get("view_props", view_props) - project_member.default_props = request.data.get("default_props", default_props) - project_member.preferences = request.data.get("preferences", preferences) - project_member.sort_order = request.data.get("sort_order", sort_order) - - project_member.save() - - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectMemberUserEndpoint(BaseAPIView): - def get(self, request, slug, project_id): - project_member = ProjectMember.objects.get( - project_id=project_id, - workspace__slug=slug, - member=request.user, - is_active=True, - ) - serializer = ProjectMemberSerializer(project_member) - - return Response(serializer.data, status=status.HTTP_200_OK) - - -class ProjectFavoritesViewSet(BaseViewSet): - serializer_class = ProjectFavoriteSerializer - model = ProjectFavorite - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(user=self.request.user) - .select_related( - "project", "project__project_lead", "project__default_assignee" - ) - .select_related("workspace", "workspace__owner") - ) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - def create(self, request, slug): - serializer = ProjectFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=request.user) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id): - project_favorite = ProjectFavorite.objects.get( - project=project_id, user=request.user, workspace__slug=slug - ) - project_favorite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectPublicCoverImagesEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def get(self, request): - files = [] - s3_client_params = { - "service_name": "s3", - "aws_access_key_id": settings.AWS_ACCESS_KEY_ID, - "aws_secret_access_key": settings.AWS_SECRET_ACCESS_KEY, - } - - # Use AWS_S3_ENDPOINT_URL if it is present in the settings - if hasattr(settings, "AWS_S3_ENDPOINT_URL") and settings.AWS_S3_ENDPOINT_URL: - s3_client_params["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL - - s3 = boto3.client(**s3_client_params) - - params = { - "Bucket": settings.AWS_STORAGE_BUCKET_NAME, - "Prefix": "static/project-cover/", - } - - response = s3.list_objects_v2(**params) - # Extracting file keys from the response - if "Contents" in response: - for content in response["Contents"]: - if not content["Key"].endswith( - "/" - ): # This line ensures we're only getting files, not "sub-folders" - if ( - hasattr(settings, "AWS_S3_CUSTOM_DOMAIN") - and settings.AWS_S3_CUSTOM_DOMAIN - and hasattr(settings, "AWS_S3_URL_PROTOCOL") - and settings.AWS_S3_URL_PROTOCOL - ): - files.append( - f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/{content['Key']}" - ) - else: - files.append( - f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" - ) - - return Response(files, status=status.HTTP_200_OK) - - -class ProjectDeployBoardViewSet(BaseViewSet): - permission_classes = [ - ProjectMemberPermission, - ] - serializer_class = ProjectDeployBoardSerializer - model = ProjectDeployBoard - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - .select_related("project") - ) - - def create(self, request, slug, project_id): - comments = request.data.get("comments", False) - reactions = request.data.get("reactions", False) - inbox = request.data.get("inbox", None) - votes = request.data.get("votes", False) - views = request.data.get( - "views", - { - "list": True, - "kanban": True, - "calendar": True, - "gantt": True, - "spreadsheet": True, - }, - ) - - project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create( - anchor=f"{slug}/{project_id}", - project_id=project_id, - ) - project_deploy_board.comments = comments - project_deploy_board.reactions = reactions - project_deploy_board.inbox = inbox - project_deploy_board.votes = votes - project_deploy_board.views = views - - project_deploy_board.save() - - serializer = ProjectDeployBoardSerializer(project_deploy_board) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class UserProjectRolesEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceUserPermission, - ] - - def get(self, request, slug): - project_members = ProjectMember.objects.filter( - workspace__slug=slug, - member_id=request.user.id, - ).values("project_id", "role") - - project_members = { - str(member["project_id"]): member["role"] for member in project_members - } - return Response(project_members, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py new file mode 100644 index 000000000..39db11871 --- /dev/null +++ b/apiserver/plane/app/views/project/base.py @@ -0,0 +1,689 @@ +# Python imports +import boto3 +import json + +# Django imports +from django.db import IntegrityError +from django.db.models import ( + Prefetch, + Q, + Exists, + OuterRef, + F, + Func, + Subquery, +) +from django.conf import settings +from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework import serializers +from rest_framework.permissions import AllowAny + +# Module imports +from plane.app.views.base import BaseViewSet, BaseAPIView +from plane.app.serializers import ( + ProjectSerializer, + ProjectListSerializer, + ProjectDeployBoardSerializer, +) + +from plane.app.permissions import ( + ProjectBasePermission, + ProjectMemberPermission, +) + +from plane.db.models import ( + Project, + ProjectMember, + Workspace, + State, + UserFavorite, + ProjectIdentifier, + Module, + Cycle, + Inbox, + ProjectDeployBoard, + IssueProperty, + Issue, +) +from plane.utils.cache import cache_response +from plane.bgtasks.webhook_task import model_activity + + +class ProjectViewSet(BaseViewSet): + serializer_class = ProjectListSerializer + model = Project + webhook_event = "project" + + permission_classes = [ + ProjectBasePermission, + ] + + 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")) + .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_favorite=Exists( + UserFavorite.objects.filter( + user=self.request.user, + entity_identifier=OuterRef("pk"), + entity_type="project", + project_id=OuterRef("pk"), + ) + ) + ) + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ) + ) + ) + .annotate( + total_members=ProjectMember.objects.filter( + project_id=OuterRef("id"), + member__is_bot=False, + is_active=True, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_cycles=Cycle.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_modules=Module.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + member_role=ProjectMember.objects.filter( + project_id=OuterRef("pk"), + member_id=self.request.user.id, + is_active=True, + ).values("role") + ) + .annotate( + is_deployed=Exists( + ProjectDeployBoard.objects.filter( + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ) + ) + ) + .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() + ) + + def list(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 request.GET.get("per_page", False) and request.GET.get( + "cursor", False + ): + return self.paginate( + request=request, + queryset=(projects), + on_results=lambda projects: ProjectListSerializer( + projects, many=True + ).data, + ) + projects = ProjectListSerializer( + projects, many=True, fields=fields if fields else None + ).data + return Response(projects, status=status.HTTP_200_OK) + + def retrieve(self, request, slug, pk): + project = ( + self.get_queryset() + .filter(archived_at__isnull=True) + .filter(pk=pk) + .annotate( + total_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("pk"), + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("pk"), + parent__isnull=False, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + archived_issues=Issue.objects.filter( + project_id=self.kwargs.get("pk"), + archived_at__isnull=False, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + archived_sub_issues=Issue.objects.filter( + project_id=self.kwargs.get("pk"), + archived_at__isnull=False, + parent__isnull=False, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + draft_issues=Issue.objects.filter( + project_id=self.kwargs.get("pk"), + is_draft=True, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + draft_sub_issues=Issue.objects.filter( + project_id=self.kwargs.get("pk"), + is_draft=True, + parent__isnull=False, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ).first() + + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_200_OK) + + def create(self, request, slug): + try: + workspace = Workspace.objects.get(slug=slug) + + serializer = ProjectSerializer( + data={**request.data}, context={"workspace_id": workspace.id} + ) + if serializer.is_valid(): + serializer.save() + + # Add the user as Administrator to the project + _ = ProjectMember.objects.create( + project_id=serializer.data["id"], + member=request.user, + role=20, + ) + # Also create the issue property for the user + _ = IssueProperty.objects.create( + project_id=serializer.data["id"], + user=request.user, + ) + + if serializer.data["project_lead"] is not None and str( + serializer.data["project_lead"] + ) != str(request.user.id): + ProjectMember.objects.create( + project_id=serializer.data["id"], + member_id=serializer.data["project_lead"], + role=20, + ) + # Also create the issue property for the user + IssueProperty.objects.create( + project_id=serializer.data["id"], + user_id=serializer.data["project_lead"], + ) + + # Default states + states = [ + { + "name": "Backlog", + "color": "#A3A3A3", + "sequence": 15000, + "group": "backlog", + "default": True, + }, + { + "name": "Todo", + "color": "#3A3A3A", + "sequence": 25000, + "group": "unstarted", + }, + { + "name": "In Progress", + "color": "#F59E0B", + "sequence": 35000, + "group": "started", + }, + { + "name": "Done", + "color": "#16A34A", + "sequence": 45000, + "group": "completed", + }, + { + "name": "Cancelled", + "color": "#EF4444", + "sequence": 55000, + "group": "cancelled", + }, + ] + + State.objects.bulk_create( + [ + State( + name=state["name"], + color=state["color"], + project=serializer.instance, + sequence=state["sequence"], + workspace=serializer.instance.workspace, + group=state["group"], + default=state.get("default", False), + created_by=request.user, + ) + for state in states + ] + ) + + project = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .first() + ) + + 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=request.META.get("HTTP_ORIGIN"), + ) + + serializer = ProjectListSerializer(project) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The project name is already taken"}, + status=status.HTTP_410_GONE, + ) + except Workspace.DoesNotExist: + return Response( + {"error": "Workspace does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + except serializers.ValidationError: + return Response( + {"identifier": "The project identifier is already taken"}, + status=status.HTTP_410_GONE, + ) + + def partial_update(self, request, slug, pk=None): + try: + workspace = Workspace.objects.get(slug=slug) + + project = Project.objects.get(pk=pk) + 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}, + context={"workspace_id": workspace.id}, + partial=True, + ) + + if serializer.is_valid(): + serializer.save() + if serializer.data["inbox_view"]: + Inbox.objects.get_or_create( + name=f"{project.name} Inbox", + project=project, + is_default=True, + ) + + # Create the triage state in Backlog group + State.objects.get_or_create( + name="Triage", + group="triage", + description="Default state for managing all Inbox Issues", + project_id=pk, + color="#ff7700", + is_triage=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=request.META.get("HTTP_ORIGIN"), + ) + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The project name is already taken"}, + status=status.HTTP_410_GONE, + ) + except (Project.DoesNotExist, Workspace.DoesNotExist): + return Response( + {"error": "Project does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + except serializers.ValidationError: + return Response( + {"identifier": "The project identifier is already taken"}, + status=status.HTTP_410_GONE, + ) + + +class ProjectArchiveUnarchiveEndpoint(BaseAPIView): + + permission_classes = [ + ProjectBasePermission, + ] + + def post(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.archived_at = timezone.now() + project.save() + return Response( + {"archived_at": str(project.archived_at)}, + status=status.HTTP_200_OK, + ) + + 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): + permission_classes = [ + ProjectBasePermission, + ] + + def get(self, request, slug): + name = request.GET.get("name", "").strip().upper() + + if name == "": + return Response( + {"error": "Name is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + exists = ProjectIdentifier.objects.filter( + name=name, workspace__slug=slug + ).values("id", "name", "project") + + return Response( + {"exists": len(exists), "identifiers": exists}, + status=status.HTTP_200_OK, + ) + + def delete(self, request, slug): + name = request.data.get("name", "").strip().upper() + + if name == "": + return Response( + {"error": "Name is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if Project.objects.filter( + identifier=name, workspace__slug=slug + ).exists(): + return Response( + { + "error": "Cannot delete an identifier of an existing project" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + ProjectIdentifier.objects.filter( + name=name, workspace__slug=slug + ).delete() + + return Response( + status=status.HTTP_204_NO_CONTENT, + ) + + +class ProjectUserViewsEndpoint(BaseAPIView): + def post(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + project_member = ProjectMember.objects.filter( + member=request.user, + project=project, + is_active=True, + ).first() + + if project_member is None: + return Response( + {"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN + ) + + view_props = project_member.view_props + default_props = project_member.default_props + preferences = project_member.preferences + sort_order = project_member.sort_order + + project_member.view_props = request.data.get("view_props", view_props) + project_member.default_props = request.data.get( + "default_props", default_props + ) + project_member.preferences = request.data.get( + "preferences", preferences + ) + project_member.sort_order = request.data.get("sort_order", sort_order) + + project_member.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class 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() + 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/", + } + + response = s3.list_objects_v2(**params) + # Extracting file keys from the response + if "Contents" in response: + for content in response["Contents"]: + if not content["Key"].endswith( + "/" + ): # This line ensures we're only getting files, not "sub-folders" + files.append( + f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" + ) + + return Response(files, status=status.HTTP_200_OK) + + +class ProjectDeployBoardViewSet(BaseViewSet): + permission_classes = [ + ProjectMemberPermission, + ] + serializer_class = ProjectDeployBoardSerializer + model = ProjectDeployBoard + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + .select_related("project") + ) + + def create(self, request, slug, project_id): + comments = request.data.get("comments", False) + reactions = request.data.get("reactions", False) + inbox = request.data.get("inbox", None) + votes = request.data.get("votes", False) + views = request.data.get( + "views", + { + "list": True, + "kanban": True, + "calendar": True, + "gantt": True, + "spreadsheet": True, + }, + ) + + project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create( + anchor=f"{slug}/{project_id}", + project_id=project_id, + ) + project_deploy_board.comments = comments + project_deploy_board.reactions = reactions + project_deploy_board.inbox = inbox + project_deploy_board.votes = votes + project_deploy_board.views = views + + project_deploy_board.save() + + serializer = ProjectDeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/project/invite.py b/apiserver/plane/app/views/project/invite.py new file mode 100644 index 000000000..d199a8770 --- /dev/null +++ b/apiserver/plane/app/views/project/invite.py @@ -0,0 +1,286 @@ +# 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 ProjectBasePermission + +from plane.db.models import ( + ProjectMember, + Workspace, + ProjectMemberInvite, + User, + WorkspaceMember, + IssueProperty, +) + + +class ProjectInvitationsViewset(BaseViewSet): + serializer_class = ProjectMemberInviteSerializer + model = ProjectMemberInvite + + search_fields = [] + + permission_classes = [ + ProjectBasePermission, + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .select_related("project") + .select_related("workspace", "workspace__owner") + ) + + def create(self, request, slug, project_id): + emails = request.data.get("emails", []) + + # Check if email is provided + if not emails: + return Response( + {"error": "Emails are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + requesting_user = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member_id=request.user.id, + ) + + # Check if any invited user has an higher role + if len( + [ + email + for email in emails + if int(email.get("role", 10)) > requesting_user.role + ] + ): + return Response( + {"error": "You cannot invite a user with higher role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + + project_invitations = [] + for email in emails: + try: + validate_email(email.get("email")) + project_invitations.append( + ProjectMemberInvite( + email=email.get("email").strip().lower(), + project_id=project_id, + workspace_id=workspace.id, + token=jwt.encode( + { + "email": email, + "timestamp": datetime.now().timestamp(), + }, + settings.SECRET_KEY, + algorithm="HS256", + ), + role=email.get("role", 10), + created_by=request.user, + ) + ) + except ValidationError: + return Response( + { + "error": f"Invalid email - {email} provided a valid email address is required to send the invite" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create workspace member invite + project_invitations = ProjectMemberInvite.objects.bulk_create( + project_invitations, batch_size=10, ignore_conflicts=True + ) + current_site = request.META.get("HTTP_ORIGIN") + + # Send invitations + for invitation in project_invitations: + project_invitations.delay( + invitation.email, + project_id, + invitation.token, + current_site, + request.user.email, + ) + + return Response( + { + "message": "Email sent successfully", + }, + status=status.HTTP_200_OK, + ) + + +class UserProjectInvitationsViewset(BaseViewSet): + serializer_class = ProjectMemberInviteSerializer + model = ProjectMemberInvite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(email=self.request.user.email) + .select_related("workspace", "workspace__owner", "project") + ) + + def create(self, request, slug): + project_ids = request.data.get("project_ids", []) + + # Get the workspace user role + workspace_member = WorkspaceMember.objects.get( + member=request.user, + workspace__slug=slug, + is_active=True, + ) + + workspace_role = workspace_member.role + workspace = workspace_member.workspace + + # If the user was already part of workspace + _ = ProjectMember.objects.filter( + workspace__slug=slug, + project_id__in=project_ids, + member=request.user, + ).update(is_active=True) + + ProjectMember.objects.bulk_create( + [ + ProjectMember( + project_id=project_id, + member=request.user, + role=15 if workspace_role >= 15 else 10, + workspace=workspace, + created_by=request.user, + ) + for project_id in project_ids + ], + ignore_conflicts=True, + ) + + IssueProperty.objects.bulk_create( + [ + IssueProperty( + project_id=project_id, + user=request.user, + workspace=workspace, + created_by=request.user, + ) + for project_id in project_ids + ], + ignore_conflicts=True, + ) + + return Response( + {"message": "Projects joined successfully"}, + status=status.HTTP_201_CREATED, + ) + + +class ProjectJoinEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request, slug, project_id, pk): + project_invite = ProjectMemberInvite.objects.get( + pk=pk, + project_id=project_id, + workspace__slug=slug, + ) + + email = request.data.get("email", "") + + if email == "" or project_invite.email != email: + return Response( + {"error": "You do not have permission to join the project"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if project_invite.responded_at is None: + project_invite.accepted = request.data.get("accepted", False) + project_invite.responded_at = timezone.now() + project_invite.save() + + if project_invite.accepted: + # Check if the user account exists + user = User.objects.filter(email=email).first() + + # Check if user is a part of workspace + workspace_member = WorkspaceMember.objects.filter( + workspace__slug=slug, member=user + ).first() + # Add him to workspace + if workspace_member is None: + _ = WorkspaceMember.objects.create( + workspace_id=project_invite.workspace_id, + member=user, + role=( + 15 + if project_invite.role >= 15 + else project_invite.role + ), + ) + else: + # Else make him active + workspace_member.is_active = True + workspace_member.save() + + # Check if the user was already a member of project then activate the user + project_member = ProjectMember.objects.filter( + workspace_id=project_invite.workspace_id, member=user + ).first() + if project_member is None: + # Create a Project Member + _ = ProjectMember.objects.create( + workspace_id=project_invite.workspace_id, + member=user, + role=project_invite.role, + ) + else: + project_member.is_active = True + project_member.role = project_member.role + project_member.save() + + return Response( + {"message": "Project Invitation Accepted"}, + status=status.HTTP_200_OK, + ) + + return Response( + {"message": "Project Invitation was not accepted"}, + status=status.HTTP_200_OK, + ) + + return Response( + {"error": "You have already responded to the invitation request"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def get(self, request, slug, project_id, pk): + project_invitation = ProjectMemberInvite.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + serializer = ProjectMemberInviteSerializer(project_invitation) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py new file mode 100644 index 000000000..187dfc8d0 --- /dev/null +++ b/apiserver/plane/app/views/project/member.py @@ -0,0 +1,349 @@ +# 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 ( + ProjectBasePermission, + ProjectMemberPermission, + ProjectLitePermission, + WorkspaceUserPermission, +) + +from plane.db.models import ( + Project, + ProjectMember, + Workspace, + TeamMember, + IssueProperty, +) + + +class ProjectMemberViewSet(BaseViewSet): + serializer_class = ProjectMemberAdminSerializer + model = ProjectMember + permission_classes = [ + ProjectMemberPermission, + ] + + def get_permissions(self): + if self.action == "leave": + self.permission_classes = [ + ProjectLitePermission, + ] + else: + self.permission_classes = [ + ProjectMemberPermission, + ] + + return super(ProjectMemberViewSet, self).get_permissions() + + search_fields = [ + "member__display_name", + "member__first_name", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(member__is_bot=False) + .filter() + .select_related("project") + .select_related("member") + .select_related("workspace", "workspace__owner") + ) + + def create(self, request, slug, project_id): + members = request.data.get("members", []) + + # get the project + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + if not len(members): + return Response( + {"error": "Atleast one member is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + bulk_project_members = [] + bulk_issue_props = [] + + project_members = ( + ProjectMember.objects.filter( + workspace__slug=slug, + member_id__in=[member.get("member_id") for member in members], + ) + .values("member_id", "sort_order") + .order_by("sort_order") + ) + + bulk_project_members = [] + member_roles = { + member.get("member_id"): member.get("role") for member in members + } + # Update roles in the members array based on the member_roles dictionary + 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 + ) + + for member in members: + sort_order = [ + project_member.get("sort_order") + for project_member in project_members + if str(project_member.get("member_id")) + == str(member.get("member_id")) + ] + bulk_project_members.append( + ProjectMember( + member_id=member.get("member_id"), + role=member.get("role", 10), + project_id=project_id, + workspace_id=project.workspace_id, + sort_order=( + sort_order[0] - 10000 if len(sort_order) else 65535 + ), + ) + ) + bulk_issue_props.append( + IssueProperty( + user_id=member.get("member_id"), + project_id=project_id, + workspace_id=project.workspace_id, + ) + ) + + project_members = ProjectMember.objects.bulk_create( + bulk_project_members, + batch_size=10, + ignore_conflicts=True, + ) + + _ = IssueProperty.objects.bulk_create( + bulk_issue_props, batch_size=10, ignore_conflicts=True + ) + + project_members = ProjectMember.objects.filter( + project_id=project_id, + member_id__in=[member.get("member_id") for member in members], + ) + serializer = ProjectMemberRoleSerializer(project_members, many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + 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, + ).select_related("project", "member", "workspace") + + serializer = ProjectMemberRoleSerializer( + project_members, fields=("id", "member", "role"), many=True + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, slug, project_id, pk): + project_member = ProjectMember.objects.get( + pk=pk, + workspace__slug=slug, + project_id=project_id, + is_active=True, + ) + if request.user.id == project_member.member_id: + return Response( + {"error": "You cannot update your own role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Check while updating user roles + requested_project_member = ProjectMember.objects.get( + project_id=project_id, + workspace__slug=slug, + member=request.user, + is_active=True, + ) + if ( + "role" in request.data + and int(request.data.get("role", project_member.role)) + > requested_project_member.role + ): + return Response( + { + "error": "You cannot update a role that is higher than your own role" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = ProjectMemberSerializer( + project_member, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, pk): + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + pk=pk, + member__is_bot=False, + is_active=True, + ) + # check requesting user role + requesting_project_member = ProjectMember.objects.get( + workspace__slug=slug, + member=request.user, + project_id=project_id, + is_active=True, + ) + # User cannot remove himself + if str(project_member.id) == str(requesting_project_member.id): + return Response( + { + "error": "You cannot remove yourself from the workspace. Please use leave workspace" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # User cannot deactivate higher role + if requesting_project_member.role < project_member.role: + return Response( + { + "error": "You cannot remove a user having role higher than you" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + project_member.is_active = False + project_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def leave(self, request, slug, project_id): + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) + + # Check if the leaving user is the only admin of the project + if ( + project_member.role == 20 + and not ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + role=20, + is_active=True, + ).count() + > 1 + ): + return Response( + { + "error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Deactivate the user + project_member.is_active = False + project_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class AddTeamToProjectEndpoint(BaseAPIView): + permission_classes = [ + ProjectBasePermission, + ] + + def post(self, request, slug, project_id): + team_members = TeamMember.objects.filter( + workspace__slug=slug, team__in=request.data.get("teams", []) + ).values_list("member", flat=True) + + if len(team_members) == 0: + return Response( + {"error": "No such team exists"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + + project_members = [] + issue_props = [] + for member in team_members: + project_members.append( + ProjectMember( + project_id=project_id, + member_id=member, + workspace=workspace, + created_by=request.user, + ) + ) + issue_props.append( + IssueProperty( + project_id=project_id, + user_id=member, + workspace=workspace, + created_by=request.user, + ) + ) + + ProjectMember.objects.bulk_create( + project_members, batch_size=10, ignore_conflicts=True + ) + + _ = IssueProperty.objects.bulk_create( + issue_props, batch_size=10, ignore_conflicts=True + ) + + serializer = ProjectMemberSerializer(project_members, many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class 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, + ] + + def get(self, request, slug): + project_members = ProjectMember.objects.filter( + workspace__slug=slug, + member_id=request.user.id, + ).values("project_id", "role") + + project_members = { + str(member["project_id"]): member["role"] + for member in project_members + } + return Response(project_members, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py index ac560643a..93bab2de3 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -10,7 +10,15 @@ from rest_framework.response import Response # Module imports from .base import BaseAPIView -from plane.db.models import Workspace, Project, Issue, Cycle, Module, Page, IssueView +from plane.db.models import ( + Workspace, + Project, + Issue, + Cycle, + Module, + Page, + IssueView, +) from plane.utils.issue_search import search_issues @@ -25,7 +33,9 @@ class GlobalSearchEndpoint(BaseAPIView): for field in fields: q |= Q(**{f"{field}__icontains": query}) return ( - Workspace.objects.filter(q, workspace_member__member=self.request.user) + Workspace.objects.filter( + q, workspace_member__member=self.request.user + ) .distinct() .values("name", "id", "slug") ) @@ -38,7 +48,9 @@ class GlobalSearchEndpoint(BaseAPIView): return ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) | Q(network=2), + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + archived_at__isnull=True, workspace__slug=slug, ) .distinct() @@ -50,7 +62,8 @@ class GlobalSearchEndpoint(BaseAPIView): q = Q() for field in fields: if field == "sequence_id": - sequences = re.findall(r"\d+\.\d+|\d+", query) + # 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: @@ -59,6 +72,8 @@ class GlobalSearchEndpoint(BaseAPIView): 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, ) @@ -83,6 +98,8 @@ class GlobalSearchEndpoint(BaseAPIView): 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, ) @@ -106,6 +123,8 @@ class GlobalSearchEndpoint(BaseAPIView): 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, ) @@ -129,6 +148,8 @@ class GlobalSearchEndpoint(BaseAPIView): pages = Page.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, workspace__slug=slug, ) @@ -152,6 +173,8 @@ class GlobalSearchEndpoint(BaseAPIView): 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, ) @@ -168,7 +191,9 @@ class GlobalSearchEndpoint(BaseAPIView): def get(self, request, slug): query = request.query_params.get("search", False) - workspace_search = request.query_params.get("workspace_search", "false") + workspace_search = request.query_params.get( + "workspace_search", "false" + ) project_id = request.query_params.get("project_id", False) if not query: @@ -208,18 +233,23 @@ class GlobalSearchEndpoint(BaseAPIView): class IssueSearchEndpoint(BaseAPIView): def get(self, request, slug, project_id): query = request.query_params.get("search", False) - workspace_search = request.query_params.get("workspace_search", "false") + workspace_search = request.query_params.get( + "workspace_search", "false" + ) parent = request.query_params.get("parent", "false") issue_relation = request.query_params.get("issue_relation", "false") cycle = request.query_params.get("cycle", "false") - module = request.query_params.get("module", "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": @@ -231,11 +261,7 @@ class IssueSearchEndpoint(BaseAPIView): if parent == "true" and issue_id: issue = Issue.issue_objects.get(pk=issue_id) issues = issues.filter( - ~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True - ).exclude( - pk__in=Issue.issue_objects.filter(parent__isnull=False).values_list( - "parent_id", flat=True - ) + ~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id) ) if issue_relation == "true" and issue_id: issue = Issue.issue_objects.get(pk=issue_id) @@ -253,13 +279,17 @@ class IssueSearchEndpoint(BaseAPIView): if cycle == "true": issues = issues.exclude(issue_cycle__isnull=False) - if module == "true": - issues = issues.exclude(issue_module__isnull=False) + if module: + issues = issues.exclude(issue_module__module=module) + + if target_date == "none": + issues = issues.filter(target_date__isnull=True) return Response( issues.values( "name", "id", + "start_date", "sequence_id", "project__name", "project__identifier", diff --git a/apiserver/plane/app/views/state.py b/apiserver/plane/app/views/state/base.py similarity index 70% rename from apiserver/plane/app/views/state.py rename to apiserver/plane/app/views/state/base.py index f7226ba6e..b488d9efb 100644 --- a/apiserver/plane/app/views/state.py +++ b/apiserver/plane/app/views/state/base.py @@ -1,18 +1,18 @@ # Python imports from itertools import groupby -# Django imports -from django.db.models import Q - # Third party imports from rest_framework.response import Response from rest_framework import status # Module imports -from . import BaseViewSet +from .. import BaseViewSet from plane.app.serializers import StateSerializer -from plane.app.permissions import ProjectEntityPermission +from plane.app.permissions import ( + ProjectEntityPermission, +) from plane.db.models import State, Issue +from plane.utils.cache import invalidate_cache class StateViewSet(BaseViewSet): @@ -22,22 +22,26 @@ class StateViewSet(BaseViewSet): ProjectEntityPermission, ] - def perform_create(self, serializer): - serializer.save(project_id=self.kwargs.get("project_id")) - def get_queryset(self): return self.filter_queryset( super() .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) - .filter(~Q(name="Triage")) + .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 + ) def create(self, request, slug, project_id): serializer = StateSerializer(data=request.data) if serializer.is_valid(): @@ -58,6 +62,9 @@ class StateViewSet(BaseViewSet): return Response(state_dict, status=status.HTTP_200_OK) return Response(states, status=status.HTTP_200_OK) + @invalidate_cache( + path="workspaces/:slug/states/", url_params=True, user=False + ) def mark_as_default(self, request, slug, project_id, pk): # Select all the states which are marked as default _ = State.objects.filter( @@ -68,25 +75,33 @@ class StateViewSet(BaseViewSet): ).update(default=True) return Response(status=status.HTTP_204_NO_CONTENT) + @invalidate_cache( + path="workspaces/:slug/states/", url_params=True, user=False + ) def destroy(self, request, slug, project_id, pk): state = State.objects.get( - ~Q(name="Triage"), + 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) + 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"}, + { + "error": "The state is not empty, only empty states can be deleted" + }, status=status.HTTP_400_BAD_REQUEST, ) state.delete() - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/user.py b/apiserver/plane/app/views/user.py deleted file mode 100644 index 008780526..000000000 --- a/apiserver/plane/app/views/user.py +++ /dev/null @@ -1,161 +0,0 @@ -# Third party imports -from rest_framework.response import Response -from rest_framework import status - - -# Module imports -from plane.app.serializers import ( - UserSerializer, - IssueActivitySerializer, - UserMeSerializer, - UserMeSettingsSerializer, -) - -from plane.app.views.base import BaseViewSet, BaseAPIView -from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember -from plane.license.models import Instance, InstanceAdmin -from plane.utils.paginator import BasePaginator - - -from django.db.models import Q, F, Count, Case, When, IntegerField - - -class UserEndpoint(BaseViewSet): - serializer_class = UserSerializer - model = User - - def get_object(self): - return self.request.user - - def retrieve(self, request): - serialized_data = UserMeSerializer(request.user).data - return Response( - serialized_data, - status=status.HTTP_200_OK, - ) - - def retrieve_user_settings(self, request): - serialized_data = UserMeSettingsSerializer(request.user).data - return Response(serialized_data, status=status.HTTP_200_OK) - - def retrieve_instance_admin(self, request): - instance = Instance.objects.first() - is_admin = InstanceAdmin.objects.filter( - instance=instance, user=request.user - ).exists() - return Response({"is_instance_admin": is_admin}, status=status.HTTP_200_OK) - - def deactivate(self, request): - # Check all workspace user is active - user = self.get_object() - - # 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 - ) - - # Deactivate the user - user.is_active = False - user.last_workspace_id = None - user.is_tour_completed = False - user.is_onboarded = False - user.onboarding_step = { - "workspace_join": False, - "profile_complete": False, - "workspace_create": False, - "workspace_invite": False, - } - user.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class UpdateUserOnBoardedEndpoint(BaseAPIView): - def patch(self, request): - user = User.objects.get(pk=request.user.id, is_active=True) - user.is_onboarded = request.data.get("is_onboarded", False) - user.save() - return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) - - -class UpdateUserTourCompletedEndpoint(BaseAPIView): - def patch(self, request): - user = User.objects.get(pk=request.user.id, is_active=True) - user.is_tour_completed = request.data.get("is_tour_completed", False) - user.save() - return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) - - -class UserActivityEndpoint(BaseAPIView, BasePaginator): - def get(self, request): - queryset = IssueActivity.objects.filter(actor=request.user).select_related( - "actor", "workspace", "issue", "project" - ) - - return self.paginate( - request=request, - queryset=queryset, - on_results=lambda issue_activities: IssueActivitySerializer( - issue_activities, many=True - ).data, - ) - diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py new file mode 100644 index 000000000..de1559b0c --- /dev/null +++ b/apiserver/plane/app/views/user/base.py @@ -0,0 +1,297 @@ +# 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.cache import cache_response, invalidate_cache +from plane.utils.paginator import BasePaginator +from plane.authentication.utils.host import user_ip + + +class UserEndpoint(BaseViewSet): + serializer_class = UserSerializer + model = User + + def get_object(self): + return self.request.user + + @cache_response(60 * 60) + def retrieve(self, request): + serialized_data = UserMeSerializer(request.user).data + return Response( + serialized_data, + status=status.HTTP_200_OK, + ) + + @cache_response(60 * 60) + def retrieve_user_settings(self, request): + serialized_data = UserMeSettingsSerializer(request.user).data + return Response(serialized_data, status=status.HTTP_200_OK) + + @cache_response(60 * 60) + def retrieve_instance_admin(self, request): + instance = Instance.objects.first() + is_admin = InstanceAdmin.objects.filter( + instance=instance, user=request.user + ).exists() + return Response( + {"is_instance_admin": is_admin}, status=status.HTTP_200_OK + ) + + @invalidate_cache( + path="/api/users/me/", + ) + @invalidate_cache( + path="/api/users/me/settings/", + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @invalidate_cache(path="/api/users/me/") + 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() + + # 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): + + @invalidate_cache(path="/api/users/me/") + def patch(self, request): + profile = Profile.objects.get(user_id=request.user.id) + profile.is_onboarded = request.data.get("is_onboarded", False) + profile.save() + return Response( + {"message": "Updated successfully"}, status=status.HTTP_200_OK + ) + + +class UpdateUserTourCompletedEndpoint(BaseAPIView): + + @invalidate_cache(path="/api/users/me/") + def patch(self, request): + profile = Profile.objects.get(user_id=request.user.id) + profile.is_tour_completed = request.data.get( + "is_tour_completed", False + ) + profile.save() + return Response( + {"message": "Updated successfully"}, status=status.HTTP_200_OK + ) + + +class UserActivityEndpoint(BaseAPIView, BasePaginator): + + def get(self, request): + queryset = IssueActivity.objects.filter( + actor=request.user + ).select_related("actor", "workspace", "issue", "project") + + return self.paginate( + request=request, + queryset=queryset, + on_results=lambda issue_activities: IssueActivitySerializer( + issue_activities, many=True + ).data, + ) + + +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): + def get(self, request): + profile = Profile.objects.get(user=request.user) + serializer = ProfileSerializer(profile) + return Response(serializer.data, status=status.HTTP_200_OK) + + @invalidate_cache("/api/users/me/settings/") + def patch(self, request): + profile = Profile.objects.get(user=request.user) + serializer = ProfileSerializer( + profile, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/view.py b/apiserver/plane/app/views/view/base.py similarity index 52% rename from apiserver/plane/app/views/view.py rename to apiserver/plane/app/views/view/base.py index eb76407b7..72c27d20a 100644 --- a/apiserver/plane/app/views/view.py +++ b/apiserver/plane/app/views/view/base.py @@ -1,6 +1,6 @@ # Django imports from django.db.models import ( - Prefetch, + Q, OuterRef, Func, F, @@ -13,38 +13,39 @@ from django.db.models import ( ) from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.db.models import Prefetch, OuterRef, Exists +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import UUIDField +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 BaseViewSet, BaseAPIView +from .. import BaseViewSet from plane.app.serializers import ( - GlobalViewSerializer, IssueViewSerializer, - IssueLiteSerializer, - IssueViewFavoriteSerializer, + IssueSerializer, +) +from plane.app.permissions import ( + WorkspaceEntityPermission, + ProjectEntityPermission, ) -from plane.app.permissions import WorkspaceEntityPermission, ProjectEntityPermission from plane.db.models import ( Workspace, - GlobalView, IssueView, Issue, - IssueViewFavorite, - IssueReaction, + UserFavorite, IssueLink, IssueAttachment, ) from plane.utils.issue_filters import issue_filters -from plane.utils.grouper import group_results - +from plane.utils.user_timezone_converter import user_timezone_converter class GlobalViewViewSet(BaseViewSet): - serializer_class = GlobalViewSerializer - model = GlobalView + serializer_class = IssueViewSerializer + model = IssueView permission_classes = [ WorkspaceEntityPermission, ] @@ -58,6 +59,7 @@ class GlobalViewViewSet(BaseViewSet): super() .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project__isnull=True) .select_related("workspace") .order_by(self.request.GET.get("order_by", "-created_at")) .distinct() @@ -72,43 +74,21 @@ class GlobalViewIssuesViewSet(BaseViewSet): def get_queryset(self): return ( Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, ) - ) - - @method_decorator(gzip_page) - def list(self, request, slug): - filters = issue_filters(request.query_params, "GET") - fields = [field for field in request.GET.get("fields", "").split(",") if field] - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - self.get_queryset() - .filter(**filters) - .filter(project__project_projectmember__member=self.request.user) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -116,17 +96,78 @@ class GlobalViewIssuesViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ) + + @method_decorator(gzip_page) + def list(self, request, slug): + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + self.get_queryset() + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) ) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] + priority_order + if order_by_param == "priority" + else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -174,17 +215,50 @@ class GlobalViewIssuesViewSet(BaseViewSet): else order_by_param ) ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" + "-max_values" + if order_by_param.startswith("-") + else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response( - issue_dict, - status=status.HTTP_200_OK, - ) + if self.fields: + issues = IssueSerializer( + issue_queryset, many=True, fields=self.fields + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + return Response(issues, status=status.HTTP_200_OK) class IssueViewViewSet(BaseViewSet): @@ -198,9 +272,10 @@ class IssueViewViewSet(BaseViewSet): serializer.save(project_id=self.kwargs.get("project_id")) def get_queryset(self): - subquery = IssueViewFavorite.objects.filter( + subquery = UserFavorite.objects.filter( user=self.request.user, - view_id=OuterRef("pk"), + entity_identifier=OuterRef("pk"), + entity_type="view", project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) @@ -209,7 +284,11 @@ class IssueViewViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) .select_related("project") .select_related("workspace") .annotate(is_favorite=Exists(subquery)) @@ -217,10 +296,21 @@ class IssueViewViewSet(BaseViewSet): .distinct() ) + def list(self, request, slug, project_id): + queryset = self.get_queryset() + 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) + class IssueViewFavoriteViewSet(BaseViewSet): - serializer_class = IssueViewFavoriteSerializer - model = IssueViewFavorite + model = UserFavorite def get_queryset(self): return self.filter_queryset( @@ -232,18 +322,21 @@ class IssueViewFavoriteViewSet(BaseViewSet): ) def create(self, request, slug, project_id): - serializer = IssueViewFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=request.user, project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + _ = 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) def destroy(self, request, slug, project_id, view_id): - view_favourite = IssueViewFavorite.objects.get( + view_favorite = UserFavorite.objects.get( project=project_id, user=request.user, workspace__slug=slug, - view_id=view_id, + entity_type="view", + entity_identifier=view_id, ) - view_favourite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + view_favorite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/webhook.py b/apiserver/plane/app/views/webhook/base.py similarity index 93% rename from apiserver/plane/app/views/webhook.py rename to apiserver/plane/app/views/webhook/base.py index 48608d583..9586722a0 100644 --- a/apiserver/plane/app/views/webhook.py +++ b/apiserver/plane/app/views/webhook/base.py @@ -8,7 +8,7 @@ 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 ..base import BaseAPIView from plane.app.permissions import WorkspaceOwnerPermission from plane.app.serializers import WebhookSerializer, WebhookLogSerializer @@ -26,8 +26,12 @@ class WebhookEndpoint(BaseAPIView): ) if serializer.is_valid(): serializer.save(workspace_id=workspace.id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) except IntegrityError as e: if "already exists" in str(e): return Response( @@ -37,7 +41,7 @@ class WebhookEndpoint(BaseAPIView): raise IntegrityError def get(self, request, slug, pk=None): - if pk == None: + if pk is None: webhooks = Webhook.objects.filter(workspace__slug=slug) serializer = WebhookSerializer( webhooks, diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py deleted file mode 100644 index 11170114a..000000000 --- a/apiserver/plane/app/views/workspace.py +++ /dev/null @@ -1,1351 +0,0 @@ -# Python imports -import jwt -from datetime import date, datetime -from dateutil.relativedelta import relativedelta - -# Django imports -from django.db import IntegrityError -from django.conf import settings -from django.utils import timezone -from django.core.exceptions import ValidationError -from django.core.validators import validate_email -from django.db.models import ( - Prefetch, - OuterRef, - Func, - F, - Q, - Count, - Case, - Value, - CharField, - When, - Max, - IntegerField, -) -from django.db.models.functions import ExtractWeek, Cast, ExtractDay -from django.db.models.fields import DateField - -# Third party modules -from rest_framework import status -from rest_framework.response import Response -from rest_framework.permissions import AllowAny - -# Module imports -from plane.app.serializers import ( - WorkSpaceSerializer, - WorkSpaceMemberSerializer, - TeamSerializer, - WorkSpaceMemberInviteSerializer, - UserLiteSerializer, - ProjectMemberSerializer, - WorkspaceThemeSerializer, - IssueActivitySerializer, - IssueLiteSerializer, - WorkspaceMemberAdminSerializer, - WorkspaceMemberMeSerializer, -) -from plane.app.views.base import BaseAPIView -from . import BaseViewSet -from plane.db.models import ( - User, - Workspace, - WorkspaceMemberInvite, - Team, - ProjectMember, - IssueActivity, - Issue, - WorkspaceTheme, - IssueLink, - IssueAttachment, - IssueSubscriber, - Project, - Label, - WorkspaceMember, - CycleIssue, - IssueReaction, -) -from plane.app.permissions import ( - WorkSpaceBasePermission, - WorkSpaceAdminPermission, - WorkspaceEntityPermission, - WorkspaceViewerPermission, - WorkspaceUserPermission, -) -from plane.bgtasks.workspace_invitation_task import workspace_invitation -from plane.utils.issue_filters import issue_filters -from plane.bgtasks.event_tracking_task import workspace_invite_event - -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") - ) - - issue_count = ( - Issue.issue_objects.filter(workspace=OuterRef("id")) - .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) - .annotate(total_issues=issue_count) - .select_related("owner") - ) - - def create(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(): - serializer.save(owner=request.user) - # Create Workspace member - _ = WorkspaceMember.objects.create( - workspace_id=serializer.data["id"], - member=request.user, - role=20, - company_role=request.data.get("company_role", ""), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response( - [serializer.errors[error][0] for error in serializer.errors], - status=status.HTTP_400_BAD_REQUEST, - ) - - except IntegrityError as e: - if "already exists" in str(e): - return Response( - {"slug": "The workspace with the slug already exists"}, - status=status.HTTP_410_GONE, - ) - - -class UserWorkSpacesEndpoint(BaseAPIView): - search_fields = [ - "name", - ] - filterset_fields = [ - "owner", - ] - - def get(self, request): - 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") - ) - - issue_count = ( - Issue.issue_objects.filter(workspace=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - - workspace = ( - Workspace.objects.prefetch_related( - Prefetch( - "workspace_member", - queryset=WorkspaceMember.objects.filter( - member=request.user, is_active=True - ), - ) - ) - .select_related("owner") - .annotate(total_members=member_count) - .annotate(total_issues=issue_count) - .filter( - workspace_member__member=request.user, workspace_member__is_active=True - ) - .distinct() - ) - - serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True) - return Response(serializer.data, 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() - return Response({"status": not workspace}, status=status.HTTP_200_OK) - - -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", 10)) > 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", "workspace", "workspace__owner") - - 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", 10), - created_by=request.user, - ) - ) - except ValidationError: - return Response( - { - "error": f"Invalid email - {email} provided a valid email address is required to send the invite" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - # Create workspace member invite - workspace_invitations = WorkspaceMemberInvite.objects.bulk_create( - workspace_invitations, batch_size=10, ignore_conflicts=True - ) - - current_site = request.META.get("HTTP_ORIGIN") - - # 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""" - - 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=request.META.get("REMOTE_ADDR"), - event_name="MEMBER_ACCEPTED", - accepted_from="EMAIL", - ) - - return Response( - {"message": "Workspace Invitation Accepted"}, - status=status.HTTP_200_OK, - ) - - # Workspace invitation rejected - return Response( - {"message": "Workspace Invitation was not accepted"}, - status=status.HTTP_200_OK, - ) - - 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", "workspace__owner", "created_by") - .annotate(total_members=Count("workspace__workspace_member")) - ) - - 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: - # 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) - - -class WorkSpaceMemberViewSet(BaseViewSet): - serializer_class = WorkspaceMemberAdminSerializer - model = WorkspaceMember - - permission_classes = [ - WorkspaceEntityPermission, - ] - - def get_permissions(self): - if self.action == "leave": - self.permission_classes = [ - WorkspaceUserPermission, - ] - else: - self.permission_classes = [ - WorkspaceEntityPermission, - ] - - return super(WorkSpaceMemberViewSet, self).get_permissions() - - 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"), - member__is_bot=False, - is_active=True, - ) - .select_related("workspace", "workspace__owner") - .select_related("member") - ) - - def list(self, request, slug): - workspace_member = WorkspaceMember.objects.get( - member=request.user, - workspace__slug=slug, - is_active=True, - ) - - # Get all active workspace members - workspace_members = self.get_queryset() - - if workspace_member.role > 10: - serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True) - else: - serializer = WorkSpaceMemberSerializer( - workspace_members, - many=True, - ) - return Response(serializer.data, status=status.HTTP_200_OK) - - def partial_update(self, request, slug, pk): - workspace_member = WorkspaceMember.objects.get( - pk=pk, - workspace__slug=slug, - 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, - ) - - # Get the requested user role - requested_workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, - member=request.user, - is_active=True, - ) - # Check if role is being updated - # One cannot update role higher than his own role - if ( - "role" in request.data - and int(request.data.get("role", workspace_member.role)) - > requested_workspace_member.role - ): - return Response( - {"error": "You cannot update a role that is higher than your own role"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - 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) - - 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." - }, - 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) - - workspace_member.is_active = False - workspace_member.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - def leave(self, request, slug): - workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, - member=request.user, - is_active=True, - ) - - # Check if the leaving user is the only admin of the workspace - if ( - workspace_member.role == 20 - and not WorkspaceMember.objects.filter( - workspace__slug=slug, - role=20, - is_active=True, - ).count() - > 1 - ): - return Response( - { - "error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - if ( - Project.objects.annotate( - total_members=Count("project_projectmember"), - member_with_role=Count( - "project_projectmember", - filter=Q( - project_projectmember__member_id=request.user.id, - project_projectmember__role=20, - ), - ), - ) - .filter(total_members=1, member_with_role=1, workspace__slug=slug) - .exists() - ): - return Response( - { - "error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # # Deactivate the users from the projects where the user is part of - _ = ProjectMember.objects.filter( - workspace__slug=slug, - member_id=workspace_member.member_id, - is_active=True, - ).update(is_active=False) - - # # Deactivate the user - workspace_member.is_active = False - workspace_member.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class TeamMemberViewSet(BaseViewSet): - serializer_class = TeamSerializer - model = Team - permission_classes = [ - WorkSpaceAdminPermission, - ] - - search_fields = [ - "member__display_name", - "member__first_name", - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("workspace", "workspace__owner") - .prefetch_related("members") - ) - - def create(self, request, slug): - members = list( - WorkspaceMember.objects.filter( - workspace__slug=slug, - member__id__in=request.data.get("members", []), - is_active=True, - ) - .annotate(member_str_id=Cast("member", output_field=CharField())) - .distinct() - .values_list("member_str_id", flat=True) - ) - - if len(members) != len(request.data.get("members", [])): - users = list(set(request.data.get("members", [])).difference(members)) - users = User.objects.filter(pk__in=users) - - serializer = UserLiteSerializer(users, many=True) - return Response( - { - "error": f"{len(users)} of the member(s) are not a part of the workspace", - "members": serializer.data, - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - workspace = Workspace.objects.get(slug=slug) - - serializer = TeamSerializer(data=request.data, context={"workspace": workspace}) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -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 WorkspaceMemberUserEndpoint(BaseAPIView): - def get(self, request, slug): - workspace_member = WorkspaceMember.objects.get( - member=request.user, - workspace__slug=slug, - is_active=True, - ) - serializer = WorkspaceMemberMeSerializer(workspace_member) - return Response(serializer.data, status=status.HTTP_200_OK) - - -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 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) - - -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 WorkspaceUserProfileStatsEndpoint(BaseAPIView): - def get(self, request, slug, user_id): - filters = issue_filters(request.query_params, "GET") - - state_distribution = ( - Issue.issue_objects.filter( - workspace__slug=slug, - assignees__in=[user_id], - project__project_projectmember__member=request.user, - ) - .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( - workspace__slug=slug, - assignees__in=[user_id], - project__project_projectmember__member=request.user, - ) - .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, - created_by_id=user_id, - ) - .filter(**filters) - .count() - ) - - assigned_issues_count = ( - Issue.issue_objects.filter( - workspace__slug=slug, - assignees__in=[user_id], - project__project_projectmember__member=request.user, - ) - .filter(**filters) - .count() - ) - - pending_issues_count = ( - Issue.issue_objects.filter( - ~Q(state__group__in=["completed", "cancelled"]), - workspace__slug=slug, - assignees__in=[user_id], - project__project_projectmember__member=request.user, - ) - .filter(**filters) - .count() - ) - - completed_issues_count = ( - Issue.issue_objects.filter( - workspace__slug=slug, - assignees__in=[user_id], - state__group="completed", - project__project_projectmember__member=request.user, - ) - .filter(**filters) - .count() - ) - - subscribed_issues_count = ( - IssueSubscriber.objects.filter( - workspace__slug=slug, - subscriber_id=user_id, - project__project_projectmember__member=request.user, - ) - .filter(**filters) - .count() - ) - - upcoming_cycles = CycleIssue.objects.filter( - workspace__slug=slug, - cycle__start_date__gt=timezone.now().date(), - issue__assignees__in=[ - user_id, - ], - ).values("cycle__name", "cycle__id", "cycle__project_id") - - present_cycle = CycleIssue.objects.filter( - workspace__slug=slug, - cycle__start_date__lt=timezone.now().date(), - cycle__end_date__gt=timezone.now().date(), - issue__assignees__in=[ - user_id, - ], - ).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 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, - actor=user_id, - ).select_related("actor", "workspace", "issue", "project") - - if projects: - queryset = queryset.filter(project__in=projects) - - return self.paginate( - request=request, - queryset=queryset, - on_results=lambda issue_activities: IssueActivitySerializer( - issue_activities, many=True - ).data, - ) - - -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 >= 10: - projects = ( - Project.objects.filter( - workspace__slug=slug, - project_projectmember__member=request.user, - ) - .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", - "name", - "identifier", - "emoji", - "icon_prop", - "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": user_data.avatar, - "cover_image": user_data.cover_image, - "date_joined": user_data.date_joined, - "user_timezone": user_data.user_timezone, - "display_name": user_data.display_name, - }, - }, - status=status.HTTP_200_OK, - ) - - -class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceViewerPermission, - ] - - def get(self, request, slug, user_id): - fields = [field for field in request.GET.get("fields", "").split(",") if field] - filters = issue_filters(request.query_params, "GET") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = ( - 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, - project__project_projectmember__member=request.user, - ) - .filter(**filters) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .select_related("project", "workspace", "state", "parent") - .prefetch_related("assignees", "labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - .order_by("-created_at") - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ).distinct() - - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssueLiteSerializer( - issue_queryset, many=True, fields=fields if fields else None - ).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) - - -class WorkspaceLabelsEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceViewerPermission, - ] - - def get(self, request, slug): - labels = Label.objects.filter( - workspace__slug=slug, - project__project_projectmember__member=request.user, - ).values("parent", "name", "color", "id", "project_id", "workspace__slug") - return Response(labels, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py new file mode 100644 index 000000000..830ae1dc2 --- /dev/null +++ b/apiserver/plane/app/views/workspace/base.py @@ -0,0 +1,423 @@ +# Python imports +import csv +import io +from datetime import date + +from dateutil.relativedelta import relativedelta +from django.db import IntegrityError +from django.db.models import ( + Count, + F, + Func, + OuterRef, + Prefetch, + Q, +) +from django.db.models.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, +) +from plane.utils.cache import cache_response, invalidate_cache + + +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") + ) + + issue_count = ( + Issue.issue_objects.filter(workspace=OuterRef("id")) + .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) + .annotate(total_issues=issue_count) + .select_related("owner") + ) + + @invalidate_cache(path="/api/workspaces/", user=False) + @invalidate_cache(path="/api/users/me/workspaces/") + @invalidate_cache(path="/api/instances/", user=False) + def create(self, request): + try: + 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(): + serializer.save(owner=request.user) + # Create Workspace member + _ = WorkspaceMember.objects.create( + workspace_id=serializer.data["id"], + member=request.user, + role=20, + company_role=request.data.get("company_role", ""), + ) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) + return Response( + [serializer.errors[error][0] for error in serializer.errors], + status=status.HTTP_400_BAD_REQUEST, + ) + + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"slug": "The workspace with the slug already exists"}, + status=status.HTTP_410_GONE, + ) + + @cache_response(60 * 60 * 2) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @invalidate_cache(path="/api/workspaces/", user=False) + @invalidate_cache(path="/api/users/me/workspaces/") + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @invalidate_cache(path="/api/workspaces/", user=False) + @invalidate_cache( + path="/api/users/me/workspaces/", multiple=True, user=False + ) + @invalidate_cache( + path="/api/users/me/settings/", multiple=True, user=False + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + +class UserWorkSpacesEndpoint(BaseAPIView): + search_fields = [ + "name", + ] + filterset_fields = [ + "owner", + ] + + @cache_response(60 * 60 * 2) + 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") + ) + + issue_count = ( + Issue.issue_objects.filter(workspace=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + workspace = ( + Workspace.objects.prefetch_related( + Prefetch( + "workspace_member", + queryset=WorkspaceMember.objects.filter( + member=request.user, is_active=True + ), + ) + ) + .select_related("owner") + .annotate(total_members=member_count) + .annotate(total_issues=issue_count) + .filter( + workspace_member__member=request.user, + workspace_member__is_active=True, + ) + .distinct() + ) + 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() + 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, + 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/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py new file mode 100644 index 000000000..fa2954d67 --- /dev/null +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -0,0 +1,117 @@ +# Django imports +from django.db.models import ( + Q, + Count, + Sum, +) + +# 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, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .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/apiserver/plane/app/views/workspace/estimate.py b/apiserver/plane/app/views/workspace/estimate.py new file mode 100644 index 000000000..59a23d867 --- /dev/null +++ b/apiserver/plane/app/views/workspace/estimate.py @@ -0,0 +1,30 @@ +# 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, + ] + + @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/apiserver/plane/app/views/workspace/invite.py b/apiserver/plane/app/views/workspace/invite.py new file mode 100644 index 000000000..d3511a865 --- /dev/null +++ b/apiserver/plane/app/views/workspace/invite.py @@ -0,0 +1,314 @@ +# 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.db.models import Count +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 .. 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", 10)) > 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", "workspace", "workspace__owner") + + 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", 10), + created_by=request.user, + ) + ) + except ValidationError: + return Response( + { + "error": f"Invalid email - {email} provided a valid email address is required to send the invite" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Create workspace member invite + workspace_invitations = WorkspaceMemberInvite.objects.bulk_create( + workspace_invitations, batch_size=10, ignore_conflicts=True + ) + + current_site = request.META.get("HTTP_ORIGIN") + + # 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=request.META.get("REMOTE_ADDR"), + event_name="MEMBER_ACCEPTED", + accepted_from="EMAIL", + ) + + return Response( + {"message": "Workspace Invitation Accepted"}, + status=status.HTTP_200_OK, + ) + + # Workspace invitation rejected + return Response( + {"message": "Workspace Invitation was not accepted"}, + status=status.HTTP_200_OK, + ) + + 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", "workspace__owner", "created_by") + .annotate(total_members=Count("workspace__workspace_member")) + ) + + @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/apiserver/plane/app/views/workspace/label.py b/apiserver/plane/app/views/workspace/label.py new file mode 100644 index 000000000..328f3f8c1 --- /dev/null +++ b/apiserver/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, + ] + + @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/apiserver/plane/app/views/workspace/member.py b/apiserver/plane/app/views/workspace/member.py new file mode 100644 index 000000000..39b2f3d98 --- /dev/null +++ b/apiserver/plane/app/views/workspace/member.py @@ -0,0 +1,415 @@ +# Django imports +from django.db.models import ( + CharField, + Count, + Q, +) +from django.db.models.functions import Cast + +# Third party modules +from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import ( + WorkSpaceAdminPermission, + WorkspaceEntityPermission, + WorkspaceUserPermission, +) + +# Module imports +from plane.app.serializers import ( + ProjectMemberRoleSerializer, + TeamSerializer, + UserLiteSerializer, + WorkspaceMemberAdminSerializer, + WorkspaceMemberMeSerializer, + WorkSpaceMemberSerializer, +) +from plane.app.views.base import BaseAPIView +from plane.db.models import ( + Project, + ProjectMember, + Team, + User, + Workspace, + WorkspaceMember, +) +from plane.utils.cache import cache_response, invalidate_cache + +from .. import BaseViewSet + + +class WorkSpaceMemberViewSet(BaseViewSet): + serializer_class = WorkspaceMemberAdminSerializer + model = WorkspaceMember + + permission_classes = [ + WorkspaceEntityPermission, + ] + + def get_permissions(self): + if self.action == "leave": + self.permission_classes = [ + WorkspaceUserPermission, + ] + else: + self.permission_classes = [ + WorkspaceEntityPermission, + ] + + return super(WorkSpaceMemberViewSet, self).get_permissions() + + search_fields = [ + "member__display_name", + "member__first_name", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ) + .select_related("workspace", "workspace__owner") + .select_related("member") + ) + + @cache_response(60 * 60 * 2) + 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 > 10: + 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) + + @invalidate_cache( + path="/api/workspaces/:slug/members/", + url_params=True, + user=False, + multiple=True, + ) + 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, + ) + + # Get the requested user role + requested_workspace_member = WorkspaceMember.objects.get( + workspace__slug=slug, + member=request.user, + is_active=True, + ) + # Check if role is being updated + # One cannot update role higher than his own role + if ( + "role" in request.data + and int(request.data.get("role", workspace_member.role)) + > requested_workspace_member.role + ): + return Response( + { + "error": "You cannot update a role that is higher than your own role" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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) + + @invalidate_cache( + path="/api/workspaces/:slug/members/", + url_params=True, + user=False, + multiple=True, + ) + @invalidate_cache(path="/api/users/me/settings/", multiple=True) + @invalidate_cache( + path="/api/users/me/workspaces/", user=False, multiple=True + ) + 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." + }, + 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) + + 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 + ) + def leave(self, request, slug): + workspace_member = WorkspaceMember.objects.get( + workspace__slug=slug, + member=request.user, + is_active=True, + ) + + # Check if the leaving user is the only admin of the workspace + if ( + workspace_member.role == 20 + and not WorkspaceMember.objects.filter( + workspace__slug=slug, + role=20, + is_active=True, + ).count() + > 1 + ): + return Response( + { + "error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if ( + Project.objects.annotate( + total_members=Count("project_projectmember"), + member_with_role=Count( + "project_projectmember", + filter=Q( + project_projectmember__member_id=request.user.id, + project_projectmember__role=20, + ), + ), + ) + .filter(total_members=1, member_with_role=1, workspace__slug=slug) + .exists() + ): + return Response( + { + "error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # # Deactivate the users from the projects where the user is part of + _ = ProjectMember.objects.filter( + workspace__slug=slug, + member_id=workspace_member.member_id, + is_active=True, + ).update(is_active=False) + + # # Deactivate the user + workspace_member.is_active = False + workspace_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +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): + def get(self, request, slug): + workspace_member = WorkspaceMember.objects.get( + member=request.user, + workspace__slug=slug, + is_active=True, + ) + 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) + + +class TeamMemberViewSet(BaseViewSet): + serializer_class = TeamSerializer + model = Team + permission_classes = [ + WorkSpaceAdminPermission, + ] + + search_fields = [ + "member__display_name", + "member__first_name", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "workspace__owner") + .prefetch_related("members") + ) + + def create(self, request, slug): + members = list( + WorkspaceMember.objects.filter( + workspace__slug=slug, + member__id__in=request.data.get("members", []), + is_active=True, + ) + .annotate(member_str_id=Cast("member", output_field=CharField())) + .distinct() + .values_list("member_str_id", flat=True) + ) + + if len(members) != len(request.data.get("members", [])): + users = list( + set(request.data.get("members", [])).difference(members) + ) + users = User.objects.filter(pk__in=users) + + serializer = UserLiteSerializer(users, many=True) + return Response( + { + "error": f"{len(users)} of the member(s) are not a part of the workspace", + "members": serializer.data, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + + serializer = TeamSerializer( + data=request.data, context={"workspace": workspace} + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/workspace/module.py b/apiserver/plane/app/views/workspace/module.py new file mode 100644 index 000000000..7671692ec --- /dev/null +++ b/apiserver/plane/app/views/workspace/module.py @@ -0,0 +1,111 @@ +# 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, + ), + 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, + ), + 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, + ), + 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, + ), + 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, + ), + 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, + ), + 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/apiserver/plane/app/views/workspace/state.py b/apiserver/plane/app/views/workspace/state.py new file mode 100644 index 000000000..c69b56d4f --- /dev/null +++ b/apiserver/plane/app/views/workspace/state.py @@ -0,0 +1,27 @@ +# 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 plane.utils.cache import cache_response + +class WorkspaceStatesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceEntityPermission, + ] + + @cache_response(60 * 60 * 2) + 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, + ) + serializer = StateSerializer(states, many=True).data + return Response(serializer, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py new file mode 100644 index 000000000..94a22a1a7 --- /dev/null +++ b/apiserver/plane/app/views/workspace/user.py @@ -0,0 +1,577 @@ +# Python imports +from datetime import date +from dateutil.relativedelta import relativedelta + +# Django imports +from django.utils import timezone +from django.db.models import ( + OuterRef, + Func, + F, + Q, + Count, + Case, + Value, + CharField, + When, + Max, + IntegerField, + UUIDField, +) +from django.db.models.functions import ExtractWeek, Cast +from django.db.models.fields import DateField +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce + +# Third party modules +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.serializers import ( + WorkSpaceSerializer, + ProjectMemberSerializer, + IssueActivitySerializer, + IssueSerializer, + WorkspaceUserPropertiesSerializer, +) +from plane.app.views.base import BaseAPIView +from plane.db.models import ( + User, + Workspace, + ProjectMember, + IssueActivity, + Issue, + IssueLink, + IssueAttachment, + IssueSubscriber, + Project, + WorkspaceMember, + CycleIssue, + WorkspaceUserProperties, +) +from plane.app.permissions import ( + WorkspaceEntityPermission, + WorkspaceViewerPermission, +) +from plane.utils.issue_filters import issue_filters + + +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, + ] + + def get(self, request, slug, user_id): + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + order_by_param = request.GET.get("order_by", "-created_at") + issue_queryset = ( + 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, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + ) + .filter(**filters) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .order_by("created_at") + ).distinct() + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" + if order_by_param.startswith("-") + else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issues = IssueSerializer( + issue_queryset, many=True, fields=fields if fields else None + ).data + return Response(issues, status=status.HTTP_200_OK) + + +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.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 >= 10: + 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": user_data.avatar, + "cover_image": user_data.cover_image, + "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( + 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( + workspace__slug=slug, + assignees__in=[user_id], + 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( + workspace__slug=slug, + assignees__in=[user_id], + 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( + workspace__slug=slug, + assignees__in=[user_id], + 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"]), + workspace__slug=slug, + assignees__in=[user_id], + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + ) + .filter(**filters) + .count() + ) + + completed_issues_count = ( + Issue.issue_objects.filter( + workspace__slug=slug, + assignees__in=[user_id], + 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().date(), + issue__assignees__in=[ + user_id, + ], + ).values("cycle__name", "cycle__id", "cycle__project_id") + + present_cycle = CycleIssue.objects.filter( + workspace__slug=slug, + cycle__start_date__lt=timezone.now().date(), + cycle__end_date__gt=timezone.now().date(), + issue__assignees__in=[ + user_id, + ], + ).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/apiserver/plane/utils/integrations/__init__.py b/apiserver/plane/authentication/__init__.py similarity index 100% rename from apiserver/plane/utils/integrations/__init__.py rename to apiserver/plane/authentication/__init__.py diff --git a/apiserver/plane/authentication/adapter/__init__.py b/apiserver/plane/authentication/adapter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py new file mode 100644 index 000000000..5876e934f --- /dev/null +++ b/apiserver/plane/authentication/adapter/base.py @@ -0,0 +1,193 @@ +# 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 + + +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 = self.request.META.get("REMOTE_ADDR") + user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + 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) + + if not user.is_active: + raise AuthenticationException( + AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + + # 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/apiserver/plane/authentication/adapter/credential.py b/apiserver/plane/authentication/adapter/credential.py new file mode 100644 index 000000000..0327289ca --- /dev/null +++ b/apiserver/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/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py new file mode 100644 index 000000000..55ff10988 --- /dev/null +++ b/apiserver/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 + "GOOGLE_NOT_CONFIGURED": 5105, + "GITHUB_NOT_CONFIGURED": 5110, + "GOOGLE_OAUTH_PROVIDER_ERROR": 5115, + "GITHUB_OAUTH_PROVIDER_ERROR": 5120, + # Reset Password + "INVALID_PASSWORD_TOKEN": 5125, + "EXPIRED_PASSWORD_TOKEN": 5130, + # Change password + "INCORRECT_OLD_PASSWORD": 5135, + "MISSING_PASSWORD": 5138, + "INVALID_NEW_PASSWORD": 5140, + # set passowrd + "PASSWORD_ALREADY_SET": 5145, + # Admin + "ADMIN_ALREADY_EXIST": 5150, + "REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME": 5155, + "INVALID_ADMIN_EMAIL": 5160, + "INVALID_ADMIN_PASSWORD": 5165, + "REQUIRED_ADMIN_EMAIL_PASSWORD": 5170, + "ADMIN_AUTHENTICATION_FAILED": 5175, + "ADMIN_USER_ALREADY_EXIST": 5180, + "ADMIN_USER_DOES_NOT_EXIST": 5185, + "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/apiserver/plane/authentication/adapter/exception.py b/apiserver/plane/authentication/adapter/exception.py new file mode 100644 index 000000000..a6f7637a9 --- /dev/null +++ b/apiserver/plane/authentication/adapter/exception.py @@ -0,0 +1,27 @@ +# 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/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py new file mode 100644 index 000000000..b1a92e79e --- /dev/null +++ b/apiserver/plane/authentication/adapter/oauth.py @@ -0,0 +1,116 @@ +# Python imports +import requests + +# Django imports +from django.utils import timezone + +# Module imports +from plane.db.models import Account + +from .base import Adapter +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +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 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 = ( + "GOOGLE_OAUTH_PROVIDER_ERROR" + if self.provider == "google" + else "GITHUB_OAUTH_PROVIDER_ERROR" + ) + 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: + if self.provider == "google": + code = "GOOGLE_OAUTH_PROVIDER_ERROR" + if self.provider == "github": + code = "GITHUB_OAUTH_PROVIDER_ERROR" + + 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): + account, created = Account.objects.update_or_create( + user=user, + provider=self.provider, + defaults={ + "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", ""), + }, + ) diff --git a/apiserver/plane/authentication/apps.py b/apiserver/plane/authentication/apps.py new file mode 100644 index 000000000..cf5cdca1c --- /dev/null +++ b/apiserver/plane/authentication/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + name = "plane.authentication" diff --git a/apiserver/plane/authentication/middleware/__init__.py b/apiserver/plane/authentication/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/middleware/session.py b/apiserver/plane/authentication/middleware/session.py new file mode 100644 index 000000000..2bb62b881 --- /dev/null +++ b/apiserver/plane/authentication/middleware/session.py @@ -0,0 +1,94 @@ +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/apiserver/plane/authentication/provider/__init__.py b/apiserver/plane/authentication/provider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/provider/credentials/__init__.py b/apiserver/plane/authentication/provider/credentials/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/provider/credentials/email.py b/apiserver/plane/authentication/provider/credentials/email.py new file mode 100644 index 000000000..4c7764128 --- /dev/null +++ b/apiserver/plane/authentication/provider/credentials/email.py @@ -0,0 +1,121 @@ +# 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/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py new file mode 100644 index 000000000..21309ea9c --- /dev/null +++ b/apiserver/plane/authentication/provider/credentials/magic_code.py @@ -0,0 +1,180 @@ +# 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(self.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(self.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/apiserver/plane/authentication/provider/oauth/__init__.py b/apiserver/plane/authentication/provider/oauth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/provider/oauth/github.py b/apiserver/plane/authentication/provider/oauth/github.py new file mode 100644 index 000000000..edd99b1ba --- /dev/null +++ b/apiserver/plane/authentication/provider/oauth/github.py @@ -0,0 +1,148 @@ +# 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" + provider = "github" + scope = "read:user user:email" + + def __init__(self, request, code=None, state=None, callback=None): + + GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value( + [ + { + "key": "GITHUB_CLIENT_ID", + "default": os.environ.get("GITHUB_CLIENT_ID"), + }, + { + "key": "GITHUB_CLIENT_SECRET", + "default": os.environ.get("GITHUB_CLIENT_SECRET"), + }, + ] + ) + + 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 + + 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 set_user_data(self): + user_info_response = self.get_user_response() + headers = { + "Authorization": f"Bearer {self.token_data.get('access_token')}", + "Accept": "application/json", + } + 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/apiserver/plane/authentication/provider/oauth/google.py b/apiserver/plane/authentication/provider/oauth/google.py new file mode 100644 index 000000000..9c17a75af --- /dev/null +++ b/apiserver/plane/authentication/provider/oauth/google.py @@ -0,0 +1,117 @@ +# 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/apiserver/plane/authentication/rate_limit.py b/apiserver/plane/authentication/rate_limit.py new file mode 100644 index 000000000..744bd38fe --- /dev/null +++ b/apiserver/plane/authentication/rate_limit.py @@ -0,0 +1,26 @@ +# 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/apiserver/plane/authentication/session.py b/apiserver/plane/authentication/session.py new file mode 100644 index 000000000..7bb0b4a00 --- /dev/null +++ b/apiserver/plane/authentication/session.py @@ -0,0 +1,8 @@ +from rest_framework.authentication import SessionAuthentication + + +class BaseSessionAuthentication(SessionAuthentication): + + # Disable csrf for the rest apis + def enforce_csrf(self, request): + return diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py new file mode 100644 index 000000000..ee860f41f --- /dev/null +++ b/apiserver/plane/authentication/urls.py @@ -0,0 +1,196 @@ +from django.urls import path + +from .views import ( + CSRFTokenEndpoint, + ForgotPasswordEndpoint, + SetUserPasswordEndpoint, + ResetPasswordEndpoint, + ChangePasswordEndpoint, + # App + EmailCheckEndpoint, + GitHubCallbackEndpoint, + GitHubOauthInitiateEndpoint, + GoogleCallbackEndpoint, + GoogleOauthInitiateEndpoint, + MagicGenerateEndpoint, + MagicSignInEndpoint, + MagicSignUpEndpoint, + SignInAuthEndpoint, + SignOutAuthEndpoint, + SignUpAuthEndpoint, + ForgotPasswordSpaceEndpoint, + ResetPasswordSpaceEndpoint, + # Space + EmailCheckSpaceEndpoint, + 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="sign-in", + ), + path( + "spaces/sign-up/", + SignUpAuthSpaceEndpoint.as_view(), + name="sign-in", + ), + # signout + path( + "sign-out/", + SignOutAuthEndpoint.as_view(), + name="sign-out", + ), + path( + "spaces/sign-out/", + SignOutAuthSpaceEndpoint.as_view(), + name="sign-out", + ), + # csrf token + path( + "get-csrf-token/", + CSRFTokenEndpoint.as_view(), + name="get_csrf_token", + ), + # Magic sign in + path( + "magic-generate/", + MagicGenerateEndpoint.as_view(), + name="magic-generate", + ), + path( + "magic-sign-in/", + MagicSignInEndpoint.as_view(), + name="magic-sign-in", + ), + path( + "magic-sign-up/", + MagicSignUpEndpoint.as_view(), + name="magic-sign-up", + ), + path( + "get-csrf-token/", + CSRFTokenEndpoint.as_view(), + name="get_csrf_token", + ), + path( + "spaces/magic-generate/", + MagicGenerateSpaceEndpoint.as_view(), + name="magic-generate", + ), + path( + "spaces/magic-sign-in/", + MagicSignInSpaceEndpoint.as_view(), + name="magic-sign-in", + ), + path( + "spaces/magic-sign-up/", + MagicSignUpSpaceEndpoint.as_view(), + name="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="google-initiate", + ), + path( + "google/callback/", + GoogleCallbackSpaceEndpoint.as_view(), + name="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="github-initiate", + ), + path( + "spaces/github/callback/", + GitHubCallbackSpaceEndpoint.as_view(), + name="github-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="forgot-password", + ), + path( + "spaces/reset-password///", + ResetPasswordSpaceEndpoint.as_view(), + name="forgot-password", + ), + path( + "change-password/", + ChangePasswordEndpoint.as_view(), + name="forgot-password", + ), + path( + "set-password/", + SetUserPasswordEndpoint.as_view(), + name="set-password", + ), +] diff --git a/apiserver/plane/authentication/utils/host.py b/apiserver/plane/authentication/utils/host.py new file mode 100644 index 000000000..4046c1e20 --- /dev/null +++ b/apiserver/plane/authentication/utils/host.py @@ -0,0 +1,42 @@ +# Python imports +from urllib.parse import urlsplit + +# Django imports +from django.conf import settings + + +def base_host(request, is_admin=False, is_space=False, is_app=False): + """Utility function to return host / origin from the request""" + # Calculate the base origin from request + base_origin = str( + request.META.get("HTTP_ORIGIN") + or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}" + or f"""{"https" if request.is_secure() else "http"}://{request.get_host()}""" + ) + + # Admin redirections + if is_admin: + if settings.ADMIN_BASE_URL: + return settings.ADMIN_BASE_URL + else: + return base_origin + "/god-mode/" + + # Space redirections + if is_space: + if settings.SPACE_BASE_URL: + return settings.SPACE_BASE_URL + else: + return base_origin + "/spaces/" + + # 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): + return str(request.META.get("REMOTE_ADDR")) diff --git a/apiserver/plane/authentication/utils/login.py b/apiserver/plane/authentication/utils/login.py new file mode 100644 index 000000000..f5d453d02 --- /dev/null +++ b/apiserver/plane/authentication/utils/login.py @@ -0,0 +1,28 @@ +# Django imports +from django.contrib.auth import login +from django.conf import settings + +# Module imports +from plane.authentication.utils.host import base_host + + +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": request.META.get("REMOTE_ADDR", ""), + "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/apiserver/plane/authentication/utils/redirection_path.py b/apiserver/plane/authentication/utils/redirection_path.py new file mode 100644 index 000000000..12de25cc2 --- /dev/null +++ b/apiserver/plane/authentication/utils/redirection_path.py @@ -0,0 +1,45 @@ +from plane.db.models import Profile, Workspace, WorkspaceMemberInvite + + +def get_redirection_path(user): + # Handle redirections + profile = Profile.objects.get(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/apiserver/plane/authentication/utils/user_auth_workflow.py b/apiserver/plane/authentication/utils/user_auth_workflow.py new file mode 100644 index 000000000..e7cb4942e --- /dev/null +++ b/apiserver/plane/authentication/utils/user_auth_workflow.py @@ -0,0 +1,9 @@ +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/apiserver/plane/authentication/utils/workspace_project_join.py b/apiserver/plane/authentication/utils/workspace_project_join.py new file mode 100644 index 000000000..3b6f231ed --- /dev/null +++ b/apiserver/plane/authentication/utils/workspace_project_join.py @@ -0,0 +1,83 @@ +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, 10, 15] + else 15 + ), + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=( + project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15 + ), + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() diff --git a/apiserver/plane/authentication/views/__init__.py b/apiserver/plane/authentication/views/__init__.py new file mode 100644 index 000000000..51ea3e60a --- /dev/null +++ b/apiserver/plane/authentication/views/__init__.py @@ -0,0 +1,59 @@ +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.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.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/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py new file mode 100644 index 000000000..5b3ac7337 --- /dev/null +++ b/apiserver/plane/authentication/views/app/check.py @@ -0,0 +1,133 @@ +# 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, + ) + + # 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: + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + return Response( + exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST + ) + + return Response( + { + "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/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py new file mode 100644 index 000000000..f21e431a4 --- /dev/null +++ b/apiserver/plane/authentication/views/app/email.py @@ -0,0 +1,282 @@ +# Python imports +from urllib.parse import urlencode, urljoin + +# 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, +) + + +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() + if next_path: + params["next_path"] = str(next_path) + # Base URL join + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(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 + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # 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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + 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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + provider = EmailProvider( + request=request, + 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 = str(next_path) + else: + path = get_redirection_path(user=user) + + # redirect to referer path + url = urljoin(base_host(request=request, is_app=True), path) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class SignUpAuthEndpoint(View): + + def post(self, request): + next_path = request.POST.get("next_path") + # Check instance configuration + 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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # Existing user + existing_user = User.objects.filter(email=email).first() + + if existing_user: + # Existing User + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], + error_message="USER_ALREADY_EXIST", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + 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) + # redirect to referer path + url = urljoin(base_host(request=request, is_app=True), path) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/github.py b/apiserver/plane/authentication/views/app/github.py new file mode 100644 index 000000000..f93beefa3 --- /dev/null +++ b/apiserver/plane/authentication/views/app/github.py @@ -0,0 +1,131 @@ +import uuid +from urllib.parse import urlencode, urljoin + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.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, +) + + +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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + try: + state = uuid.uuid4().hex + provider = 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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class GitHubCallbackEndpoint(View): + + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + 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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GITHUB_OAUTH_PROVIDER_ERROR" + ], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + 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) + # Get the redirection path + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + # redirect to referer path + url = urljoin(base_host, path) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/google.py b/apiserver/plane/authentication/views/app/google.py new file mode 100644 index 000000000..05f4511e2 --- /dev/null +++ b/apiserver/plane/authentication/views/app/google.py @@ -0,0 +1,126 @@ +# Python imports +import uuid +from urllib.parse import urlencode, urljoin + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + + +# Module imports +from plane.authentication.provider.oauth.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, +) + + +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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = 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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class GoogleCallbackEndpoint(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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GOOGLE_OAUTH_PROVIDER_ERROR" + ], + error_message="GOOGLE_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = next_path + url = urljoin( + base_host, + "?" + urlencode(params), + ) + 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 + path = get_redirection_path(user=user) + # redirect to referer path + url = urljoin(base_host, str(next_path) if next_path else path) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py new file mode 100644 index 000000000..bb3c72534 --- /dev/null +++ b/apiserver/plane/authentication/views/app/magic.py @@ -0,0 +1,233 @@ +# Python imports +from urllib.parse import urlencode, urljoin + +# 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, +) + + +class MagicGenerateEndpoint(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 + ) + + origin = request.META.get("HTTP_ORIGIN", "/") + email = request.data.get("email", False) + try: + # Clean up the email + email = email.strip().lower() + 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, origin) + 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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # 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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + provider = MagicCodeProvider( + request=request, + key=f"magic_{email}", + code=code, + callback=post_user_auth_workflow, + ) + user = provider.authenticate() + profile = Profile.objects.get(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: + path = "accounts/set-password" + else: + # Get the redirection path + path = ( + str(next_path) + if next_path + else str(get_redirection_path(user=user)) + ) + # redirect to referer path + url = urljoin(base_host(request=request, is_app=True), path) + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class MagicSignUpEndpoint(View): + + def post(self, request): + + # set the referer as session to redirect after login + code = request.POST.get("code", "").strip() + email = request.POST.get("email", "").strip().lower() + 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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(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 = str(next_path) + else: + path = get_redirection_path(user=user) + # redirect to referer path + url = urljoin(base_host(request=request, is_app=True), path) + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py new file mode 100644 index 000000000..43054867e --- /dev/null +++ b/apiserver/plane/authentication/views/app/password_management.py @@ -0,0 +1,197 @@ +# 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 = request.META.get("HTTP_ORIGIN") + # send the forgot password email + forgot_password.delay( + user.first_name, user.email, uidb64, token, current_site + ) + return Response( + {"message": "Check your email to reset your password"}, + status=status.HTTP_200_OK, + ) + 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 + 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 = 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/apiserver/plane/authentication/views/app/signout.py b/apiserver/plane/authentication/views/app/signout.py new file mode 100644 index 000000000..260a89a8d --- /dev/null +++ b/apiserver/plane/authentication/views/app/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 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/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py new file mode 100644 index 000000000..3e95d6ed8 --- /dev/null +++ b/apiserver/plane/authentication/views/common.py @@ -0,0 +1,150 @@ +# 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) + + old_password = request.data.get("old_password", False) + new_password = request.data.get("new_password", False) + + if not old_password or 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 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/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py new file mode 100644 index 000000000..a86a29c09 --- /dev/null +++ b/apiserver/plane/authentication/views/space/check.py @@ -0,0 +1,131 @@ +# 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, + ) + + # 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: + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + return Response( + exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST + ) + + return Response( + { + "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/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py new file mode 100644 index 000000000..7a5613a75 --- /dev/null +++ b/apiserver/plane/authentication/views/space/email.py @@ -0,0 +1,220 @@ +# Python imports +from urllib.parse import urlencode + +# 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.db.models import User +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + try: + provider = EmailProvider( + request=request, key=email, code=password, is_signup=False + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # redirect to next path + url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + +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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + # Existing User + existing_user = User.objects.filter(email=email).first() + + if existing_user: + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], + error_message="USER_ALREADY_EXIST", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + try: + provider = EmailProvider( + request=request, key=email, code=password, is_signup=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 + url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/github.py b/apiserver/plane/authentication/views/space/github.py new file mode 100644 index 000000000..711f7eaa7 --- /dev/null +++ b/apiserver/plane/authentication/views/space/github.py @@ -0,0 +1,109 @@ +# Python imports +import uuid +from urllib.parse import urlencode + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.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, +) + + +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") + if next_path: + request.session["next_path"] = str(next_path) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = 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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GITHUB_OAUTH_PROVIDER_ERROR" + ], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(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 + url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/google.py b/apiserver/plane/authentication/views/space/google.py new file mode 100644 index 000000000..38a2b910a --- /dev/null +++ b/apiserver/plane/authentication/views/space/google.py @@ -0,0 +1,103 @@ +# Python imports +import uuid +from urllib.parse import urlencode + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.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, +) + + +class GoogleOauthInitiateSpaceEndpoint(View): + def get(self, request): + request.session["host"] = base_host(request=request, is_space=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(next_path) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = 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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GOOGLE_OAUTH_PROVIDER_ERROR" + ], + error_message="GOOGLE_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = next_path + url = f"{base_host(request=request, is_space=True)}?{urlencode(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 + url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py new file mode 100644 index 000000000..0e859d44d --- /dev/null +++ b/apiserver/plane/authentication/views/space/magic.py @@ -0,0 +1,192 @@ +# Python imports +from urllib.parse import urlencode + +# 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.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, +) + + +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 + ) + + origin = base_host(request=request, is_space=True) + email = request.data.get("email", False) + try: + # Clean up the email + email = email.strip().lower() + 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, origin) + return Response({"key": str(key)}, status=status.HTTP_200_OK) + except AuthenticationException as e: + return Response( + e.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + +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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + # Active User + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + try: + provider = MagicCodeProvider( + request=request, key=f"magic_{email}", code=code + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # redirect to referer path + profile = Profile.objects.get(user=user) + if user.is_password_autoset and profile.is_onboarded: + path = "accounts/set-password" + else: + # Get the redirection path + path = str(next_path) if next_path else "" + url = f"{base_host(request=request, is_space=True)}{path}" + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + +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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(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() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + try: + provider = MagicCodeProvider( + request=request, key=f"magic_{email}", code=code + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # redirect to referer path + url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/password_management.py b/apiserver/plane/authentication/views/space/password_management.py new file mode 100644 index 000000000..3e0379b96 --- /dev/null +++ b/apiserver/plane/authentication/views/space/password_management.py @@ -0,0 +1,192 @@ +# 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 = request.META.get("HTTP_ORIGIN") + # send the forgot password email + forgot_password.delay( + user.first_name, user.email, uidb64, token, current_site + ) + return Response( + {"message": "Check your email to reset your password"}, + status=status.HTTP_200_OK, + ) + 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())}" + 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())}" + 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())}" + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/signout.py b/apiserver/plane/authentication/views/space/signout.py new file mode 100644 index 000000000..d3f29bd8d --- /dev/null +++ b/apiserver/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 + + +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 = f"{base_host(request=request, is_space=True)}{next_path}" + return HttpResponseRedirect(url) + except Exception: + url = f"{base_host(request=request, is_space=True)}{next_path}" + return HttpResponseRedirect(url) diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index a4f5b194c..e6788df79 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -1,24 +1,22 @@ # Python imports import csv import io -import requests -import json +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.conf import settings - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception # Module imports from plane.db.models import Issue -from plane.utils.analytics_plot import build_graph_plot -from plane.utils.issue_filters import issue_filters from plane.license.utils.instance_value import get_email_configuration +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", @@ -57,6 +55,7 @@ def send_export_email(email, slug, csv_buffer, rows): EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -66,6 +65,7 @@ def send_export_email(email, slug, csv_buffer, rows): username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -101,7 +101,9 @@ def get_assignee_details(slug, filters): def get_label_details(slug, filters): """Fetch label details if required""" return ( - Issue.objects.filter(workspace__slug=slug, **filters, labels__id__isnull=False) + Issue.objects.filter( + workspace__slug=slug, **filters, labels__id__isnull=False + ) .distinct("labels__id") .order_by("labels__id") .values("labels__id", "labels__color", "labels__name") @@ -174,7 +176,9 @@ def generate_segmented_rows( ): segment_zero = list( set( - item.get("segment") for sublist in distribution.values() for item in sublist + item.get("segment") + for sublist in distribution.values() + for item in sublist ) ) @@ -193,7 +197,9 @@ def generate_segmented_rows( ] for segment in segment_zero: - value = next((x.get(key) for x in data if x.get("segment") == segment), "0") + value = next( + (x.get(key) for x in data if x.get("segment") == segment), "0" + ) generated_row.append(value) if x_axis == ASSIGNEE_ID: @@ -206,13 +212,17 @@ def generate_segmented_rows( None, ) if assignee: - generated_row[ - 0 - ] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + 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)), + ( + lab + for lab in label_details + if str(lab[LABEL_ID]) == str(item) + ), None, ) @@ -221,7 +231,11 @@ def generate_segmented_rows( if x_axis == STATE_ID: state = next( - (sta for sta in state_details if str(sta[STATE_ID]) == str(item)), + ( + sta + for sta in state_details + if str(sta[STATE_ID]) == str(item) + ), None, ) @@ -230,7 +244,11 @@ def generate_segmented_rows( if x_axis == CYCLE_ID: cycle = next( - (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), + ( + cyc + for cyc in cycle_details + if str(cyc[CYCLE_ID]) == str(item) + ), None, ) @@ -239,7 +257,11 @@ def generate_segmented_rows( if x_axis == MODULE_ID: module = next( - (mod for mod in module_details if str(mod[MODULE_ID]) == str(item)), + ( + mod + for mod in module_details + if str(mod[MODULE_ID]) == str(item) + ), None, ) @@ -259,14 +281,18 @@ def generate_segmented_rows( None, ) if assignee: - row_zero[ - index + 2 - ] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + 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)), + ( + lab + for lab in label_details + if str(lab[LABEL_ID]) == str(segm) + ), None, ) if label: @@ -275,7 +301,11 @@ def generate_segmented_rows( if segmented == STATE_ID: for index, segm in enumerate(row_zero[2:]): state = next( - (sta for sta in state_details if str(sta[STATE_ID]) == str(segm)), + ( + sta + for sta in state_details + if str(sta[STATE_ID]) == str(segm) + ), None, ) if state: @@ -284,7 +314,11 @@ def generate_segmented_rows( if segmented == MODULE_ID: for index, segm in enumerate(row_zero[2:]): module = next( - (mod for mod in label_details if str(mod[MODULE_ID]) == str(segm)), + ( + mod + for mod in label_details + if str(mod[MODULE_ID]) == str(segm) + ), None, ) if module: @@ -293,7 +327,11 @@ def generate_segmented_rows( if segmented == CYCLE_ID: for index, segm in enumerate(row_zero[2:]): cycle = next( - (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(segm)), + ( + cyc + for cyc in cycle_details + if str(cyc[CYCLE_ID]) == str(segm) + ), None, ) if cycle: @@ -315,7 +353,10 @@ def generate_non_segmented_rows( ): rows = [] for item, data in distribution.items(): - row = [item, data[0].get("count" if y_axis == "issue_count" else "estimate")] + row = [ + item, + data[0].get("count" if y_axis == "issue_count" else "estimate"), + ] if x_axis == ASSIGNEE_ID: assignee = next( @@ -327,13 +368,17 @@ def generate_non_segmented_rows( None, ) if assignee: - row[ - 0 - ] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + 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)), + ( + lab + for lab in label_details + if str(lab[LABEL_ID]) == str(item) + ), None, ) @@ -342,7 +387,11 @@ def generate_non_segmented_rows( if x_axis == STATE_ID: state = next( - (sta for sta in state_details if str(sta[STATE_ID]) == str(item)), + ( + sta + for sta in state_details + if str(sta[STATE_ID]) == str(item) + ), None, ) @@ -351,7 +400,11 @@ def generate_non_segmented_rows( if x_axis == CYCLE_ID: cycle = next( - (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), + ( + cyc + for cyc in cycle_details + if str(cyc[CYCLE_ID]) == str(item) + ), None, ) @@ -360,7 +413,11 @@ def generate_non_segmented_rows( if x_axis == MODULE_ID: module = next( - (mod for mod in module_details if str(mod[MODULE_ID]) == str(item)), + ( + mod + for mod in module_details + if str(mod[MODULE_ID]) == str(item) + ), None, ) @@ -369,7 +426,10 @@ def generate_non_segmented_rows( rows.append(tuple(row)) - row_zero = [row_mapping.get(x_axis, "X-Axis"), row_mapping.get(y_axis, "Y-Axis")] + row_zero = [ + row_mapping.get(x_axis, "X-Axis"), + row_mapping.get(y_axis, "Y-Axis"), + ] return [tuple(row_zero)] + rows @@ -446,10 +506,8 @@ def analytic_export_task(email, data, slug): csv_buffer = generate_csv_from_rows(rows) send_export_email(email, slug, csv_buffer, rows) + logging.getLogger("plane").info("Email sent succesfully.") return except Exception as e: - print(e) - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/api_logs_task.py b/apiserver/plane/bgtasks/api_logs_task.py new file mode 100644 index 000000000..038b939d5 --- /dev/null +++ b/apiserver/plane/bgtasks/api_logs_task.py @@ -0,0 +1,15 @@ +from django.utils import timezone +from datetime import timedelta +from plane.db.models import APIActivityLog +from celery import shared_task + + +@shared_task +def delete_api_logs(): + # Get the logs older than 30 days to delete + logs_to_delete = APIActivityLog.objects.filter( + created_at__lte=timezone.now() - timedelta(days=30) + ) + + # Delete the logs + logs_to_delete._raw_delete(logs_to_delete.db) diff --git a/apiserver/plane/bgtasks/apps.py b/apiserver/plane/bgtasks/apps.py index 03d29f3e0..7f6ca38f0 100644 --- a/apiserver/plane/bgtasks/apps.py +++ b/apiserver/plane/bgtasks/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class BgtasksConfig(AppConfig): - name = 'plane.bgtasks' + name = "plane.bgtasks" diff --git a/apiserver/plane/bgtasks/dummy_data_task.py b/apiserver/plane/bgtasks/dummy_data_task.py new file mode 100644 index 000000000..e76cdac22 --- /dev/null +++ b/apiserver/plane/bgtasks/dummy_data_task.py @@ -0,0 +1,679 @@ +# 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, + PageLabel, + Inbox, + InboxIssue, +) + + +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, + inbox_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(), + project=project, + 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, + ) + ) + + return Page.objects.bulk_create(pages, ignore_conflicts=True) + + +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(project=project).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, + project=project, + 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=60000) + 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_inbox_issues(workspace, project, user_id, inbox_issue_count): + issues = create_issues(workspace, project, user_id, inbox_issue_count) + inbox, create = Inbox.objects.get_or_create( + name="Inbox", + project=project, + is_default=True, + ) + InboxIssue.objects.bulk_create( + [ + InboxIssue( + issue=issue, + inbox=inbox, + 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="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), + ) + + # Bulk issue + bulk_issue_labels = [] + for issue in issues: + for label in random.sample( + list(labels), random.randint(0, len(labels) - 1) + ): + 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), + ) + + # Bulk issue + bulk_module_issues = [] + for issue in issues: + module = modules[random.randint(0, len(modules) - 1)] + 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, + inbox_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 inbox issues + create_inbox_issues( + workspace=workspace, + project=project, + user_id=user_id, + inbox_issue_count=inbox_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/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py new file mode 100644 index 000000000..fa154828b --- /dev/null +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -0,0 +1,344 @@ +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): + # Keep only alphanumeric characters, spaces, and dashes. + processed_text = re.sub(r"[^a-zA-Z0-9 \-]", "", 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): + 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": actor.avatar, + "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": actor.avatar, + "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": actor.avatar, + "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)}", + }, + "receiver": { + "email": receiver.email, + }, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", + "workspace": str(issue.project.workspace.slug), + "project": str(issue.project.name), + "user_preference": f"{base_api}/profile/preferences/email", + "comments": comments, + } + 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").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").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/apiserver/plane/bgtasks/event_tracking_task.py b/apiserver/plane/bgtasks/event_tracking_task.py index 7d26dd4ab..135ae1dd1 100644 --- a/apiserver/plane/bgtasks/event_tracking_task.py +++ b/apiserver/plane/bgtasks/event_tracking_task.py @@ -1,13 +1,13 @@ -import uuid import os +import uuid # third party imports from celery import shared_task -from sentry_sdk import capture_exception from posthog import Posthog # module imports from plane.license.utils.instance_value import get_configuration_value +from plane.utils.exception_logger import log_exception def posthogConfiguration(): @@ -40,22 +40,25 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time): 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 - } + "event_id": uuid.uuid4().hex, + "user": {"email": email, "id": str(user)}, + "device_ctx": { + "ip": ip, + "user_agent": user_agent, + }, + "medium": medium, + "first_time": first_time, + }, ) except Exception as e: - capture_exception(e) - + log_exception(e) + return + @shared_task -def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from): +def workspace_invite_event( + user, email, user_agent, ip, event_name, accepted_from +): try: POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() @@ -65,14 +68,15 @@ def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_fro 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 - } + "event_id": uuid.uuid4().hex, + "user": {"email": email, "id": str(user)}, + "device_ctx": { + "ip": ip, + "user_agent": user_agent, + }, + "accepted_from": accepted_from, + }, ) except Exception as e: - capture_exception(e) \ No newline at end of file + log_exception(e) + return diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index e895b859d..c99836c83 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -2,21 +2,22 @@ import csv import io import json -import boto3 import zipfile +import boto3 +from botocore.client import Config + +# Third party imports +from celery import shared_task + # Django imports from django.conf import settings from django.utils import timezone - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception -from botocore.client import Config from openpyxl import Workbook # Module imports -from plane.db.models import Issue, ExporterHistory +from plane.db.models import ExporterHistory, Issue +from plane.utils.exception_logger import log_exception def dateTimeConverter(time): @@ -68,7 +69,9 @@ def create_zip_file(files): def upload_to_s3(zip_file, workspace_id, token_id, slug): - file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip" + file_name = ( + f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip" + ) expires_in = 7 * 24 * 60 * 60 if settings.USE_MINIO: @@ -87,12 +90,15 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): ) presigned_url = s3.generate_presigned_url( "get_object", - Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name}, + Params={ + "Bucket": settings.AWS_STORAGE_BUCKET_NAME, + "Key": file_name, + }, ExpiresIn=expires_in, ) # Create the new url with updated domain and protocol presigned_url = presigned_url.replace( - "http://plane-minio:9000/uploads/", + f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}/", f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/", ) else: @@ -112,7 +118,10 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): presigned_url = s3.generate_presigned_url( "get_object", - Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name}, + Params={ + "Bucket": settings.AWS_STORAGE_BUCKET_NAME, + "Key": file_name, + }, ExpiresIn=expires_in, ) @@ -136,12 +145,17 @@ def generate_table_row(issue): issue["description_stripped"], issue["state__name"], issue["priority"], - f"{issue['created_by__first_name']} {issue['created_by__last_name']}" - if issue["created_by__first_name"] and issue["created_by__last_name"] - else "", - f"{issue['assignees__first_name']} {issue['assignees__last_name']}" - if issue["assignees__first_name"] and issue["assignees__last_name"] - else "", + ( + f"{issue['created_by__first_name']} {issue['created_by__last_name']}" + if issue["created_by__first_name"] + and issue["created_by__last_name"] + else "" + ), + ( + f"{issue['assignees__first_name']} {issue['assignees__last_name']}" + if issue["assignees__first_name"] and issue["assignees__last_name"] + else "" + ), issue["labels__name"], issue["issue_cycle__cycle__name"], dateConverter(issue["issue_cycle__cycle__start_date"]), @@ -164,19 +178,30 @@ def generate_json_row(issue): "Description": issue["description_stripped"], "State": issue["state__name"], "Priority": issue["priority"], - "Created By": f"{issue['created_by__first_name']} {issue['created_by__last_name']}" - if issue["created_by__first_name"] and issue["created_by__last_name"] - else "", - "Assignee": f"{issue['assignees__first_name']} {issue['assignees__last_name']}" - if issue["assignees__first_name"] and issue["assignees__last_name"] - else "", + "Created By": ( + f"{issue['created_by__first_name']} {issue['created_by__last_name']}" + if issue["created_by__first_name"] + and issue["created_by__last_name"] + else "" + ), + "Assignee": ( + f"{issue['assignees__first_name']} {issue['assignees__last_name']}" + if issue["assignees__first_name"] and issue["assignees__last_name"] + else "" + ), "Labels": issue["labels__name"], "Cycle Name": issue["issue_cycle__cycle__name"], - "Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]), + "Cycle Start Date": dateConverter( + issue["issue_cycle__cycle__start_date"] + ), "Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]), "Module Name": issue["issue_module__module__name"], - "Module Start Date": dateConverter(issue["issue_module__module__start_date"]), - "Module Target Date": dateConverter(issue["issue_module__module__target_date"]), + "Module Start Date": dateConverter( + issue["issue_module__module__start_date"] + ), + "Module Target Date": dateConverter( + issue["issue_module__module__target_date"] + ), "Created At": dateTimeConverter(issue["created_at"]), "Updated At": dateTimeConverter(issue["updated_at"]), "Completed At": dateTimeConverter(issue["completed_at"]), @@ -211,7 +236,11 @@ def update_json_row(rows, row): def update_table_row(rows, row): matched_index = next( - (index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]), + ( + index + for index, existing_row in enumerate(rows) + if existing_row[0] == row[0] + ), None, ) @@ -260,7 +289,9 @@ def generate_xlsx(header, project_id, issues, files): @shared_task -def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug): +def issue_export_task( + provider, workspace_id, project_ids, token_id, multiple, slug +): try: exporter_instance = ExporterHistory.objects.get(token=token_id) exporter_instance.status = "processing" @@ -272,10 +303,17 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s 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", "parent", "created_by" ) - .select_related("project", "workspace", "state", "parent", "created_by") .prefetch_related( - "assignees", "labels", "issue_cycle__cycle", "issue_module__module" + "assignees", + "labels", + "issue_cycle__cycle", + "issue_module__module", ) .values( "id", @@ -367,8 +405,5 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s exporter_instance.status = "failed" exporter_instance.reason = str(e) exporter_instance.save(update_fields=["status", "reason"]) - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/exporter_expired_task.py b/apiserver/plane/bgtasks/exporter_expired_task.py index 30b638c84..d408c6476 100644 --- a/apiserver/plane/bgtasks/exporter_expired_task.py +++ b/apiserver/plane/bgtasks/exporter_expired_task.py @@ -19,7 +19,8 @@ from plane.db.models import ExporterHistory def delete_old_s3_link(): # Get a list of keys and IDs to process expired_exporter_history = ExporterHistory.objects.filter( - Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8)) + Q(url__isnull=False) + & Q(created_at__lte=timezone.now() - timedelta(days=8)) ).values_list("key", "id") if settings.USE_MINIO: s3 = boto3.client( @@ -42,8 +43,12 @@ def delete_old_s3_link(): # Delete object from S3 if file_name: if settings.USE_MINIO: - s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name) + s3.delete_object( + Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name + ) else: - s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name) + s3.delete_object( + Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name + ) ExporterHistory.objects.filter(id=exporter_id).update(url=None) diff --git a/apiserver/plane/bgtasks/file_asset_task.py b/apiserver/plane/bgtasks/file_asset_task.py index 339d24583..e372355ef 100644 --- a/apiserver/plane/bgtasks/file_asset_task.py +++ b/apiserver/plane/bgtasks/file_asset_task.py @@ -14,10 +14,10 @@ from plane.db.models import FileAsset @shared_task def delete_file_asset(): - # file assets to delete file_assets_to_delete = FileAsset.objects.filter( - Q(is_deleted=True) & Q(updated_at__lte=timezone.now() - timedelta(days=7)) + Q(is_deleted=True) + & Q(updated_at__lte=timezone.now() - timedelta(days=7)) ) # Delete the file from storage and the file object from the database @@ -26,4 +26,3 @@ def delete_file_asset(): file_asset.asset.delete(save=False) # Delete the file object file_asset.delete() - diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index d790f845d..f830eb1e2 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -1,28 +1,24 @@ -# Python import -import os -import requests -import json - -# 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.conf import settings +# Python imports +import logging # Third party imports from celery import shared_task -from sentry_sdk import capture_exception + +# 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/password/?uidb64={uidb64}&token={token}&email={email}" - ) + relative_link = f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}" abs_url = str(current_site) + relative_link ( @@ -31,6 +27,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -42,7 +39,9 @@ def forgot_password(first_name, email, uidb64, token, current_site): "email": email, } - html_content = render_to_string("emails/auth/forgot_password.html", context) + html_content = render_to_string( + "emails/auth/forgot_password.html", context + ) text_content = strip_tags(html_content) @@ -52,6 +51,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -63,10 +63,8 @@ def forgot_password(first_name, email, uidb64, token, current_site): ) msg.attach_alternative(html_content, "text/html") msg.send() + logging.getLogger("plane").info("Email sent successfully") return except Exception as e: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py deleted file mode 100644 index 84d10ecd3..000000000 --- a/apiserver/plane/bgtasks/importer_task.py +++ /dev/null @@ -1,200 +0,0 @@ -# Python imports -import json -import requests -import uuid - -# Django imports -from django.conf import settings -from django.core.serializers.json import DjangoJSONEncoder -from django.contrib.auth.hashers import make_password - -# Third Party imports -from celery import shared_task -from sentry_sdk import capture_exception - -# Module imports -from plane.app.serializers import ImporterSerializer -from plane.db.models import ( - Importer, - WorkspaceMember, - GithubRepositorySync, - GithubRepository, - ProjectMember, - WorkspaceIntegration, - Label, - User, - IssueProperty, -) -from plane.bgtasks.user_welcome_task import send_welcome_slack - - -@shared_task -def service_importer(service, importer_id): - try: - importer = Importer.objects.get(pk=importer_id) - importer.status = "processing" - importer.save() - - users = importer.data.get("users", []) - - # Check if we need to import users as well - if len(users): - # For all invited users create the users - new_users = User.objects.bulk_create( - [ - User( - email=user.get("email").strip().lower(), - username=uuid.uuid4().hex, - password=make_password(uuid.uuid4().hex), - is_password_autoset=True, - ) - for user in users - if user.get("import", False) == "invite" - ], - batch_size=10, - ignore_conflicts=True, - ) - - _ = [ - send_welcome_slack.delay( - str(user.id), - True, - f"{user.email} was imported to Plane from {service}", - ) - for user in new_users - ] - - workspace_users = User.objects.filter( - email__in=[ - user.get("email").strip().lower() - for user in users - if user.get("import", False) == "invite" - or user.get("import", False) == "map" - ] - ) - - # Check if any of the users are already member of workspace - _ = WorkspaceMember.objects.filter( - member__in=[user for user in workspace_users], - workspace_id=importer.workspace_id, - ).update(is_active=True) - - # Add new users to Workspace and project automatically - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - member=user, - workspace_id=importer.workspace_id, - created_by=importer.created_by, - ) - for user in workspace_users - ], - batch_size=100, - ignore_conflicts=True, - ) - - ProjectMember.objects.bulk_create( - [ - ProjectMember( - project_id=importer.project_id, - workspace_id=importer.workspace_id, - member=user, - created_by=importer.created_by, - ) - for user in workspace_users - ], - batch_size=100, - ignore_conflicts=True, - ) - - IssueProperty.objects.bulk_create( - [ - IssueProperty( - project_id=importer.project_id, - workspace_id=importer.workspace_id, - user=user, - created_by=importer.created_by, - ) - for user in workspace_users - ], - batch_size=100, - ignore_conflicts=True, - ) - - # Check if sync config is on for github importers - if service == "github" and importer.config.get("sync", False): - name = importer.metadata.get("name", False) - url = importer.metadata.get("url", False) - config = importer.metadata.get("config", {}) - owner = importer.metadata.get("owner", False) - repository_id = importer.metadata.get("repository_id", False) - - workspace_integration = WorkspaceIntegration.objects.get( - workspace_id=importer.workspace_id, integration__provider="github" - ) - - # Delete the old repository object - GithubRepositorySync.objects.filter(project_id=importer.project_id).delete() - GithubRepository.objects.filter(project_id=importer.project_id).delete() - - # Create a Label for github - label = Label.objects.filter( - name="GitHub", project_id=importer.project_id - ).first() - - if label is None: - label = Label.objects.create( - name="GitHub", - project_id=importer.project_id, - description="Label to sync Plane issues with GitHub issues", - color="#003773", - ) - # Create repository - repo = GithubRepository.objects.create( - name=name, - url=url, - config=config, - repository_id=repository_id, - owner=owner, - project_id=importer.project_id, - ) - - # Create repo sync - _ = GithubRepositorySync.objects.create( - repository=repo, - workspace_integration=workspace_integration, - actor=workspace_integration.actor, - credentials=importer.data.get("credentials", {}), - project_id=importer.project_id, - label=label, - ) - - # Add bot as a member in the project - _ = ProjectMember.objects.get_or_create( - member=workspace_integration.actor, - role=20, - project_id=importer.project_id, - ) - - if settings.PROXY_BASE_URL: - headers = {"Content-Type": "application/json"} - import_data_json = json.dumps( - ImporterSerializer(importer).data, - cls=DjangoJSONEncoder, - ) - _ = requests.post( - f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/", - json=import_data_json, - headers=headers, - ) - - return - except Exception as e: - importer = Importer.objects.get(pk=importer_id) - importer.status = "failed" - importer.save() - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) - return diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 3b2b40223..007b3e48c 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -1,32 +1,37 @@ # Python imports import json + import requests +# Third Party imports +from celery import shared_task + # Django imports from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone -# Third Party imports -from celery import shared_task -from sentry_sdk import capture_exception +from plane.app.serializers import IssueActivitySerializer +from plane.bgtasks.notification_task import notifications # Module imports from plane.db.models import ( - User, - Issue, - Project, - Label, - IssueActivity, - State, - Cycle, - Module, - IssueReaction, CommentReaction, + Cycle, + Issue, + IssueActivity, IssueComment, + IssueReaction, + IssueSubscriber, + Label, + Module, + Project, + State, + User, ) -from plane.app.serializers import IssueActivitySerializer -from plane.bgtasks.notification_task import notifications +from plane.settings.redis import redis_instance +from plane.utils.exception_logger import log_exception +from plane.bgtasks.webhook_task import webhook_activity # Track Changes in name @@ -51,7 +56,7 @@ def track_name( field="name", project_id=project_id, workspace_id=workspace_id, - comment=f"updated the name to", + comment="updated the name to", epoch=epoch, ) ) @@ -94,7 +99,7 @@ def track_description( field="description", project_id=project_id, workspace_id=workspace_id, - comment=f"updated the description to", + comment="updated the description to", epoch=epoch, ) ) @@ -111,27 +116,43 @@ def track_parent( issue_activities, epoch, ): - if current_instance.get("parent") != requested_data.get("parent"): - old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first() - new_parent = Issue.objects.filter(pk=requested_data.get("parent")).first() + if current_instance.get("parent_id") != requested_data.get("parent_id"): + old_parent = ( + Issue.objects.filter(pk=current_instance.get("parent_id")).first() + if current_instance.get("parent_id") is not None + else None + ) + new_parent = ( + Issue.objects.filter(pk=requested_data.get("parent_id")).first() + if requested_data.get("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 "", + 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=f"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, + 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, ) ) @@ -159,7 +180,7 @@ def track_priority( field="priority", project_id=project_id, workspace_id=workspace_id, - comment=f"updated the priority to", + comment="updated the priority to", epoch=epoch, ) ) @@ -176,9 +197,11 @@ def track_state( issue_activities, epoch, ): - if current_instance.get("state") != requested_data.get("state"): - new_state = State.objects.get(pk=requested_data.get("state", None)) - old_state = State.objects.get(pk=current_instance.get("state", None)) + if current_instance.get("state_id") != requested_data.get("state_id"): + new_state = State.objects.get(pk=requested_data.get("state_id", None)) + old_state = State.objects.get( + pk=current_instance.get("state_id", None) + ) issue_activities.append( IssueActivity( @@ -190,7 +213,7 @@ def track_state( field="state", project_id=project_id, workspace_id=workspace_id, - comment=f"updated the state to", + comment="updated the state to", old_identifier=old_state.id, new_identifier=new_state.id, epoch=epoch, @@ -209,22 +232,28 @@ def track_target_date( issue_activities, epoch, ): - if current_instance.get("target_date") != requested_data.get("target_date"): + if current_instance.get("target_date") != requested_data.get( + "target_date" + ): issue_activities.append( IssueActivity( issue_id=issue_id, 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 "", + 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=f"updated the target date to", + comment="updated the target date to", epoch=epoch, ) ) @@ -247,16 +276,20 @@ def track_start_date( 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 "", + 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=f"updated the start date to ", + comment="updated the start date to ", epoch=epoch, ) ) @@ -273,8 +306,12 @@ def track_labels( issue_activities, epoch, ): - requested_labels = set([str(lab) for lab in requested_data.get("labels", [])]) - current_labels = set([str(lab) for lab in current_instance.get("labels", [])]) + requested_labels = set( + [str(lab) for lab in requested_data.get("label_ids", [])] + ) + current_labels = set( + [str(lab) for lab in current_instance.get("label_ids", [])] + ) added_labels = requested_labels - current_labels dropped_labels = current_labels - requested_labels @@ -312,7 +349,7 @@ def track_labels( field="labels", project_id=project_id, workspace_id=workspace_id, - comment=f"removed label ", + comment="removed label ", old_identifier=label.id, new_identifier=None, epoch=epoch, @@ -331,12 +368,21 @@ def track_assignees( issue_activities, epoch, ): - requested_assignees = set([str(asg) for asg in requested_data.get("assignees", [])]) - current_assignees = set([str(asg) for asg in current_instance.get("assignees", [])]) + requested_assignees = ( + set([str(asg) for asg in requested_data.get("assignee_ids", [])]) + if requested_data is not None + else set() + ) + current_assignees = ( + set([str(asg) for asg in current_instance.get("assignee_ids", [])]) + if current_instance is not None + else set() + ) added_assignees = requested_assignees - current_assignees dropped_assginees = current_assignees - requested_assignees + bulk_subscribers = [] for added_asignee in added_assignees: assignee = User.objects.get(pk=added_asignee) issue_activities.append( @@ -349,11 +395,26 @@ def track_assignees( field="assignees", project_id=project_id, workspace_id=workspace_id, - comment=f"added assignee ", + 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: assignee = User.objects.get(pk=dropped_assignee) @@ -367,7 +428,7 @@ def track_assignees( field="assignees", project_id=project_id, workspace_id=workspace_id, - comment=f"removed assignee ", + comment="removed assignee ", old_identifier=assignee.id, epoch=epoch, ) @@ -384,22 +445,28 @@ def track_estimate_points( issue_activities, epoch, ): - if current_instance.get("estimate_point") != requested_data.get("estimate_point"): + if current_instance.get("estimate_point") != requested_data.get( + "estimate_point" + ): issue_activities.append( IssueActivity( issue_id=issue_id, actor_id=actor_id, verb="updated", - old_value=current_instance.get("estimate_point") - if current_instance.get("estimate_point") is not None - else "", - new_value=requested_data.get("estimate_point") - if requested_data.get("estimate_point") is not None - else "", + old_value=( + current_instance.get("estimate_point") + if current_instance.get("estimate_point") is not None + else "" + ), + new_value=( + requested_data.get("estimate_point") + if requested_data.get("estimate_point") is not None + else "" + ), field="estimate_point", project_id=project_id, workspace_id=workspace_id, - comment=f"updated the estimate point to ", + comment="updated the estimate point to ", epoch=epoch, ) ) @@ -415,7 +482,9 @@ def track_archive_at( issue_activities, epoch, ): - if current_instance.get("archived_at") != requested_data.get("archived_at"): + if current_instance.get("archived_at") != requested_data.get( + "archived_at" + ): if requested_data.get("archived_at") is None: issue_activities.append( IssueActivity( @@ -432,17 +501,23 @@ def track_archive_at( ) ) 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="Plane has archived the issue", + comment=comment, verb="updated", actor_id=actor_id, field="archived_at", old_value=None, - new_value="archive", + new_value=new_value, epoch=epoch, ) ) @@ -472,7 +547,7 @@ def track_closed_to( field="state", project_id=project_id, workspace_id=workspace_id, - comment=f"Plane updated the state to ", + comment="Plane updated the state to ", old_identifier=None, new_identifier=updated_state.id, epoch=epoch, @@ -495,12 +570,26 @@ def create_issue_activity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment=f"created the issue", + comment="created the issue", verb="created", actor_id=actor_id, epoch=epoch, ) ) + 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( @@ -515,20 +604,22 @@ def update_issue_activity( ): ISSUE_ACTIVITY_MAPPER = { "name": track_name, - "parent": track_parent, + "parent_id": track_parent, "priority": track_priority, - "state": track_state, + "state_id": track_state, "description_html": track_description, "target_date": track_target_date, "start_date": track_start_date, - "labels": track_labels, - "assignees": track_assignees, + "label_ids": track_labels, + "assignee_ids": track_assignees, "estimate_point": track_estimate_points, "archived_at": track_archive_at, "closed_to": track_closed_to, } - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -562,7 +653,7 @@ def delete_issue_activity( IssueActivity( project_id=project_id, workspace_id=workspace_id, - comment=f"deleted the issue", + comment="deleted the issue", verb="deleted", actor_id=actor_id, field="issue", @@ -581,7 +672,9 @@ def create_comment_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -591,7 +684,7 @@ def create_comment_activity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment=f"created a comment", + comment="created a comment", verb="created", actor_id=actor_id, field="comment", @@ -613,18 +706,22 @@ def update_comment_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) - if current_instance.get("comment_html") != requested_data.get("comment_html"): + if current_instance.get("comment_html") != requested_data.get( + "comment_html" + ): issue_activities.append( IssueActivity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment=f"updated a comment", + comment="updated a comment", verb="updated", actor_id=actor_id, field="comment", @@ -653,7 +750,7 @@ def delete_comment_activity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment=f"deleted the comment", + comment="deleted the comment", verb="deleted", actor_id=actor_id, field="comment", @@ -672,14 +769,18 @@ def create_cycle_issue_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) # Updated Records: updated_records = current_instance.get("updated_cycle_issues", []) - created_records = json.loads(current_instance.get("created_cycle_issues", [])) + created_records = json.loads( + current_instance.get("created_cycle_issues", []) + ) for updated_record in updated_records: old_cycle = Cycle.objects.filter( @@ -714,7 +815,9 @@ def create_cycle_issue_activity( cycle = Cycle.objects.filter( pk=created_record.get("fields").get("cycle") ).first() - issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).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"]) @@ -746,7 +849,9 @@ def delete_cycle_issue_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -788,67 +893,29 @@ def create_module_issue_activity( 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 + requested_data = ( + json.loads(requested_data) if requested_data is not None else None ) - - # Updated Records: - updated_records = current_instance.get("updated_module_issues", []) - created_records = json.loads(current_instance.get("created_module_issues", [])) - - for updated_record in updated_records: - old_module = Module.objects.filter( - pk=updated_record.get("old_module_id", None) - ).first() - new_module = Module.objects.filter( - pk=updated_record.get("new_module_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_module.name, - new_value=new_module.name, - field="modules", - project_id=project_id, - workspace_id=workspace_id, - comment=f"updated module to ", - old_identifier=old_module.id, - new_identifier=new_module.id, - epoch=epoch, - ) - ) - - for created_record in created_records: - module = Module.objects.filter( - pk=created_record.get("fields").get("module") - ).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=module.name, - field="modules", - project_id=project_id, - workspace_id=workspace_id, - comment=f"added module {module.name}", - new_identifier=module.id, - epoch=epoch, - ) + 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, + field="modules", + project_id=project_id, + workspace_id=workspace_id, + comment=f"added module {module.name}", + new_identifier=requested_data.get("module_id"), + epoch=epoch, ) + ) def delete_module_issue_activity( @@ -861,36 +928,36 @@ def delete_module_issue_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) - - module_id = requested_data.get("module_id", "") - module_name = requested_data.get("module_name", "") - module = Module.objects.filter(pk=module_id).first() - issues = requested_data.get("issues") - - for issue in issues: - current_issue = Issue.objects.filter(pk=issue).first() - if 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=module.name if module is not None else module_name, - new_value="", - field="modules", - project_id=project_id, - workspace_id=workspace_id, - comment=f"removed this issue from {module.name if module is not None else module_name}", - old_identifier=module_id if module_id is not None else None, - epoch=epoch, - ) + 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( @@ -903,7 +970,9 @@ def create_link_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -913,7 +982,7 @@ def create_link_activity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment=f"created a link", + comment="created a link", verb="created", actor_id=actor_id, field="link", @@ -934,7 +1003,9 @@ def update_link_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -945,7 +1016,7 @@ def update_link_activity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment=f"updated a link", + comment="updated a link", verb="updated", actor_id=actor_id, field="link", @@ -977,7 +1048,7 @@ def delete_link_activity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment=f"deleted the link", + comment="deleted the link", verb="deleted", actor_id=actor_id, field="link", @@ -998,7 +1069,9 @@ def create_attachment_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -1008,7 +1081,7 @@ def create_attachment_activity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment=f"created an attachment", + comment="created an attachment", verb="created", actor_id=actor_id, field="attachment", @@ -1034,7 +1107,7 @@ def delete_attachment_activity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment=f"deleted the attachment", + comment="deleted the attachment", verb="deleted", actor_id=actor_id, field="attachment", @@ -1053,7 +1126,9 @@ def create_issue_reaction_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) if requested_data and requested_data.get("reaction") is not None: issue_reaction = ( IssueReaction.objects.filter( @@ -1125,7 +1200,9 @@ def create_comment_reaction_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) if requested_data and requested_data.get("reaction") is not None: comment_reaction_id, comment_id = ( CommentReaction.objects.filter( @@ -1136,7 +1213,9 @@ def create_comment_reaction_activity( .values_list("id", "comment__id") .first() ) - comment = IssueComment.objects.get(pk=comment_id, project_id=project_id) + comment = IssueComment.objects.get( + pk=comment_id, project_id=project_id + ) if ( comment is not None and comment_reaction_id is not None @@ -1210,13 +1289,15 @@ def create_issue_vote_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) if requested_data and requested_data.get("vote") is not None: issue_activities.append( IssueActivity( issue_id=issue_id, actor_id=actor_id, - verb="created", + verb="updated", old_value=None, new_value=requested_data.get("vote"), field="vote", @@ -1272,44 +1353,51 @@ def create_issue_relation_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) - if current_instance is None and requested_data.get("related_list") is not None: - for issue_relation in requested_data.get("related_list"): - if issue_relation.get("relation_type") == "blocked_by": - relation_type = "blocking" - else: - relation_type = issue_relation.get("relation_type") - issue = Issue.objects.get(pk=issue_relation.get("issue")) + 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_relation.get("related_issue"), + issue_id=issue_id, actor_id=actor_id, - verb="created", + verb="updated", old_value="", new_value=f"{issue.project.identifier}-{issue.sequence_id}", - field=relation_type, + field=requested_data.get("relation_type"), project_id=project_id, workspace_id=workspace_id, - comment=f"added {relation_type} relation", - old_identifier=issue_relation.get("issue"), + comment=f"added {requested_data.get('relation_type')} relation", + old_identifier=related_issue, ) ) - issue = Issue.objects.get(pk=issue_relation.get("related_issue")) + issue = Issue.objects.get(pk=issue_id) issue_activities.append( IssueActivity( - issue_id=issue_relation.get("issue"), + issue_id=related_issue, actor_id=actor_id, - verb="created", + verb="updated", old_value="", new_value=f"{issue.project.identifier}-{issue.sequence_id}", - field=f'{issue_relation.get("relation_type")}', + 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'added {issue_relation.get("relation_type")} relation', - old_identifier=issue_relation.get("related_issue"), + comment=f'added {"blocking" if requested_data.get("relation_type") == "blocked_by" else ("blocked_by" if requested_data.get("relation_type") == "blocking" else requested_data.get("relation_type")),} relation', + old_identifier=issue_id, epoch=epoch, ) ) @@ -1325,47 +1413,52 @@ def delete_issue_relation_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) - if current_instance is not None and requested_data.get("related_list") is None: - if current_instance.get("relation_type") == "blocked_by": - relation_type = "blocking" - else: - relation_type = current_instance.get("relation_type") - issue = Issue.objects.get(pk=current_instance.get("issue")) - issue_activities.append( - IssueActivity( - issue_id=current_instance.get("related_issue"), - actor_id=actor_id, - verb="deleted", - old_value=f"{issue.project.identifier}-{issue.sequence_id}", - new_value="", - field=relation_type, - project_id=project_id, - workspace_id=workspace_id, - comment=f"deleted {relation_type} relation", - old_identifier=current_instance.get("issue"), - epoch=epoch, - ) + 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=current_instance.get("related_issue")) - issue_activities.append( - IssueActivity( - issue_id=current_instance.get("issue"), - actor_id=actor_id, - verb="deleted", - old_value=f"{issue.project.identifier}-{issue.sequence_id}", - new_value="", - field=f'{current_instance.get("relation_type")}', - project_id=project_id, - workspace_id=workspace_id, - comment=f'deleted {current_instance.get("relation_type")} relation', - old_identifier=current_instance.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( @@ -1383,7 +1476,7 @@ def create_draft_issue_activity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment=f"drafted the issue", + comment="drafted the issue", field="draft", verb="created", actor_id=actor_id, @@ -1402,20 +1495,22 @@ def update_draft_issue_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) if ( requested_data.get("is_draft") is not None - and requested_data.get("is_draft") == False + 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=f"created the issue", + comment="created the issue", verb="updated", actor_id=actor_id, epoch=epoch, @@ -1427,7 +1522,7 @@ def update_draft_issue_activity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment=f"updated the draft issue", + comment="updated the draft issue", field="draft", verb="updated", actor_id=actor_id, @@ -1450,7 +1545,7 @@ def delete_draft_issue_activity( IssueActivity( project_id=project_id, workspace_id=workspace_id, - comment=f"deleted the draft issue", + comment="deleted the draft issue", field="draft", verb="deleted", actor_id=actor_id, @@ -1459,6 +1554,46 @@ def delete_draft_issue_activity( ) +def create_inbox_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 inbox status", + field="inbox", + 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( @@ -1470,6 +1605,9 @@ def issue_activity( project_id, epoch, subscriber=True, + notification=False, + origin=None, + inbox=None, ): try: issue_activities = [] @@ -1478,12 +1616,16 @@ def issue_activity( 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 as e: + except Exception: pass ACTIVITY_MAPPER = { @@ -1513,6 +1655,7 @@ def issue_activity( "issue_draft.activity.created": create_draft_issue_activity, "issue_draft.activity.updated": update_draft_issue_activity, "issue_draft.activity.deleted": delete_draft_issue_activity, + "inbox.activity.created": create_inbox_activity, } func = ACTIVITY_MAPPER.get(type) @@ -1529,7 +1672,9 @@ def issue_activity( ) # Save all the values to database - issue_activities_created = IssueActivity.objects.bulk_create(issue_activities) + issue_activities_created = IssueActivity.objects.bulk_create( + issue_activities + ) # Post the updates to segway for integrations and webhooks if len(issue_activities_created): # Don't send activities if the actor is a bot @@ -1547,26 +1692,61 @@ def issue_activity( headers=headers, ) except Exception as e: - capture_exception(e) + log_exception(e) - 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, - ) + for activity in issue_activities_created: + webhook_activity.delay( + event=( + "issue_comment" + if activity.field == "comment" + else "inbox_issue" if inbox else "issue" + ), + event_id=( + activity.issue_comment_id + if activity.field == "comment" + else inbox if inbox else activity.issue_id + ), + verb=activity.verb, + field=( + "description" + if activity.field == "comment" + else activity.field + ), + old_value=( + activity.old_value + if activity.old_value != "" + else None + ), + new_value=( + activity.new_value + if activity.new_value != "" + else None + ), + actor_id=activity.actor_id, + current_site=origin, + slug=activity.workspace.slug, + old_identifier=activity.old_identifier, + new_identifier=activity.new_identifier, + ) + + 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: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 6a09b08ba..cdcdcd174 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -2,18 +2,17 @@ import json from datetime import timedelta -# Django imports -from django.utils import timezone -from django.db.models import Q -from django.conf import settings - # Third party imports from celery import shared_task -from sentry_sdk import capture_exception +from django.db.models import Q + +# Django imports +from django.utils import timezone # Module imports -from plane.db.models import Issue, Project, State from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import Issue, Project, State +from plane.utils.exception_logger import log_exception @shared_task @@ -36,7 +35,9 @@ def archive_old_issues(): Q( project=project_id, archived_at__isnull=True, - updated_at__lte=(timezone.now() - timedelta(days=archive_in * 30)), + updated_at__lte=( + timezone.now() - timedelta(days=archive_in * 30) + ), state__group__in=["completed", "cancelled"], ), Q(issue_cycle__isnull=True) @@ -46,7 +47,9 @@ def archive_old_issues(): ), Q(issue_module__isnull=True) | ( - Q(issue_module__module__target_date__lt=timezone.now().date()) + Q( + issue_module__module__target_date__lt=timezone.now().date() + ) & Q(issue_module__isnull=False) ), ).filter( @@ -74,21 +77,25 @@ def archive_old_issues(): _ = [ issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps({"archived_at": str(archive_at)}), + 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: - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return @@ -108,7 +115,9 @@ def close_old_issues(): Q( project=project_id, archived_at__isnull=True, - updated_at__lte=(timezone.now() - timedelta(days=close_in * 30)), + updated_at__lte=( + timezone.now() - timedelta(days=close_in * 30) + ), state__group__in=["backlog", "unstarted", "started"], ), Q(issue_cycle__isnull=True) @@ -118,7 +127,9 @@ def close_old_issues(): ), Q(issue_module__isnull=True) | ( - Q(issue_module__module__target_date__lt=timezone.now().date()) + Q( + issue_module__module__target_date__lt=timezone.now().date() + ) & Q(issue_module__isnull=False) ), ).filter( @@ -131,7 +142,9 @@ def close_old_issues(): # Check if Issues if issues: if project.default_state is None: - close_state = State.objects.filter(group="cancelled").first() + close_state = State.objects.filter( + group="cancelled" + ).first() else: close_state = project.default_state @@ -157,12 +170,11 @@ def close_old_issues(): current_instance=None, subscriber=False, epoch=int(timezone.now().timestamp()), + notification=True, ) for issue in issues_to_update ] return except Exception as e: - if settings.DEBUG: - print(e) - capture_exception(e) - return \ No newline at end of file + log_exception(e) + return diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index bb61e0ada..7be0ae9f8 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -1,20 +1,18 @@ # Python imports -import os -import requests -import json - -# 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.conf import settings +import logging # Third party imports from celery import shared_task -from sentry_sdk import capture_exception + +# 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 @@ -26,6 +24,7 @@ def magic_link(email, key, token, current_site): EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -33,7 +32,9 @@ def magic_link(email, key, token, current_site): subject = f"Your unique Plane login code is {token}" context = {"code": token, "email": email} - html_content = render_to_string("emails/auth/magic_signin.html", context) + html_content = render_to_string( + "emails/auth/magic_signin.html", context + ) text_content = strip_tags(html_content) connection = get_connection( @@ -42,6 +43,7 @@ def magic_link(email, key, token, current_site): username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -53,11 +55,8 @@ def magic_link(email, key, token, current_site): ) msg.attach_alternative(html_content, "text/html") msg.send() + logging.getLogger("plane").info("Email sent successfully.") return except Exception as e: - print(e) - capture_exception(e) - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 4bc27d3ee..9dfd0c16d 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -10,9 +10,12 @@ from plane.db.models import ( User, IssueAssignee, Issue, + State, + EmailNotificationLog, Notification, IssueComment, - IssueActivity + IssueActivity, + UserNotificationPreference, ) # Third Party imports @@ -20,8 +23,8 @@ from celery import shared_task from bs4 import BeautifulSoup - -# =========== Issue Description Html Parsing and Notification Functions ====================== +# =========== Issue Description Html Parsing and notification Functions ====================== + def update_mentions_for_issue(issue, project, new_mentions, removed_mention): aggregated_issue_mentions = [] @@ -32,14 +35,14 @@ def update_mentions_for_issue(issue, project, new_mentions, removed_mention): mention_id=mention_id, issue=issue, project=project, - workspace_id=project.workspace_id + workspace_id=project.workspace_id, ) ) - IssueMention.objects.bulk_create( - aggregated_issue_mentions, batch_size=100) + IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100) IssueMention.objects.filter( - issue=issue, mention__in=removed_mention).delete() + issue=issue, mention__in=removed_mention + ).delete() def get_new_mentions(requested_instance, current_instance): @@ -48,18 +51,18 @@ def get_new_mentions(requested_instance, current_instance): # 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] + 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 @@ -70,13 +73,13 @@ def get_removed_mentions(requested_instance, current_instance): # Getting Set Difference from mentions_newer removed_mentions = [ - mention for mention in mentions_older if mention not in mentions_newer] + 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 @@ -84,27 +87,34 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions): for mention_id in mentions: # If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification - if not IssueSubscriber.objects.filter( - issue_id=issue_id, - subscriber_id=mention_id, - project_id=project_id, - ).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(): - - project = Project.objects.get(pk=project_id) - - bulk_mention_subscribers.append(IssueSubscriber( - workspace_id=project.workspace_id, - project_id=project_id, + 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() + ): + 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: @@ -113,46 +123,56 @@ def extract_mentions(issue_instance): # Convert string to dictionary data = json.loads(issue_instance) html = data.get("description_html") - soup = BeautifulSoup(html, 'html.parser') + soup = BeautifulSoup(html, "html.parser") mention_tags = soup.find_all( - 'mention-component', attrs={'target': 'users'}) + "mention-component", attrs={"target": "users"} + ) - mentions = [mention_tag['id'] for mention_tag in mention_tags] + mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags] return list(set(mentions)) - except Exception as e: + except Exception: return [] - - -# =========== Comment Parsing and Notification Functions ====================== + + +# =========== Comment Parsing and notification Functions ====================== def extract_comment_mentions(comment_value): try: mentions = [] - soup = BeautifulSoup(comment_value, 'html.parser') + soup = BeautifulSoup(comment_value, "html.parser") mentions_tags = soup.find_all( - 'mention-component', attrs={'target': 'users'} + "mention-component", attrs={"target": "users"} ) for mention_tag in mentions_tags: - mentions.append(mention_tag['id']) + mentions.append(mention_tag["entity_identifier"]) return list(set(mentions)) - except Exception as e: + 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] + mention for mention in mentions_newer if mention not in mentions_older + ] return new_mentions -def createMentionNotification(project, notification_comment, issue, actor_id, mention_id, issue_id, activity): +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", @@ -178,242 +198,552 @@ def createMentionNotification(project, notification_comment, issue, actor_id, me "actor": str(activity.get("actor_id")), "new_value": str(activity.get("new_value")), "old_value": str(activity.get("old_value")), - } + }, }, ) @shared_task -def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created, requested_data, current_instance): - issue_activities_created = ( - json.loads( - issue_activities_created) if issue_activities_created is not None else None - ) - if type not in [ - "issue.activity.deleted", - "cycle.activity.created", - "cycle.activity.deleted", - "module.activity.created", - "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 = [] - - """ - 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 new mentions from the newer instance - new_mentions = get_new_mentions( - requested_instance=requested_data, current_instance=current_instance) - 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_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_assignees = list( - IssueAssignee.objects.filter( - project_id=project_id, issue_id=issue_id) - .exclude(assignee_id__in=list(new_mentions + comment_mentions)) - .values_list("assignee", flat=True) - ) - - issue_subscribers = list( - IssueSubscriber.objects.filter( - project_id=project_id, issue_id=issue_id) - .exclude(subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])) - .values_list("subscriber", flat=True) +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 [ + "issue.activity.deleted", + "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 = [] - issue = Issue.objects.filter(pk=issue_id).first() + """ + 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 + """ - if (issue.created_by_id is not None and str(issue.created_by_id) != str(actor_id)): - issue_subscribers = issue_subscribers + [issue.created_by_id] + # Get new mentions from the newer instance + new_mentions = get_new_mentions( + requested_instance=requested_data, + current_instance=current_instance, + ) + removed_mention = get_removed_mentions( + requested_instance=requested_data, + current_instance=current_instance, + ) - if subscriber: - # add the user to issue subscriber - try: - if str(issue.created_by_id) != str(actor_id) and uuid.UUID(actor_id) not in issue_assignees: - _ = IssueSubscriber.objects.get_or_create( - project_id=project_id, issue_id=issue_id, subscriber_id=actor_id - ) - except Exception as e: - pass + comment_mentions = [] + all_comment_mentions = [] - project = Project.objects.get(pk=project_id) - - issue_subscribers = list(set(issue_subscribers + issue_assignees) - {uuid.UUID(actor_id)}) - - for subscriber in issue_subscribers: - if subscriber in issue_subscribers: - sender = "in_app:issue_activities:subscribed" - if issue.created_by_id is not None and subscriber == issue.created_by_id: - sender = "in_app:issue_activities:created" - if subscriber in issue_assignees: - sender = "in_app:issue_activities:assigned" + # 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: - issue_comment = IssueComment.objects.get( - id=issue_comment, issue_id=issue_id, project_id=project_id, workspace_id=project.workspace_id) - - 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_activity.get("issue_comment") is not None - else "" - ), - }, - }, + # 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_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 + ) + .exclude( + subscriber_id__in=list( + new_mentions + comment_mentions + [actor_id] ) ) + .values_list("subscriber", flat=True) + ) - # Add Mentioned as Issue Subscribers - IssueSubscriber.objects.bulk_create( - mention_subscribers + comment_mention_subscribers, batch_size=100) + issue = Issue.objects.filter(pk=issue_id).first() - 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): - for issue_activity in issue_activities_created: - notification = createMentionNotification( - project=project, - issue=issue, - notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", - actor_id=actor_id, - mention_id=mention_id, + if subscriber: + # add the user to issue subscriber + try: + _ = IssueSubscriber.objects.get_or_create( + project_id=project_id, issue_id=issue_id, - activity=issue_activity + subscriber_id=actor_id, ) - bulk_notifications.append(notification) - + except Exception: + pass - for mention_id in new_mentions: - if (mention_id != actor_id): - if ( - last_activity is not None - and last_activity.field == "description" - and actor_id == str(last_activity.actor_id) + project = Project.objects.get(pk=project_id) + + issue_assignees = IssueAssignee.objects.filter( + issue_id=issue_id, project_id=project_id + ).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="in_app:issue_activities:mentioned", + Notification( + workspace=project.workspace, + sender=sender, triggered_by_id=actor_id, - receiver_id=mention_id, + receiver_id=subscriber, entity_identifier=issue_id, entity_name="issue", project=project, - message=f"You have been mentioned in the issue {issue.name}", + title=issue_activity.get("comment"), data={ "issue": { "id": str(issue_id), "name": str(issue.name), - "identifier": str(issue.project.identifier), + "identifier": str( + issue.project.identifier + ), "sequence_id": issue.sequence_id, "state_name": issue.state.name, - "state_group": issue.state.group, - }, - "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), - }, - }, - ) - ) - else: + "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 "" + ), + }, + }, + ) + ) + # 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 "" + ), + "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 = createMentionNotification( + notification = create_mention_notification( project=project, issue=issue, - notification_comment=f"You have been mentioned in the issue {issue.name}", + notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", actor_id=actor_id, mention_id=mention_id, issue_id=issue_id, - activity=issue_activity + 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") + ), + "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 - 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) - - + 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 + ), + }, + }, + ) + ) + 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 + ), + "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" + ) + ), + "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 + 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/apiserver/plane/bgtasks/page_transaction_task.py b/apiserver/plane/bgtasks/page_transaction_task.py new file mode 100644 index 000000000..eceb3693e --- /dev/null +++ b/apiserver/plane/bgtasks/page_transaction_task.py @@ -0,0 +1,76 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone + +# Third-party imports +from bs4 import BeautifulSoup + +# Module imports +from plane.db.models import Page, PageLog +from celery import shared_task + + +def extract_components(value, tag): + try: + mentions = [] + html = value.get("description_html") + soup = BeautifulSoup(html, "html.parser") + mention_tags = soup.find_all(tag) + + for mention_tag in mention_tags: + mention = { + "id": mention_tag.get("id"), + "entity_identifier": mention_tag.get("entity_identifier"), + "entity_name": mention_tag.get("entity_name"), + } + mentions.append(mention) + + return mentions + except Exception: + return [] + + +@shared_task +def page_transaction(new_value, old_value, page_id): + page = Page.objects.get(pk=page_id) + new_page_mention = PageLog.objects.filter(page_id=page_id).exists() + + old_value = json.loads(old_value) if old_value else {} + + new_transactions = [] + deleted_transaction_ids = set() + + # TODO - Add "issue-embed-component", "img", "todo" components + components = ["mention-component"] + for component in components: + old_mentions = extract_components(old_value, component) + new_mentions = extract_components(new_value, component) + + new_mentions_ids = {mention["id"] for mention in new_mentions} + old_mention_ids = {mention["id"] for mention in old_mentions} + deleted_transaction_ids.update(old_mention_ids - new_mentions_ids) + + new_transactions.extend( + PageLog( + transaction=mention["id"], + page_id=page_id, + entity_identifier=mention["entity_identifier"], + entity_name=mention["entity_name"], + workspace_id=page.workspace_id, + project_id=page.project_id, + created_at=timezone.now(), + updated_at=timezone.now(), + ) + for mention in new_mentions + if mention["id"] not in old_mention_ids or not new_page_mention + ) + + # Create new PageLog objects for new transactions + PageLog.objects.bulk_create( + new_transactions, batch_size=10, ignore_conflicts=True + ) + + # Delete the removed transactions + PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete() diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index b9221855b..b60c49da1 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -1,19 +1,19 @@ -# Python import -import os +# 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 -from django.conf import settings - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception # Module imports -from plane.db.models import Project, User, ProjectMemberInvite +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): @@ -52,6 +52,7 @@ def project_invitation(email, project_id, token, current_site, invitor): EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -61,6 +62,7 @@ def project_invitation(email, project_id, token, current_site, invitor): username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -73,12 +75,10 @@ def project_invitation(email, project_id, token, current_site, invitor): msg.attach_alternative(html_content, "text/html") msg.send() + logging.getLogger("plane").info("Email sent successfully.") return - except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist) as e: + except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist): return except Exception as e: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/user_welcome_task.py b/apiserver/plane/bgtasks/user_welcome_task.py deleted file mode 100644 index 33f4b5686..000000000 --- a/apiserver/plane/bgtasks/user_welcome_task.py +++ /dev/null @@ -1,36 +0,0 @@ -# Django imports -from django.conf import settings - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError - -# Module imports -from plane.db.models import User - - -@shared_task -def send_welcome_slack(user_id, created, message): - try: - instance = User.objects.get(pk=user_id) - - if created and not instance.is_bot: - # Send message on slack as well - if settings.SLACK_BOT_TOKEN: - client = WebClient(token=settings.SLACK_BOT_TOKEN) - try: - _ = client.chat_postMessage( - channel="#trackers", - text=message, - ) - except SlackApiError as e: - print(f"Got an error: {e.response['error']}") - return - except Exception as e: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) - return diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index 3681f002d..6696a569c 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -1,38 +1,49 @@ -import requests -import uuid import hashlib -import json import hmac +import json +import logging +import uuid -# Django imports -from django.conf import settings -from django.core.serializers.json import DjangoJSONEncoder +import requests # Third party imports from celery import shared_task -from sentry_sdk import capture_exception -from plane.db.models import ( - Webhook, - WebhookLog, - Project, - Issue, - Cycle, - Module, - ModuleIssue, - CycleIssue, - IssueComment, -) +# Django imports +from django.conf import settings +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 ( - ProjectSerializer, - IssueSerializer, - CycleSerializer, - ModuleSerializer, CycleIssueSerializer, - ModuleIssueSerializer, + CycleSerializer, IssueCommentSerializer, IssueExpandSerializer, + ModuleIssueSerializer, + ModuleSerializer, + ProjectSerializer, + UserLiteSerializer, + InboxIssueSerializer, ) +from plane.db.models import ( + Cycle, + CycleIssue, + Issue, + IssueComment, + Module, + ModuleIssue, + Project, + User, + Webhook, + WebhookLog, + InboxIssue, +) +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception SERIALIZER_MAPPER = { "project": ProjectSerializer, @@ -42,6 +53,8 @@ SERIALIZER_MAPPER = { "cycle_issue": CycleIssueSerializer, "module_issue": ModuleIssueSerializer, "issue_comment": IssueCommentSerializer, + "user": UserLiteSerializer, + "inbox_issue": InboxIssueSerializer, } MODEL_MAPPER = { @@ -52,6 +65,8 @@ MODEL_MAPPER = { "cycle_issue": CycleIssue, "module_issue": ModuleIssue, "issue_comment": IssueComment, + "user": User, + "inbox_issue": InboxIssue, } @@ -72,7 +87,7 @@ def get_model_data(event, event_id, many=False): max_retries=5, retry_jitter=True, ) -def webhook_task(self, webhook, slug, event, event_data, action): +def webhook_task(self, webhook, slug, event, event_data, action, current_site): try: webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) @@ -151,18 +166,223 @@ def webhook_task(self, webhook, slug, event, event_data, action): response_body=str(e), retry_count=str(self.request.retries), ) - + # 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( + 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: if settings.DEBUG: print(e) - capture_exception(e) + log_exception(e) return -@shared_task() -def send_webhook(event, payload, kw, action, slug, bulk): +@shared_task +def send_webhook_deactivation_email( + webhook_id, receiver_id, current_site, reason +): + # 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) + webhook = Webhook.objects.get(pk=webhook_id) + 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) + + 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").info("Email sent successfully.") + return + except Exception as e: + log_exception(e) + return + + +@shared_task( + bind=True, + autoretry_for=(requests.RequestException,), + retry_backoff=600, + max_retries=5, + retry_jitter=True, +) +def webhook_send_task( + self, + webhook, + slug, + event, + event_data, + action, + current_site, + activity, +): + try: + webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) + + headers = { + "Content-Type": "application/json", + "User-Agent": "Autopilot", + "X-Plane-Delivery": str(uuid.uuid4()), + "X-Plane-Event": event, + } + + # # Your secret key + event_data = ( + json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) + if event_data is not None + else None + ) + + 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 + + # Send the webhook event + response = requests.post( + webhook.url, + headers=headers, + json=payload, + timeout=30, + ) + + # Log the webhook request + WebhookLog.objects.create( + workspace_id=str(webhook.workspace_id), + webhook_id=str(webhook.id), + event_type=str(event), + request_method=str(action), + request_headers=str(headers), + request_body=str(payload), + response_status=str(response.status_code), + response_headers=str(response.headers), + response_body=str(response.text), + retry_count=str(self.request.retries), + ) + + except requests.RequestException as e: + # Log the failed webhook request + WebhookLog.objects.create( + workspace_id=str(webhook.workspace_id), + webhook_id=str(webhook.id), + event_type=str(event), + request_method=str(action), + request_headers=str(headers), + request_body=str(payload), + response_status=500, + response_headers="", + response_body=str(e), + retry_count=str(self.request.retries), + ) + # 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( + 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: + if settings.DEBUG: + print(e) + log_exception(e) + return + + +@shared_task +def webhook_activity( + event, + verb, + field, + old_value, + new_value, + actor_id, + slug, + current_site, + event_id, + old_identifier, + new_identifier, +): try: webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) @@ -181,42 +401,88 @@ def send_webhook(event, payload, kw, action, slug, bulk): if event == "issue_comment": webhooks = webhooks.filter(issue_comment=True) - if webhooks: - if action in ["POST", "PATCH"]: - if bulk and event in ["cycle_issue", "module_issue"]: - event_data = IssueExpandSerializer( - Issue.objects.filter( - pk__in=[ - str(event.get("issue")) for event in payload - ] - ).prefetch_related("issue_cycle", "issue_module"), many=True - ).data - event = "issue" - action = "PATCH" - else: - event_data = [ - get_model_data( - event=event, - event_id=payload.get("id") if isinstance(payload, dict) else None, - many=False, - ) - ] - - if action == "DELETE": - event_data = [{"id": kw.get("pk")}] - - for webhook in webhooks: - for data in event_data: - webhook_task.delay( - webhook=webhook.id, - slug=slug, - event=event, - event_data=data, - action=action, - ) - + for webhook in webhooks: + webhook_send_task.delay( + webhook=webhook.id, + slug=slug, + event=event, + event_data=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) - capture_exception(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/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 7039cb875..c0b945e62 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -1,23 +1,18 @@ # Python imports -import os -import requests -import json +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.conf import settings - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError # Module imports -from plane.db.models import Workspace, WorkspaceMemberInvite, User +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 @@ -36,13 +31,13 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): # 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() @@ -71,6 +66,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -82,25 +78,12 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): ) msg.attach_alternative(html_content, "text/html") msg.send() - - # Send message on slack as well - if settings.SLACK_BOT_TOKEN: - client = WebClient(token=settings.SLACK_BOT_TOKEN) - try: - _ = client.chat_postMessage( - channel="#trackers", - text=f"{workspace_member_invite.email} has been invited to {workspace.name} as a {workspace_member_invite.role}", - ) - except SlackApiError as e: - print(f"Got an error: {e.response['error']}") + logging.getLogger("plane").info("Email sent succesfully") return except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e: - print("Workspace or WorkspaceMember Invite Does not exists") + log_exception(e) return except Exception as e: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 442e72836..d3e742f14 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -28,6 +28,14 @@ app.conf.beat_schedule = { "task": "plane.bgtasks.file_asset_task.delete_file_asset", "schedule": crontab(hour=0, minute=0), }, + "check-every-five-minutes-to-send-email-notifications": { + "task": "plane.bgtasks.email_notification_task.stack_email_notification", + "schedule": crontab(minute="*/5"), + }, + "check-every-day-to-delete-api-logs": { + "task": "plane.bgtasks.api_logs_task.delete_api_logs", + "schedule": crontab(hour=0, minute=0), + }, } # Load task modules from all registered Django app configs. diff --git a/apiserver/plane/db/management/commands/activate_user.py b/apiserver/plane/db/management/commands/activate_user.py new file mode 100644 index 000000000..29123b4e5 --- /dev/null +++ b/apiserver/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 succesfully")) diff --git a/apiserver/plane/db/management/commands/clear_cache.py b/apiserver/plane/db/management/commands/clear_cache.py new file mode 100644 index 000000000..4dfbe6c10 --- /dev/null +++ b/apiserver/plane/db/management/commands/clear_cache.py @@ -0,0 +1,17 @@ +# 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 handle(self, *args, **options): + try: + 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/apiserver/plane/db/management/commands/create_bucket.py b/apiserver/plane/db/management/commands/create_bucket.py index 054523bf9..bdd0b7014 100644 --- a/apiserver/plane/db/management/commands/create_bucket.py +++ b/apiserver/plane/db/management/commands/create_bucket.py @@ -5,7 +5,8 @@ from botocore.exceptions import ClientError # Django imports from django.core.management import BaseCommand -from django.conf import settings +from django.conf import settings + class Command(BaseCommand): help = "Create the default bucket for the instance" @@ -13,23 +14,31 @@ class Command(BaseCommand): def set_bucket_public_policy(self, s3_client, bucket_name): public_policy = { "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": "*", - "Action": ["s3:GetObject"], - "Resource": [f"arn:aws:s3:::{bucket_name}/*"] - }] + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:GetObject"], + "Resource": [f"arn:aws:s3:::{bucket_name}/*"], + } + ], } try: s3_client.put_bucket_policy( - Bucket=bucket_name, - Policy=json.dumps(public_policy) + Bucket=bucket_name, Policy=json.dumps(public_policy) + ) + self.stdout.write( + self.style.SUCCESS( + f"Public read access policy set for bucket '{bucket_name}'." + ) ) - self.stdout.write(self.style.SUCCESS(f"Public read access policy set for bucket '{bucket_name}'.")) except ClientError as e: - self.stdout.write(self.style.ERROR(f"Error setting public read access policy: {e}")) - + self.stdout.write( + self.style.ERROR( + f"Error setting public read access policy: {e}" + ) + ) def handle(self, *args, **options): # Create a session using the credentials from Django settings @@ -39,7 +48,9 @@ class Command(BaseCommand): aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, ) # Create an S3 client using the session - s3_client = session.client('s3', endpoint_url=settings.AWS_S3_ENDPOINT_URL) + s3_client = session.client( + "s3", endpoint_url=settings.AWS_S3_ENDPOINT_URL + ) bucket_name = settings.AWS_STORAGE_BUCKET_NAME self.stdout.write(self.style.NOTICE("Checking bucket...")) @@ -49,23 +60,41 @@ class Command(BaseCommand): self.set_bucket_public_policy(s3_client, bucket_name) except ClientError as e: - error_code = int(e.response['Error']['Code']) + error_code = int(e.response["Error"]["Code"]) bucket_name = settings.AWS_STORAGE_BUCKET_NAME if error_code == 404: # Bucket does not exist, create it - self.stdout.write(self.style.WARNING(f"Bucket '{bucket_name}' does not exist. Creating bucket...")) + self.stdout.write( + self.style.WARNING( + f"Bucket '{bucket_name}' does not exist. Creating bucket..." + ) + ) try: s3_client.create_bucket(Bucket=bucket_name) - self.stdout.write(self.style.SUCCESS(f"Bucket '{bucket_name}' created successfully.")) + self.stdout.write( + self.style.SUCCESS( + f"Bucket '{bucket_name}' created successfully." + ) + ) self.set_bucket_public_policy(s3_client, bucket_name) except ClientError as create_error: - self.stdout.write(self.style.ERROR(f"Failed to create bucket: {create_error}")) + self.stdout.write( + self.style.ERROR( + f"Failed to create bucket: {create_error}" + ) + ) elif error_code == 403: # Access to the bucket is forbidden - self.stdout.write(self.style.ERROR(f"Access to the bucket '{bucket_name}' is forbidden. Check permissions.")) + 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}")) + self.stdout.write( + self.style.ERROR(f"Failed to check bucket: {e}") + ) except Exception as ex: # Handle any other exception - self.stdout.write(self.style.ERROR(f"An error occurred: {ex}")) \ No newline at end of file + self.stdout.write(self.style.ERROR(f"An error occurred: {ex}")) diff --git a/apiserver/plane/db/management/commands/create_dummy_data.py b/apiserver/plane/db/management/commands/create_dummy_data.py new file mode 100644 index 000000000..dde1411fe --- /dev/null +++ b/apiserver/plane/db/management/commands/create_dummy_data.py @@ -0,0 +1,95 @@ +# 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: ")) + inbox_issue_count = int( + input("Number of inbox issues to be created: ") + ) + + from plane.bgtasks.dummy_data_task import create_dummy_data + + create_dummy_data.delay( + slug=workspace_slug, + email=creator, + members=members, + issue_count=issue_count, + cycle_count=cycle_count, + module_count=module_count, + pages_count=pages_count, + inbox_issue_count=inbox_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/apiserver/plane/db/management/commands/create_instance_admin.py b/apiserver/plane/db/management/commands/create_instance_admin.py new file mode 100644 index 000000000..21f79c15e --- /dev/null +++ b/apiserver/plane/db/management/commands/create_instance_admin.py @@ -0,0 +1,48 @@ +# 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/apiserver/plane/db/management/commands/reset_password.py b/apiserver/plane/db/management/commands/reset_password.py index a5b4c9cc8..9c137d320 100644 --- a/apiserver/plane/db/management/commands/reset_password.py +++ b/apiserver/plane/db/management/commands/reset_password.py @@ -2,7 +2,10 @@ import getpass # Django imports -from django.core.management import BaseCommand +from django.core.management import BaseCommand, CommandError + +# Third party imports +from zxcvbn import zxcvbn # Module imports from plane.db.models import User @@ -35,7 +38,7 @@ class Command(BaseCommand): # 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.") @@ -46,9 +49,18 @@ class Command(BaseCommand): 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(f"User password updated succesfully")) + + self.stdout.write( + self.style.SUCCESS("User password updated succesfully") + ) diff --git a/apiserver/plane/db/management/commands/test_email.py b/apiserver/plane/db/management/commands/test_email.py new file mode 100644 index 000000000..facea7e9c --- /dev/null +++ b/apiserver/plane/db/management/commands/test_email.py @@ -0,0 +1,69 @@ +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/apiserver/plane/db/management/commands/wait_for_db.py b/apiserver/plane/db/management/commands/wait_for_db.py index 365452a7a..ec971f83a 100644 --- a/apiserver/plane/db/management/commands/wait_for_db.py +++ b/apiserver/plane/db/management/commands/wait_for_db.py @@ -2,18 +2,19 @@ import time from django.db import connections from django.db.utils import OperationalError from django.core.management import BaseCommand - + + class Command(BaseCommand): """Django command to pause execution until db is available""" - + def handle(self, *args, **options): - self.stdout.write('Waiting for database...') + self.stdout.write("Waiting for database...") db_conn = None while not db_conn: try: - db_conn = connections['default'] + db_conn = connections["default"] except OperationalError: - self.stdout.write('Database unavailable, waititng 1 second...') + self.stdout.write("Database unavailable, waititng 1 second...") time.sleep(1) - - self.stdout.write(self.style.SUCCESS('Database available!')) + + self.stdout.write(self.style.SUCCESS("Database available!")) diff --git a/apiserver/plane/db/management/commands/wait_for_migrations.py b/apiserver/plane/db/management/commands/wait_for_migrations.py new file mode 100644 index 000000000..91c8a4ce8 --- /dev/null +++ b/apiserver/plane/db/management/commands/wait_for_migrations.py @@ -0,0 +1,24 @@ +# 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/apiserver/plane/db/migrations/0001_initial.py b/apiserver/plane/db/migrations/0001_initial.py index dd158f0a8..936d33fa5 100644 --- a/apiserver/plane/db/migrations/0001_initial.py +++ b/apiserver/plane/db/migrations/0001_initial.py @@ -10,695 +10,2481 @@ import uuid class Migration(migrations.Migration): - initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ("auth", "0012_alter_user_first_name_max_length"), ] operations = [ migrations.CreateModel( - name='User', + 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')), + ( + "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',), + "verbose_name": "User", + "verbose_name_plural": "Users", + "db_table": "user", + "ordering": ("-created_at",), }, managers=[ - ('objects', django.contrib.auth.models.UserManager()), + ("objects", django.contrib.auth.models.UserManager()), ], ), migrations.CreateModel( - name='Cycle', + 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)), + ( + "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',), + "verbose_name": "Cycle", + "verbose_name_plural": "Cycles", + "db_table": "cycle", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Issue', + 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)), + ( + "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',), + "verbose_name": "Issue", + "verbose_name_plural": "Issues", + "db_table": "issue", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Project', + 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')), + ( + "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',), + "verbose_name": "Project", + "verbose_name_plural": "Projects", + "db_table": "project", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Team', + 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')), + ( + "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',), + "verbose_name": "Team", + "verbose_name_plural": "Teams", + "db_table": "team", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Workspace', + 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')), + ( + "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')}, + "verbose_name": "Workspace", + "verbose_name_plural": "Workspaces", + "db_table": "workspace", + "ordering": ("-created_at",), + "unique_together": {("name", "owner")}, }, ), migrations.CreateModel( - name='WorkspaceMemberInvite', + 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')), + ( + "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',), + "verbose_name": "Workspace Member Invite", + "verbose_name_plural": "Workspace Member Invites", + "db_table": "workspace_member_invite", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='View', + 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')), + ( + "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',), + "verbose_name": "View", + "verbose_name_plural": "Views", + "db_table": "view", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='TimelineIssue', + 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')), + ( + "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',), + "verbose_name": "Timeline Issue", + "verbose_name_plural": "Timeline Issues", + "db_table": "issue_timeline", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='TeamMember', + 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')), + ( + "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')}, + "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), + 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'), + 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'), + 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', + 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')), + ( + "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')}, + "verbose_name": "State", + "verbose_name_plural": "States", + "db_table": "state", + "ordering": ("-created_at",), + "unique_together": {("name", "project")}, }, ), migrations.CreateModel( - name='SocialLoginConnection', + 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)), + ( + "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',), + "verbose_name": "Social Login Connection", + "verbose_name_plural": "Social Login Connections", + "db_table": "social_login_connection", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Shortcut', + 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')), + ( + "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',), + "verbose_name": "Shortcut", + "verbose_name_plural": "Shortcuts", + "db_table": "shortcut", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='ProjectMemberInvite', + 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')), + ( + "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',), + "verbose_name": "Project Member Invite", + "verbose_name_plural": "Project Member Invites", + "db_table": "project_member_invite", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='ProjectIdentifier', + 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')), + ( + "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',), + "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'), + 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', + 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')), + ( + "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',), + "verbose_name": "Label", + "verbose_name_plural": "Labels", + "db_table": "label", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueSequence', + 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')), + ( + "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',), + "verbose_name": "Issue Sequence", + "verbose_name_plural": "Issue Sequences", + "db_table": "issue_sequence", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueProperty', + 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')), + ( + "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',), + "verbose_name": "Issue Property", + "verbose_name_plural": "Issue Properties", + "db_table": "issue_property", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueLabel', + 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')), + ( + "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',), + "verbose_name": "Issue Label", + "verbose_name_plural": "Issue Labels", + "db_table": "issue_label", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueComment', + 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')), + ( + "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',), + "verbose_name": "Issue Comment", + "verbose_name_plural": "Issue Comments", + "db_table": "issue_comment", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueBlocker', + 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')), + ( + "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',), + "verbose_name": "Issue Blocker", + "verbose_name_plural": "Issue Blockers", + "db_table": "issue_blocker", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueAssignee', + 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')), + ( + "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')}, + "verbose_name": "Issue Assignee", + "verbose_name_plural": "Issue Assignees", + "db_table": "issue_assignee", + "ordering": ("-created_at",), + "unique_together": {("issue", "assignee")}, }, ), migrations.CreateModel( - name='IssueActivity', + 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')), + ( + "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',), + "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), + 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'), + 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'), + 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'), + 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'), + 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'), + 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'), + 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'), + 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', + 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')), + ( + "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',), + "verbose_name": "File Asset", + "verbose_name_plural": "File Assets", + "db_table": "file_asset", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='CycleIssue', + 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')), + ( + "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',), + "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'), + 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'), + 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'), + 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', + 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')), + ( + "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')}, + "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')}, + name="team", + unique_together={("name", "workspace")}, ), migrations.CreateModel( - name='ProjectMember', + 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')), + ( + "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')}, + "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')}, + name="project", + unique_together={("name", "workspace")}, ), ] diff --git a/apiserver/plane/db/migrations/0002_auto_20221104_2239.py b/apiserver/plane/db/migrations/0002_auto_20221104_2239.py index 9c25c4518..d69ef1a71 100644 --- a/apiserver/plane/db/migrations/0002_auto_20221104_2239.py +++ b/apiserver/plane/db/migrations/0002_auto_20221104_2239.py @@ -6,49 +6,66 @@ import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ('db', '0001_initial'), + ("db", "0001_initial"), ] operations = [ migrations.AlterModelOptions( - name='state', - options={'ordering': ('sequence',), 'verbose_name': 'State', 'verbose_name_plural': 'States'}, + 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', + 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), + 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), + 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', + model_name="state", + name="sequence", field=models.PositiveIntegerField(default=65535), ), migrations.AddField( - model_name='workspace', - name='company_size', + model_name="workspace", + name="company_size", field=models.PositiveIntegerField(default=10), ), migrations.AddField( - model_name='workspacemember', - name='company_role', + 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'), + model_name="cycleissue", + name="issue", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_cycle", + to="db.issue", + ), ), ] diff --git a/apiserver/plane/db/migrations/0003_auto_20221109_2320.py b/apiserver/plane/db/migrations/0003_auto_20221109_2320.py index 3adac35a7..763d52eb6 100644 --- a/apiserver/plane/db/migrations/0003_auto_20221109_2320.py +++ b/apiserver/plane/db/migrations/0003_auto_20221109_2320.py @@ -6,19 +6,22 @@ import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ('db', '0002_auto_20221104_2239'), + ("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), + 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')}, + name="issueproperty", + unique_together={("user", "project")}, ), ] diff --git a/apiserver/plane/db/migrations/0004_alter_state_sequence.py b/apiserver/plane/db/migrations/0004_alter_state_sequence.py index 0d4616aea..f3489449c 100644 --- a/apiserver/plane/db/migrations/0004_alter_state_sequence.py +++ b/apiserver/plane/db/migrations/0004_alter_state_sequence.py @@ -4,15 +4,14 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('db', '0003_auto_20221109_2320'), + ("db", "0003_auto_20221109_2320"), ] operations = [ migrations.AlterField( - model_name='state', - name='sequence', + model_name="state", + name="sequence", field=models.FloatField(default=65535), ), ] diff --git a/apiserver/plane/db/migrations/0005_auto_20221114_2127.py b/apiserver/plane/db/migrations/0005_auto_20221114_2127.py index 14c280e26..8ab63a22a 100644 --- a/apiserver/plane/db/migrations/0005_auto_20221114_2127.py +++ b/apiserver/plane/db/migrations/0005_auto_20221114_2127.py @@ -4,20 +4,23 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('db', '0004_alter_state_sequence'), + ("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'), + 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'), + model_name="cycle", + name="start_date", + field=models.DateField( + blank=True, null=True, verbose_name="Start Date" + ), ), ] diff --git a/apiserver/plane/db/migrations/0006_alter_cycle_status.py b/apiserver/plane/db/migrations/0006_alter_cycle_status.py index f49e263fb..3121f4fe5 100644 --- a/apiserver/plane/db/migrations/0006_alter_cycle_status.py +++ b/apiserver/plane/db/migrations/0006_alter_cycle_status.py @@ -4,15 +4,23 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('db', '0005_auto_20221114_2127'), + ("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'), + model_name="cycle", + name="status", + field=models.CharField( + choices=[ + ("draft", "Draft"), + ("started", "Started"), + ("completed", "Completed"), + ], + default="draft", + max_length=255, + verbose_name="Cycle Status", + ), ), ] diff --git a/apiserver/plane/db/migrations/0007_label_parent.py b/apiserver/plane/db/migrations/0007_label_parent.py index 03e660473..6e67a3c94 100644 --- a/apiserver/plane/db/migrations/0007_label_parent.py +++ b/apiserver/plane/db/migrations/0007_label_parent.py @@ -5,15 +5,20 @@ import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ('db', '0006_alter_cycle_status'), + ("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'), + model_name="label", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="parent_label", + to="db.label", + ), ), ] diff --git a/apiserver/plane/db/migrations/0008_label_colour.py b/apiserver/plane/db/migrations/0008_label_colour.py index 9e630969d..3ca6b91c1 100644 --- a/apiserver/plane/db/migrations/0008_label_colour.py +++ b/apiserver/plane/db/migrations/0008_label_colour.py @@ -4,15 +4,14 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('db', '0007_label_parent'), + ("db", "0007_label_parent"), ] operations = [ migrations.AddField( - model_name='label', - name='colour', + model_name="label", + name="colour", field=models.CharField(blank=True, max_length=255), ), ] diff --git a/apiserver/plane/db/migrations/0009_auto_20221208_0310.py b/apiserver/plane/db/migrations/0009_auto_20221208_0310.py index 077ab7e82..829baaa62 100644 --- a/apiserver/plane/db/migrations/0009_auto_20221208_0310.py +++ b/apiserver/plane/db/migrations/0009_auto_20221208_0310.py @@ -4,20 +4,29 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('db', '0008_label_colour'), + ("db", "0008_label_colour"), ] operations = [ migrations.AddField( - model_name='projectmember', - name='view_props', + 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), + model_name="state", + name="group", + field=models.CharField( + choices=[ + ("backlog", "Backlog"), + ("unstarted", "Unstarted"), + ("started", "Started"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + default="backlog", + max_length=20, + ), ), ] diff --git a/apiserver/plane/db/migrations/0010_auto_20221213_0037.py b/apiserver/plane/db/migrations/0010_auto_20221213_0037.py index e8579b5ff..1672a10ab 100644 --- a/apiserver/plane/db/migrations/0010_auto_20221213_0037.py +++ b/apiserver/plane/db/migrations/0010_auto_20221213_0037.py @@ -5,28 +5,37 @@ import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ('db', '0009_auto_20221208_0310'), + ("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'), + 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'), + 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')}, + name="project", + unique_together={ + ("name", "workspace"), + ("identifier", "workspace"), + }, ), migrations.AlterUniqueTogether( - name='projectidentifier', - unique_together={('name', 'workspace')}, + name="projectidentifier", + unique_together={("name", "workspace")}, ), ] diff --git a/apiserver/plane/db/migrations/0011_auto_20221222_2357.py b/apiserver/plane/db/migrations/0011_auto_20221222_2357.py index deeb1cc2f..b52df3012 100644 --- a/apiserver/plane/db/migrations/0011_auto_20221222_2357.py +++ b/apiserver/plane/db/migrations/0011_auto_20221222_2357.py @@ -8,122 +8,341 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0010_auto_20221213_0037'), + ("db", "0010_auto_20221213_0037"), ] operations = [ migrations.CreateModel( - name='Module', + name="Module", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Module Name')), - ('description', models.TextField(blank=True, verbose_name='Module Description')), - ('description_text', models.JSONField(blank=True, null=True, verbose_name='Module Description RT')), - ('description_html', models.JSONField(blank=True, null=True, verbose_name='Module Description HTML')), - ('start_date', models.DateField(null=True)), - ('target_date', models.DateField(null=True)), - ('status', models.CharField(choices=[('backlog', 'Backlog'), ('planned', 'Planned'), ('in-progress', 'In Progress'), ('paused', 'Paused'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='planned', max_length=20)), + ( + "created_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',), + "verbose_name": "Module", + "verbose_name_plural": "Modules", + "db_table": "module", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='project', - name='icon', + 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), + 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', + model_name="user", + name="my_issues_prop", field=models.JSONField(null=True), ), migrations.CreateModel( - name='ModuleMember', + 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')), + ( + "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')}, + "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'), + 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), + 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), + 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'), + 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'), + 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'), + 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', + 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')), + ( + "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')}, + "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')}, + name="module", + unique_together={("name", "project")}, ), ] diff --git a/apiserver/plane/db/migrations/0012_auto_20230104_0117.py b/apiserver/plane/db/migrations/0012_auto_20230104_0117.py index b1ff63fe1..bc767dd5d 100644 --- a/apiserver/plane/db/migrations/0012_auto_20230104_0117.py +++ b/apiserver/plane/db/migrations/0012_auto_20230104_0117.py @@ -7,166 +7,228 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0011_auto_20221222_2357'), + ("db", "0011_auto_20221222_2357"), ] operations = [ migrations.AddField( - model_name='issueactivity', - name='new_identifier', + model_name="issueactivity", + name="new_identifier", field=models.UUIDField(null=True), ), migrations.AddField( - model_name='issueactivity', - name='old_identifier', + 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'), + 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', + name="moduleissue", unique_together=set(), ), migrations.AlterModelTable( - name='cycle', - table='cycles', + name="cycle", + table="cycles", ), migrations.AlterModelTable( - name='cycleissue', - table='cycle_issues', + name="cycleissue", + table="cycle_issues", ), migrations.AlterModelTable( - name='fileasset', - table='file_assets', + name="fileasset", + table="file_assets", ), migrations.AlterModelTable( - name='issue', - table='issues', + name="issue", + table="issues", ), migrations.AlterModelTable( - name='issueactivity', - table='issue_activities', + name="issueactivity", + table="issue_activities", ), migrations.AlterModelTable( - name='issueassignee', - table='issue_assignees', + name="issueassignee", + table="issue_assignees", ), migrations.AlterModelTable( - name='issueblocker', - table='issue_blockers', + name="issueblocker", + table="issue_blockers", ), migrations.AlterModelTable( - name='issuecomment', - table='issue_comments', + name="issuecomment", + table="issue_comments", ), migrations.AlterModelTable( - name='issuelabel', - table='issue_labels', + name="issuelabel", + table="issue_labels", ), migrations.AlterModelTable( - name='issueproperty', - table='issue_properties', + name="issueproperty", + table="issue_properties", ), migrations.AlterModelTable( - name='issuesequence', - table='issue_sequences', + name="issuesequence", + table="issue_sequences", ), migrations.AlterModelTable( - name='label', - table='labels', + name="label", + table="labels", ), migrations.AlterModelTable( - name='module', - table='modules', + name="module", + table="modules", ), migrations.AlterModelTable( - name='modulemember', - table='module_members', + name="modulemember", + table="module_members", ), migrations.AlterModelTable( - name='project', - table='projects', + name="project", + table="projects", ), migrations.AlterModelTable( - name='projectidentifier', - table='project_identifiers', + name="projectidentifier", + table="project_identifiers", ), migrations.AlterModelTable( - name='projectmember', - table='project_members', + name="projectmember", + table="project_members", ), migrations.AlterModelTable( - name='projectmemberinvite', - table='project_member_invites', + name="projectmemberinvite", + table="project_member_invites", ), migrations.AlterModelTable( - name='shortcut', - table='shortcuts', + name="shortcut", + table="shortcuts", ), migrations.AlterModelTable( - name='socialloginconnection', - table='social_login_connections', + name="socialloginconnection", + table="social_login_connections", ), migrations.AlterModelTable( - name='state', - table='states', + name="state", + table="states", ), migrations.AlterModelTable( - name='team', - table='teams', + name="team", + table="teams", ), migrations.AlterModelTable( - name='teammember', - table='team_members', + name="teammember", + table="team_members", ), migrations.AlterModelTable( - name='timelineissue', - table='issue_timelines', + name="timelineissue", + table="issue_timelines", ), migrations.AlterModelTable( - name='user', - table='users', + name="user", + table="users", ), migrations.AlterModelTable( - name='view', - table='views', + name="view", + table="views", ), migrations.AlterModelTable( - name='workspace', - table='workspaces', + name="workspace", + table="workspaces", ), migrations.AlterModelTable( - name='workspacemember', - table='workspace_members', + name="workspacemember", + table="workspace_members", ), migrations.AlterModelTable( - name='workspacememberinvite', - table='workspace_member_invites', + name="workspacememberinvite", + table="workspace_member_invites", ), migrations.CreateModel( - name='ModuleLink', + 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')), + ( + "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',), + "verbose_name": "Module Link", + "verbose_name_plural": "Module Links", + "db_table": "module_links", + "ordering": ("-created_at",), }, ), ] diff --git a/apiserver/plane/db/migrations/0013_auto_20230107_0041.py b/apiserver/plane/db/migrations/0013_auto_20230107_0041.py index c75537fc1..786e6cb5d 100644 --- a/apiserver/plane/db/migrations/0013_auto_20230107_0041.py +++ b/apiserver/plane/db/migrations/0013_auto_20230107_0041.py @@ -4,35 +4,34 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('db', '0012_auto_20230104_0117'), + ("db", "0012_auto_20230104_0117"), ] operations = [ migrations.AddField( - model_name='issue', - name='description_html', + model_name="issue", + name="description_html", field=models.TextField(blank=True), ), migrations.AddField( - model_name='issue', - name='description_stripped', + model_name="issue", + name="description_stripped", field=models.TextField(blank=True), ), migrations.AddField( - model_name='user', - name='role', + model_name="user", + name="role", field=models.CharField(blank=True, max_length=300, null=True), ), migrations.AddField( - model_name='workspacemember', - name='view_props', + model_name="workspacemember", + name="view_props", field=models.JSONField(blank=True, null=True), ), migrations.AlterField( - model_name='issue', - name='description', + model_name="issue", + name="description", field=models.JSONField(blank=True), ), ] diff --git a/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py b/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py index b1786c9c1..5642ae15d 100644 --- a/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py +++ b/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py @@ -4,14 +4,13 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('db', '0013_auto_20230107_0041'), + ("db", "0013_auto_20230107_0041"), ] operations = [ migrations.AlterUniqueTogether( - name='workspacememberinvite', - unique_together={('email', 'workspace')}, + name="workspacememberinvite", + unique_together={("email", "workspace")}, ), ] diff --git a/apiserver/plane/db/migrations/0015_auto_20230107_1636.py b/apiserver/plane/db/migrations/0015_auto_20230107_1636.py index e3f5dc26a..903c78b05 100644 --- a/apiserver/plane/db/migrations/0015_auto_20230107_1636.py +++ b/apiserver/plane/db/migrations/0015_auto_20230107_1636.py @@ -4,25 +4,24 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('db', '0014_alter_workspacememberinvite_unique_together'), + ("db", "0014_alter_workspacememberinvite_unique_together"), ] operations = [ migrations.RenameField( - model_name='issuecomment', - old_name='comment', - new_name='comment_stripped', + model_name="issuecomment", + old_name="comment", + new_name="comment_stripped", ), migrations.AddField( - model_name='issuecomment', - name='comment_html', + model_name="issuecomment", + name="comment_html", field=models.TextField(blank=True), ), migrations.AddField( - model_name='issuecomment', - name='comment_json', + model_name="issuecomment", + name="comment_json", field=models.JSONField(blank=True, null=True), ), ] diff --git a/apiserver/plane/db/migrations/0016_auto_20230107_1735.py b/apiserver/plane/db/migrations/0016_auto_20230107_1735.py index 073c1e117..a22dc9a62 100644 --- a/apiserver/plane/db/migrations/0016_auto_20230107_1735.py +++ b/apiserver/plane/db/migrations/0016_auto_20230107_1735.py @@ -6,20 +6,27 @@ import plane.db.models.asset class Migration(migrations.Migration): - dependencies = [ - ('db', '0015_auto_20230107_1636'), + ("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'), + 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]), + 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/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py b/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py index c6bfc2145..1ab721a3e 100644 --- a/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py +++ b/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py @@ -4,14 +4,13 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('db', '0016_auto_20230107_1735'), + ("db", "0016_auto_20230107_1735"), ] operations = [ migrations.AlterUniqueTogether( - name='workspace', + name="workspace", unique_together=set(), ), ] diff --git a/apiserver/plane/db/migrations/0018_auto_20230130_0119.py b/apiserver/plane/db/migrations/0018_auto_20230130_0119.py index 03eaeacd7..32f886539 100644 --- a/apiserver/plane/db/migrations/0018_auto_20230130_0119.py +++ b/apiserver/plane/db/migrations/0018_auto_20230130_0119.py @@ -8,50 +8,112 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0017_alter_workspace_unique_together'), + ("db", "0017_alter_workspace_unique_together"), ] operations = [ migrations.AddField( - model_name='user', - name='is_bot', + model_name="user", + name="is_bot", field=models.BooleanField(default=False), ), migrations.AlterField( - model_name='issue', - name='description', + model_name="issue", + name="description", field=models.JSONField(blank=True, null=True), ), migrations.AlterField( - model_name='issue', - name='description_html', + model_name="issue", + name="description_html", field=models.TextField(blank=True, null=True), ), migrations.AlterField( - model_name='issue', - name='description_stripped', + model_name="issue", + name="description_stripped", field=models.TextField(blank=True, null=True), ), migrations.CreateModel( - name='APIToken', + 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)), + ( + "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',), + "verbose_name": "API Token", + "verbose_name_plural": "API Tokems", + "db_table": "api_tokens", + "ordering": ("-created_at",), }, ), ] diff --git a/apiserver/plane/db/migrations/0019_auto_20230131_0049.py b/apiserver/plane/db/migrations/0019_auto_20230131_0049.py index 38412aa9e..63545f497 100644 --- a/apiserver/plane/db/migrations/0019_auto_20230131_0049.py +++ b/apiserver/plane/db/migrations/0019_auto_20230131_0049.py @@ -4,20 +4,23 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('db', '0018_auto_20230130_0119'), + ("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'), + 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'), + model_name="issueactivity", + name="old_value", + field=models.TextField( + blank=True, null=True, verbose_name="Old Value" + ), ), ] diff --git a/apiserver/plane/db/migrations/0020_auto_20230214_0118.py b/apiserver/plane/db/migrations/0020_auto_20230214_0118.py index 192764078..4269f53b3 100644 --- a/apiserver/plane/db/migrations/0020_auto_20230214_0118.py +++ b/apiserver/plane/db/migrations/0020_auto_20230214_0118.py @@ -5,65 +5,69 @@ import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ('db', '0019_auto_20230131_0049'), + ("db", "0019_auto_20230131_0049"), ] operations = [ migrations.RenameField( - model_name='label', - old_name='colour', - new_name='color', + 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'), + 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', + model_name="issue", + name="completed_at", field=models.DateTimeField(null=True), ), migrations.AddField( - model_name='issue', - name='sort_order', + model_name="issue", + name="sort_order", field=models.FloatField(default=65535), ), migrations.AddField( - model_name='project', - name='cycle_view', + model_name="project", + name="cycle_view", field=models.BooleanField(default=True), ), migrations.AddField( - model_name='project', - name='module_view', + model_name="project", + name="module_view", field=models.BooleanField(default=True), ), migrations.AddField( - model_name='state', - name='default', + model_name="state", + name="default", field=models.BooleanField(default=False), ), migrations.AlterField( - model_name='issue', - name='description', + 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='

    '), + 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='

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

    "), ), migrations.AlterField( - model_name='issuecomment', - name='comment_json', + model_name="issuecomment", + name="comment_json", field=models.JSONField(blank=True, default=dict), ), ] diff --git a/apiserver/plane/db/migrations/0021_auto_20230223_0104.py b/apiserver/plane/db/migrations/0021_auto_20230223_0104.py index bae6a086a..0dc052c28 100644 --- a/apiserver/plane/db/migrations/0021_auto_20230223_0104.py +++ b/apiserver/plane/db/migrations/0021_auto_20230223_0104.py @@ -7,179 +7,616 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0020_auto_20230214_0118'), + ("db", "0020_auto_20230214_0118"), ] operations = [ migrations.CreateModel( - name='GithubRepository', + 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')), + ( + "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',), + "verbose_name": "Repository", + "verbose_name_plural": "Repositories", + "db_table": "github_repositories", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Integration', + 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')), + ( + "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',), + "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'), + 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', + 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')), + ( + "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')}, + "verbose_name": "Workspace Integration", + "verbose_name_plural": "Workspace Integrations", + "db_table": "workspace_integrations", + "ordering": ("-created_at",), + "unique_together": {("workspace", "integration")}, }, ), migrations.CreateModel( - name='IssueLink', + 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')), + ( + "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',), + "verbose_name": "Issue Link", + "verbose_name_plural": "Issue Links", + "db_table": "issue_links", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='GithubRepositorySync', + 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')), + ( + "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')}, + "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', + 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')), + ( + "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')}, + "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', + 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')), + ( + "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')}, + "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/apiserver/plane/db/migrations/0022_auto_20230307_0304.py b/apiserver/plane/db/migrations/0022_auto_20230307_0304.py index 25a8eef61..69bd577d7 100644 --- a/apiserver/plane/db/migrations/0022_auto_20230307_0304.py +++ b/apiserver/plane/db/migrations/0022_auto_20230307_0304.py @@ -7,95 +7,285 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0021_auto_20230223_0104'), + ("db", "0021_auto_20230223_0104"), ] operations = [ migrations.RemoveField( - model_name='cycle', - name='status', + model_name="cycle", + name="status", ), migrations.RemoveField( - model_name='project', - name='slug', + model_name="project", + name="slug", ), migrations.AddField( - model_name='issuelink', - name='metadata', + model_name="issuelink", + name="metadata", field=models.JSONField(default=dict), ), migrations.AddField( - model_name='modulelink', - name='metadata', + model_name="modulelink", + name="metadata", field=models.JSONField(default=dict), ), migrations.AddField( - model_name='project', - name='cover_image', + model_name="project", + name="cover_image", field=models.URLField(blank=True, null=True), ), migrations.CreateModel( - name='ProjectFavorite', + 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')), + ( + "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')}, + "verbose_name": "Project Favorite", + "verbose_name_plural": "Project Favorites", + "db_table": "project_favorites", + "ordering": ("-created_at",), + "unique_together": {("project", "user")}, }, ), migrations.CreateModel( - name='ModuleFavorite', + 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')), + ( + "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')}, + "verbose_name": "Module Favorite", + "verbose_name_plural": "Module Favorites", + "db_table": "module_favorites", + "ordering": ("-created_at",), + "unique_together": {("module", "user")}, }, ), migrations.CreateModel( - name='CycleFavorite', + 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')), + ( + "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')}, + "verbose_name": "Cycle Favorite", + "verbose_name_plural": "Cycle Favorites", + "db_table": "cycle_favorites", + "ordering": ("-created_at",), + "unique_together": {("cycle", "user")}, }, ), ] diff --git a/apiserver/plane/db/migrations/0023_auto_20230316_0040.py b/apiserver/plane/db/migrations/0023_auto_20230316_0040.py index c6985866c..6f6103cae 100644 --- a/apiserver/plane/db/migrations/0023_auto_20230316_0040.py +++ b/apiserver/plane/db/migrations/0023_auto_20230316_0040.py @@ -7,86 +7,299 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0022_auto_20230307_0304'), + ("db", "0022_auto_20230307_0304"), ] operations = [ migrations.CreateModel( - name='Importer', + 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')), + ( + "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',), + "verbose_name": "Importer", + "verbose_name_plural": "Importers", + "db_table": "importers", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueView', + 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')), + ( + "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',), + "verbose_name": "Issue View", + "verbose_name_plural": "Issue Views", + "db_table": "issue_views", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueViewFavorite', + 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')), + ( + "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')}, + "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')}, + name="label", + unique_together={("name", "project")}, ), migrations.DeleteModel( - name='View', + name="View", ), ] diff --git a/apiserver/plane/db/migrations/0024_auto_20230322_0138.py b/apiserver/plane/db/migrations/0024_auto_20230322_0138.py index 65880891a..7a95d519e 100644 --- a/apiserver/plane/db/migrations/0024_auto_20230322_0138.py +++ b/apiserver/plane/db/migrations/0024_auto_20230322_0138.py @@ -7,107 +7,308 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0023_auto_20230316_0040'), + ("db", "0023_auto_20230316_0040"), ] operations = [ migrations.CreateModel( - name='Page', + 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)), + ( + "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',), + "verbose_name": "Page", + "verbose_name_plural": "Pages", + "db_table": "pages", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='project', - name='issue_views_view', + 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), + model_name="importer", + name="service", + field=models.CharField( + choices=[("github", "GitHub"), ("jira", "Jira")], max_length=50 + ), ), migrations.AlterField( - model_name='project', - name='cover_image', + model_name="project", + name="cover_image", field=models.URLField(blank=True, max_length=800, null=True), ), migrations.CreateModel( - name='PageBlock', + 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')), + ( + "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',), + "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'), + 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'), + 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'), + 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', + 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')), + ( + "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')}, + "verbose_name": "Page Favorite", + "verbose_name_plural": "Page Favorites", + "db_table": "page_favorites", + "ordering": ("-created_at",), + "unique_together": {("page", "user")}, }, ), ] diff --git a/apiserver/plane/db/migrations/0025_auto_20230331_0203.py b/apiserver/plane/db/migrations/0025_auto_20230331_0203.py index 1097a4612..702d74cfc 100644 --- a/apiserver/plane/db/migrations/0025_auto_20230331_0203.py +++ b/apiserver/plane/db/migrations/0025_auto_20230331_0203.py @@ -7,55 +7,125 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0024_auto_20230322_0138'), + ("db", "0024_auto_20230322_0138"), ] operations = [ migrations.AddField( - model_name='page', - name='color', + model_name="page", + name="color", field=models.CharField(blank=True, max_length=255), ), migrations.AddField( - model_name='pageblock', - name='sort_order', + model_name="pageblock", + name="sort_order", field=models.FloatField(default=65535), ), migrations.AddField( - model_name='pageblock', - name='sync', + model_name="pageblock", + name="sync", field=models.BooleanField(default=True), ), migrations.AddField( - model_name='project', - name='page_view', + model_name="project", + name="page_view", field=models.BooleanField(default=True), ), migrations.CreateModel( - name='PageLabel', + 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')), + ( + "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',), + "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'), + model_name="page", + name="labels", + field=models.ManyToManyField( + blank=True, + related_name="pages", + through="db.PageLabel", + to="db.Label", + ), ), ] diff --git a/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py b/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py index 6f74fa499..310087f97 100644 --- a/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py +++ b/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py @@ -5,15 +5,16 @@ import plane.db.models.project class Migration(migrations.Migration): - dependencies = [ - ('db', '0025_auto_20230331_0203'), + ("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), + model_name="projectmember", + name="view_props", + field=models.JSONField( + default=plane.db.models.project.get_default_props + ), ), - ] \ No newline at end of file + ] diff --git a/apiserver/plane/db/migrations/0027_auto_20230409_0312.py b/apiserver/plane/db/migrations/0027_auto_20230409_0312.py index 8d344cf34..0377c84e8 100644 --- a/apiserver/plane/db/migrations/0027_auto_20230409_0312.py +++ b/apiserver/plane/db/migrations/0027_auto_20230409_0312.py @@ -9,89 +9,289 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0026_alter_projectmember_view_props'), + ("db", "0026_alter_projectmember_view_props"), ] operations = [ migrations.CreateModel( - name='Estimate', + 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')), + ( + "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')}, + "verbose_name": "Estimate", + "verbose_name_plural": "Estimates", + "db_table": "estimates", + "ordering": ("name",), + "unique_together": {("name", "project")}, }, ), migrations.RemoveField( - model_name='issue', - name='attachments', + 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)]), + 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', + 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')), + ( + "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',), + "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'), + 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', + 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')), + ( + "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')}, + "verbose_name": "Estimate Point", + "verbose_name_plural": "Estimate Points", + "db_table": "estimate_points", + "ordering": ("value",), + "unique_together": {("value", "estimate")}, }, ), ] diff --git a/apiserver/plane/db/migrations/0028_auto_20230414_1703.py b/apiserver/plane/db/migrations/0028_auto_20230414_1703.py index bb0b67b92..ffccccff5 100644 --- a/apiserver/plane/db/migrations/0028_auto_20230414_1703.py +++ b/apiserver/plane/db/migrations/0028_auto_20230414_1703.py @@ -8,41 +8,99 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0027_auto_20230409_0312'), + ("db", "0027_auto_20230409_0312"), ] operations = [ migrations.AddField( - model_name='user', - name='theme', + 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)]), + 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', + 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')), + ( + "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')}, + "verbose_name": "Workspace Theme", + "verbose_name_plural": "Workspace Themes", + "db_table": "workspace_themes", + "ordering": ("-created_at",), + "unique_together": {("workspace", "name")}, }, ), ] diff --git a/apiserver/plane/db/migrations/0029_auto_20230502_0126.py b/apiserver/plane/db/migrations/0029_auto_20230502_0126.py index 373cc39bd..cd2b1b865 100644 --- a/apiserver/plane/db/migrations/0029_auto_20230502_0126.py +++ b/apiserver/plane/db/migrations/0029_auto_20230502_0126.py @@ -7,52 +7,110 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0028_auto_20230414_1703'), + ("db", "0028_auto_20230414_1703"), ] operations = [ migrations.AddField( - model_name='cycle', - name='view_props', + model_name="cycle", + name="view_props", field=models.JSONField(default=dict), ), migrations.AddField( - model_name='importer', - name='imported_data', + model_name="importer", + name="imported_data", field=models.JSONField(null=True), ), migrations.AddField( - model_name='module', - name='view_props', + model_name="module", + name="view_props", field=models.JSONField(default=dict), ), migrations.CreateModel( - name='SlackProjectSync', + 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')), + ( + "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')}, + "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/apiserver/plane/db/migrations/0030_alter_estimatepoint_unique_together.py b/apiserver/plane/db/migrations/0030_alter_estimatepoint_unique_together.py index bfc1da530..63db205dc 100644 --- a/apiserver/plane/db/migrations/0030_alter_estimatepoint_unique_together.py +++ b/apiserver/plane/db/migrations/0030_alter_estimatepoint_unique_together.py @@ -4,14 +4,13 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('db', '0029_auto_20230502_0126'), + ("db", "0029_auto_20230502_0126"), ] operations = [ migrations.AlterUniqueTogether( - name='estimatepoint', + name="estimatepoint", unique_together=set(), ), ] diff --git a/apiserver/plane/db/migrations/0031_analyticview.py b/apiserver/plane/db/migrations/0031_analyticview.py index 7e02b78b2..f4520a8f5 100644 --- a/apiserver/plane/db/migrations/0031_analyticview.py +++ b/apiserver/plane/db/migrations/0031_analyticview.py @@ -7,31 +7,75 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0030_alter_estimatepoint_unique_together'), + ("db", "0030_alter_estimatepoint_unique_together"), ] operations = [ migrations.CreateModel( - name='AnalyticView', + 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')), + ( + "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',), + "verbose_name": "Analytic", + "verbose_name_plural": "Analytics", + "db_table": "analytic_views", + "ordering": ("-created_at",), }, ), ] diff --git a/apiserver/plane/db/migrations/0032_auto_20230520_2015.py b/apiserver/plane/db/migrations/0032_auto_20230520_2015.py index 27c13537e..c781d298c 100644 --- a/apiserver/plane/db/migrations/0032_auto_20230520_2015.py +++ b/apiserver/plane/db/migrations/0032_auto_20230520_2015.py @@ -4,20 +4,19 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('db', '0031_analyticview'), + ("db", "0031_analyticview"), ] operations = [ migrations.RenameField( - model_name='project', - old_name='icon', - new_name='emoji', + model_name="project", + old_name="icon", + new_name="emoji", ), migrations.AddField( - model_name='project', - name='icon_prop', + model_name="project", + name="icon_prop", field=models.JSONField(null=True), ), ] diff --git a/apiserver/plane/db/migrations/0033_auto_20230618_2125.py b/apiserver/plane/db/migrations/0033_auto_20230618_2125.py index 8eb2eda62..1705aead6 100644 --- a/apiserver/plane/db/migrations/0033_auto_20230618_2125.py +++ b/apiserver/plane/db/migrations/0033_auto_20230618_2125.py @@ -7,77 +7,210 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0032_auto_20230520_2015'), + ("db", "0032_auto_20230520_2015"), ] operations = [ migrations.CreateModel( - name='Inbox', + 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')), + ( + "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',), + "verbose_name": "Inbox", + "verbose_name_plural": "Inboxes", + "db_table": "inboxes", + "ordering": ("name",), }, ), migrations.AddField( - model_name='project', - name='inbox_view', + model_name="project", + name="inbox_view", field=models.BooleanField(default=False), ), migrations.CreateModel( - name='InboxIssue', + 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')), + ( + "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',), + "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'), + 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'), + 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'), + 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')}, + name="inbox", + unique_together={("name", "project")}, ), ] diff --git a/apiserver/plane/db/migrations/0034_auto_20230628_1046.py b/apiserver/plane/db/migrations/0034_auto_20230628_1046.py index cdd722f59..dd6d21f6d 100644 --- a/apiserver/plane/db/migrations/0034_auto_20230628_1046.py +++ b/apiserver/plane/db/migrations/0034_auto_20230628_1046.py @@ -4,36 +4,35 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('db', '0033_auto_20230618_2125'), + ("db", "0033_auto_20230618_2125"), ] operations = [ migrations.RemoveField( - model_name='timelineissue', - name='created_by', + model_name="timelineissue", + name="created_by", ), migrations.RemoveField( - model_name='timelineissue', - name='issue', + model_name="timelineissue", + name="issue", ), migrations.RemoveField( - model_name='timelineissue', - name='project', + model_name="timelineissue", + name="project", ), migrations.RemoveField( - model_name='timelineissue', - name='updated_by', + model_name="timelineissue", + name="updated_by", ), migrations.RemoveField( - model_name='timelineissue', - name='workspace', + model_name="timelineissue", + name="workspace", ), migrations.DeleteModel( - name='Shortcut', + name="Shortcut", ), migrations.DeleteModel( - name='TimelineIssue', + name="TimelineIssue", ), ] diff --git a/apiserver/plane/db/migrations/0035_auto_20230704_2225.py b/apiserver/plane/db/migrations/0035_auto_20230704_2225.py index dec6265e6..806bfef51 100644 --- a/apiserver/plane/db/migrations/0035_auto_20230704_2225.py +++ b/apiserver/plane/db/migrations/0035_auto_20230704_2225.py @@ -10,7 +10,9 @@ def update_company_organization_size(apps, schema_editor): obj.organization_size = str(obj.company_size) updated_size.append(obj) - Model.objects.bulk_update(updated_size, ["organization_size"], batch_size=100) + Model.objects.bulk_update( + updated_size, ["organization_size"], batch_size=100 + ) class Migration(migrations.Migration): @@ -28,7 +30,9 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="workspace", name="name", - field=models.CharField(max_length=80, verbose_name="Workspace Name"), + field=models.CharField( + max_length=80, verbose_name="Workspace Name" + ), ), migrations.AlterField( model_name="workspace", diff --git a/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py b/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py index 0b182f50b..86748c778 100644 --- a/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py +++ b/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py @@ -4,15 +4,14 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('db', '0035_auto_20230704_2225'), + ("db", "0035_auto_20230704_2225"), ] operations = [ migrations.AlterField( - model_name='workspace', - name='organization_size', + model_name="workspace", + name="organization_size", field=models.CharField(max_length=20), ), ] diff --git a/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py b/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py index d11e1afd8..e659133d1 100644 --- a/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py +++ b/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py @@ -8,7 +8,6 @@ import plane.db.models.user import uuid - def onboarding_default_steps(apps, schema_editor): default_onboarding_schema = { "workspace_join": True, @@ -24,7 +23,9 @@ def onboarding_default_steps(apps, schema_editor): obj.is_tour_completed = True updated_user.append(obj) - Model.objects.bulk_update(updated_user, ["onboarding_step", "is_tour_completed"], batch_size=100) + Model.objects.bulk_update( + updated_user, ["onboarding_step", "is_tour_completed"], batch_size=100 + ) class Migration(migrations.Migration): @@ -78,7 +79,9 @@ class Migration(migrations.Migration): migrations.AddField( model_name="user", name="onboarding_step", - field=models.JSONField(default=plane.db.models.user.get_default_onboarding), + field=models.JSONField( + default=plane.db.models.user.get_default_onboarding + ), ), migrations.RunPython(onboarding_default_steps), migrations.CreateModel( @@ -86,7 +89,9 @@ class Migration(migrations.Migration): fields=[ ( "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), ), ( "updated_at", @@ -110,7 +115,10 @@ class Migration(migrations.Migration): ("entity_name", models.CharField(max_length=255)), ("title", models.TextField()), ("message", models.JSONField(null=True)), - ("message_html", models.TextField(blank=True, default="

    ")), + ( + "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)), @@ -183,7 +191,9 @@ class Migration(migrations.Migration): fields=[ ( "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), ), ( "updated_at", diff --git a/apiserver/plane/db/migrations/0038_auto_20230720_1505.py b/apiserver/plane/db/migrations/0038_auto_20230720_1505.py index 1f5c63a89..5f11d9ade 100644 --- a/apiserver/plane/db/migrations/0038_auto_20230720_1505.py +++ b/apiserver/plane/db/migrations/0038_auto_20230720_1505.py @@ -1,6 +1,6 @@ # Generated by Django 4.2.3 on 2023-07-20 09:35 -from django.db import migrations, models +from django.db import migrations def restructure_theming(apps, schema_editor): @@ -15,14 +15,12 @@ def restructure_theming(apps, schema_editor): "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", "") + "darkPalette": current_theme.get("darkPalette", ""), } obj.theme = updated_theme updated_user.append(obj) - Model.objects.bulk_update( - updated_user, ["theme"], batch_size=100 - ) + Model.objects.bulk_update(updated_user, ["theme"], batch_size=100) class Migration(migrations.Migration): @@ -30,6 +28,4 @@ class Migration(migrations.Migration): ("db", "0037_issue_archived_at_project_archive_in_and_more"), ] - operations = [ - migrations.RunPython(restructure_theming) - ] + operations = [migrations.RunPython(restructure_theming)] diff --git a/apiserver/plane/db/migrations/0039_auto_20230723_2203.py b/apiserver/plane/db/migrations/0039_auto_20230723_2203.py index 5d5747543..26849d7f7 100644 --- a/apiserver/plane/db/migrations/0039_auto_20230723_2203.py +++ b/apiserver/plane/db/migrations/0039_auto_20230723_2203.py @@ -55,7 +55,9 @@ def update_workspace_member_props(apps, schema_editor): updated_workspace_member.append(obj) - Model.objects.bulk_update(updated_workspace_member, ["view_props"], batch_size=100) + Model.objects.bulk_update( + updated_workspace_member, ["view_props"], batch_size=100 + ) def update_project_member_sort_order(apps, schema_editor): @@ -67,7 +69,9 @@ def update_project_member_sort_order(apps, schema_editor): obj.sort_order = random.randint(1, 65536) updated_project_members.append(obj) - Model.objects.bulk_update(updated_project_members, ["sort_order"], batch_size=100) + Model.objects.bulk_update( + updated_project_members, ["sort_order"], batch_size=100 + ) class Migration(migrations.Migration): @@ -79,18 +83,22 @@ class Migration(migrations.Migration): 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), + 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), + 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', + model_name="projectmember", + name="sort_order", field=models.FloatField(default=65535), ), migrations.RunPython(update_project_member_sort_order), diff --git a/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py b/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py index 5662ef666..76f8e6272 100644 --- a/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py +++ b/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py @@ -8,74 +8,209 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0039_auto_20230723_2203'), + ("db", "0039_auto_20230723_2203"), ] operations = [ migrations.AddField( - model_name='projectmember', - name='preferences', - field=models.JSONField(default=plane.db.models.project.get_default_preferences), + model_name="projectmember", + name="preferences", + field=models.JSONField( + default=plane.db.models.project.get_default_preferences + ), ), migrations.AddField( - model_name='user', - name='cover_image', + model_name="user", + name="cover_image", field=models.URLField(blank=True, max_length=800, null=True), ), migrations.CreateModel( - name='IssueReaction', + 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')), + ( + "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')}, + "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', + 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')), + ( + "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')}, + "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', + 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/apiserver/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py b/apiserver/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py index 07c302c76..91119dbbd 100644 --- a/apiserver/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py +++ b/apiserver/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py @@ -10,6 +10,7 @@ import uuid import random import string + def generate_display_name(apps, schema_editor): UserModel = apps.get_model("db", "User") updated_users = [] @@ -20,7 +21,9 @@ def generate_display_name(apps, schema_editor): 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) + UserModel.objects.bulk_update( + updated_users, ["display_name"], batch_size=100 + ) def rectify_field_issue_activity(apps, schema_editor): @@ -72,7 +75,13 @@ def update_assignee_issue_activity(apps, schema_editor): Model.objects.bulk_update( updated_activity, - ["old_value", "new_value", "old_identifier", "new_identifier", "comment"], + [ + "old_value", + "new_value", + "old_identifier", + "new_identifier", + "comment", + ], batch_size=200, ) @@ -93,7 +102,9 @@ def random_cycle_order(apps, schema_editor): 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) + CycleModel.objects.bulk_update( + updated_cycles, ["sort_order"], batch_size=100 + ) def random_module_order(apps, schema_editor): @@ -102,7 +113,9 @@ def random_module_order(apps, schema_editor): 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) + ModuleModel.objects.bulk_update( + updated_modules, ["sort_order"], batch_size=100 + ) def update_user_issue_properties(apps, schema_editor): @@ -125,111 +138,353 @@ def workspace_member_properties(apps, schema_editor): updated_workspace_members.append(obj) WorkspaceMemberModel.objects.bulk_update( - updated_workspace_members, ["view_props", "default_props"], batch_size=100 + updated_workspace_members, + ["view_props", "default_props"], + batch_size=100, ) -class Migration(migrations.Migration): +class Migration(migrations.Migration): dependencies = [ - ('db', '0040_projectmember_preferences_user_cover_image_and_more'), + ("db", "0040_projectmember_preferences_user_cover_image_and_more"), ] operations = [ migrations.AddField( - model_name='cycle', - name='sort_order', + 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), + 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', + 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), + model_name="user", + name="display_name", + field=models.CharField(default="", max_length=255), ), migrations.CreateModel( - name='ExporterHistory', + 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')), + ( + "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',), + "verbose_name": "Exporter", + "verbose_name_plural": "Exporters", + "db_table": "exporters", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='ProjectDeployBoard', + 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')), + ( + "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')}, + "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', + 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')), + ( + "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')}, + "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', + model_name="modulelink", + name="title", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.RunPython(generate_display_name), diff --git a/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py index 01af46d20..f1fa99a36 100644 --- a/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py @@ -5,56 +5,762 @@ 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) + 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'), + ("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), + 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', + 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), + model_name="issuevote", + name="vote", + field=models.IntegerField( + choices=[(-1, "DOWNVOTE"), (1, "UPVOTE")], default=1 + ), ), migrations.CreateModel( - name='ProjectPublicMember', + 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')), + ( + "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')}, + "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/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py index 5a806c704..81d91bb78 100644 --- a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py @@ -24,7 +24,9 @@ def create_issue_relation(apps, schema_editor): updated_by_id=blocked_issue.updated_by_id, ) ) - IssueRelation.objects.bulk_create(updated_issue_relation, batch_size=100) + IssueRelation.objects.bulk_create( + updated_issue_relation, batch_size=100 + ) except Exception as e: print(e) capture_exception(e) @@ -36,47 +38,137 @@ def update_issue_priority_choice(apps, schema_editor): 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) + IssueModel.objects.bulk_update( + updated_issues, ["priority"], batch_size=100 + ) class Migration(migrations.Migration): - dependencies = [ - ('db', '0042_alter_analyticview_created_by_and_more'), + ("db", "0042_alter_analyticview_created_by_and_more"), ] operations = [ migrations.CreateModel( - name='IssueRelation', + 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')), + ( + "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')}, + "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', + 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'), + 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/apiserver/plane/db/migrations/0044_auto_20230913_0709.py b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py index 19a1449af..d42b3431e 100644 --- a/apiserver/plane/db/migrations/0044_auto_20230913_0709.py +++ b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py @@ -8,12 +8,16 @@ def workspace_member_props(old_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), + "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), + "target_date": old_props.get("filters", {}).get( + "target_date", None + ), "subscriber": old_props.get("filters", {}).get("subscriber", None), }, "display_filters": { @@ -27,18 +31,28 @@ def workspace_member_props(old_props): }, "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), + "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), + "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), + "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 @@ -49,12 +63,16 @@ def project_member_props(old_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), + "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), + "target_date": old_props.get("filters", {}).get( + "target_date", None + ), "subscriber": old_props.get("filters", {}).get("subscriber", None), }, "display_filters": { @@ -75,59 +93,75 @@ def cycle_module_props(old_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), + "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), + "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) + 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) + 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) + 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) + 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'), + ("db", "0043_alter_analyticview_created_by_and_more"), ] operations = [ diff --git a/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py b/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py index 4b9c1b1eb..9ac528829 100644 --- a/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py +++ b/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py @@ -21,6 +21,7 @@ def update_issue_activity_priority(apps, schema_editor): batch_size=2000, ) + def update_issue_activity_blocked(apps, schema_editor): IssueActivity = apps.get_model("db", "IssueActivity") updated_issue_activity = [] @@ -34,44 +35,104 @@ def update_issue_activity_blocked(apps, schema_editor): batch_size=1000, ) -class Migration(migrations.Migration): +class Migration(migrations.Migration): dependencies = [ - ('db', '0044_auto_20230913_0709'), + ("db", "0044_auto_20230913_0709"), ] operations = [ migrations.CreateModel( - name='GlobalView', + 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')), + ( + "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',), + "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), + model_name="workspacemember", + name="issue_props", + field=models.JSONField( + default=plane.db.models.workspace.get_issue_props + ), ), migrations.AddField( - model_name='issueactivity', - name='epoch', + model_name="issueactivity", + name="epoch", field=models.FloatField(null=True), ), migrations.RunPython(update_issue_activity_priority), diff --git a/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py index f02660e1d..be58c8f5f 100644 --- a/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py @@ -7,977 +7,2001 @@ 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) + 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): +class Migration(migrations.Migration): dependencies = [ - ('db', '0045_issueactivity_epoch_workspacemember_issue_props_and_more'), + ( + "db", + "0045_issueactivity_epoch_workspacemember_issue_props_and_more", + ), ] operations = [ migrations.AddField( - model_name='label', - name='sort_order', + 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'), + 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', + 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')), + ( + "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')}, + "verbose_name": "Issue Mention", + "verbose_name_plural": "Issue Mentions", + "db_table": "issue_mentions", + "ordering": ("-created_at",), + "unique_together": {("issue", "mention")}, }, ), migrations.RunPython(random_sort_ordering), diff --git a/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py b/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py index d44f760d0..f0a52a355 100644 --- a/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py +++ b/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py @@ -9,123 +9,288 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0046_label_sort_order_alter_analyticview_created_by_and_more'), + ("db", "0046_label_sort_order_alter_analyticview_created_by_and_more"), ] operations = [ migrations.CreateModel( - name='Webhook', + 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')), + ( + "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')}, + "verbose_name": "Webhook", + "verbose_name_plural": "Webhooks", + "db_table": "webhooks", + "ordering": ("-created_at",), + "unique_together": {("workspace", "url")}, }, ), migrations.AddField( - model_name='apitoken', - name='description', + model_name="apitoken", + name="description", field=models.TextField(blank=True), ), migrations.AddField( - model_name='apitoken', - name='expired_at', + model_name="apitoken", + name="expired_at", field=models.DateTimeField(blank=True, null=True), ), migrations.AddField( - model_name='apitoken', - name='is_active', + model_name="apitoken", + name="is_active", field=models.BooleanField(default=True), ), migrations.AddField( - model_name='apitoken', - name='last_used', + model_name="apitoken", + name="last_used", field=models.DateTimeField(null=True), ), migrations.AddField( - model_name='projectmember', - name='is_active', + model_name="projectmember", + name="is_active", field=models.BooleanField(default=True), ), migrations.AddField( - model_name='workspacemember', - name='is_active', + 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), + 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', + 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')), + ( + "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',), + "verbose_name": "Webhook Log", + "verbose_name_plural": "Webhook Logs", + "db_table": "webhook_logs", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='APIActivityLog', + 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')), + ( + "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',), + "verbose_name": "API Activity Log", + "verbose_name_plural": "API Activity Logs", + "db_table": "api_activity_logs", + "ordering": ("-created_at",), }, ), ] diff --git a/apiserver/plane/db/migrations/0048_auto_20231116_0713.py b/apiserver/plane/db/migrations/0048_auto_20231116_0713.py index 8d896b01d..791affed6 100644 --- a/apiserver/plane/db/migrations/0048_auto_20231116_0713.py +++ b/apiserver/plane/db/migrations/0048_auto_20231116_0713.py @@ -7,48 +7,135 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('db', '0047_webhook_apitoken_description_apitoken_expired_at_and_more'), + ( + "db", + "0047_webhook_apitoken_description_apitoken_expired_at_and_more", + ), ] operations = [ migrations.CreateModel( - name='PageLog', + 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')), + ( + "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')} + "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', + model_name="page", + name="archived_at", field=models.DateField(null=True), ), migrations.AddField( - model_name='page', - name='is_locked', + 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'), + model_name="page", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="child_page", + to="db.page", + ), ), - ] \ No newline at end of file + ] diff --git a/apiserver/plane/db/migrations/0049_auto_20231116_0713.py b/apiserver/plane/db/migrations/0049_auto_20231116_0713.py index 75d5e5982..d59fc5a84 100644 --- a/apiserver/plane/db/migrations/0049_auto_20231116_0713.py +++ b/apiserver/plane/db/migrations/0049_auto_20231116_0713.py @@ -18,7 +18,9 @@ def update_pages(apps, schema_editor): # 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 + page_id=page.id, + project_id=page.project_id, + workspace_id=page.workspace_id, ).order_by("sort_order") if page_blocks: @@ -69,4 +71,4 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(update_pages), - ] \ No newline at end of file + ] diff --git a/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py b/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py index a8807d104..327a5ab72 100644 --- a/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py +++ b/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py @@ -3,37 +3,41 @@ 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'), + ("db", "0049_auto_20231116_0713"), ] operations = [ migrations.AddField( - model_name='user', - name='use_case', + model_name="user", + name="use_case", field=models.TextField(blank=True, null=True), ), migrations.AlterField( - model_name='workspace', - name='organization_size', + model_name="workspace", + name="organization_size", field=models.CharField(blank=True, max_length=20, null=True), ), migrations.AddField( - model_name='fileasset', - name='is_deleted', + 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]), + 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), + migrations.RunPython(user_password_autoset), ] diff --git a/apiserver/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py b/apiserver/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py new file mode 100644 index 000000000..886cee52d --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0052_auto_20231220_1141.py b/apiserver/plane/db/migrations/0052_auto_20231220_1141.py new file mode 100644 index 000000000..da16fb9f6 --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0053_auto_20240102_1315.py b/apiserver/plane/db/migrations/0053_auto_20240102_1315.py new file mode 100644 index 000000000..32b5ad2d5 --- /dev/null +++ b/apiserver/plane/db/migrations/0053_auto_20240102_1315.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.7 on 2024-01-02 13:15 + +from plane.db.models import WorkspaceUserProperties, ProjectMember, IssueView +from django.db import migrations + + +def workspace_user_properties(apps, schema_editor): + WorkspaceMember = apps.get_model("db", "WorkspaceMember") + 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") + 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") + 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/apiserver/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py b/apiserver/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py new file mode 100644 index 000000000..b3b5cc8c1 --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0055_auto_20240108_0648.py b/apiserver/plane/db/migrations/0055_auto_20240108_0648.py new file mode 100644 index 000000000..b13fcdea1 --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py b/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py new file mode 100644 index 000000000..2e6645945 --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0057_auto_20240122_0901.py b/apiserver/plane/db/migrations/0057_auto_20240122_0901.py new file mode 100644 index 000000000..a143917d2 --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py b/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py new file mode 100644 index 000000000..411cd47bd --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0059_auto_20240208_0957.py b/apiserver/plane/db/migrations/0059_auto_20240208_0957.py new file mode 100644 index 000000000..30d816a93 --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py b/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py new file mode 100644 index 000000000..575836a35 --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0061_project_logo_props.py b/apiserver/plane/db/migrations/0061_project_logo_props.py new file mode 100644 index 000000000..d8752d9dd --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py b/apiserver/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py new file mode 100644 index 000000000..be3f9fc2a --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0063_state_is_triage_alter_state_group.py b/apiserver/plane/db/migrations/0063_state_is_triage_alter_state_group.py new file mode 100644 index 000000000..66303dfe6 --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0064_auto_20240409_1134.py b/apiserver/plane/db/migrations/0064_auto_20240409_1134.py new file mode 100644 index 000000000..53e5938af --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0065_auto_20240415_0937.py b/apiserver/plane/db/migrations/0065_auto_20240415_0937.py new file mode 100644 index 000000000..4698c7120 --- /dev/null +++ b/apiserver/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/apiserver/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py b/apiserver/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py new file mode 100644 index 000000000..2ad5b7481 --- /dev/null +++ b/apiserver/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/apiserver/plane/db/mixins.py b/apiserver/plane/db/mixins.py index 728cb9933..f1756e5ad 100644 --- a/apiserver/plane/db/mixins.py +++ b/apiserver/plane/db/mixins.py @@ -1,26 +1,25 @@ # Python imports -import uuid # Django imports from django.db import models class TimeAuditModel(models.Model): - """To path when the record was created and last modified""" created_at = models.DateTimeField( auto_now_add=True, verbose_name="Created At", ) - updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At") + 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( @@ -43,7 +42,6 @@ class UserAuditModel(models.Model): class AuditModel(TimeAuditModel, UserAuditModel): - """To path when the record was created and last modified""" class Meta: diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index c76df6e5b..b11ce7aa3 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -1,69 +1,80 @@ -from .base import BaseModel - -from .user import User - -from .workspace import ( - Workspace, - WorkspaceMember, - Team, - WorkspaceMemberInvite, - TeamMember, - WorkspaceTheme, -) - -from .project import ( - Project, - ProjectMember, - ProjectBaseModel, - ProjectMemberInvite, - ProjectIdentifier, - ProjectFavorite, - ProjectDeployBoard, - ProjectPublicMember, -) - -from .issue import ( - Issue, - IssueActivity, - IssueProperty, - IssueComment, - IssueLabel, - IssueAssignee, - Label, - IssueBlocker, - IssueRelation, - IssueMention, - IssueLink, - IssueSequence, - IssueAttachment, - IssueSubscriber, - IssueReaction, - CommentReaction, - IssueVote, -) - +from .analytic import AnalyticView +from .api import APIActivityLog, APIToken from .asset import FileAsset - -from .social_connection import SocialLoginConnection - -from .state import State - -from .cycle import Cycle, CycleIssue, CycleFavorite - -from .view import GlobalView, IssueView, IssueViewFavorite - -from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite - -from .api import APIToken, APIActivityLog - +from .base import BaseModel +from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties +from .dashboard import Dashboard, DashboardWidget, Widget +from .estimate import Estimate, EstimatePoint +from .exporter import ExporterHistory +from .importer import Importer +from .inbox import Inbox, InboxIssue from .integration import ( - WorkspaceIntegration, - Integration, + GithubCommentSync, + GithubIssueSync, GithubRepository, GithubRepositorySync, - GithubIssueSync, - GithubCommentSync, + Integration, SlackProjectSync, + WorkspaceIntegration, +) +from .issue import ( + CommentReaction, + Issue, + IssueActivity, + IssueAssignee, + IssueAttachment, + IssueBlocker, + IssueComment, + IssueLabel, + IssueLink, + IssueMention, + IssueProperty, + IssueReaction, + IssueRelation, + IssueSequence, + IssueSubscriber, + IssueVote, + Label, +) +from .module import ( + Module, + ModuleFavorite, + ModuleIssue, + ModuleLink, + ModuleMember, + ModuleUserProperties, +) +from .notification import ( + EmailNotificationLog, + Notification, + UserNotificationPreference, +) +from .page import Page, PageFavorite, PageLabel, PageLog +from .project import ( + Project, + ProjectBaseModel, + ProjectDeployBoard, + ProjectFavorite, + 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 GlobalView, IssueView, IssueViewFavorite +from .webhook import Webhook, WebhookLog +from .workspace import ( + Team, + TeamMember, + Workspace, + WorkspaceBaseModel, + WorkspaceMember, + WorkspaceMemberInvite, + WorkspaceTheme, + WorkspaceUserProperties, ) from .importer import Importer @@ -76,8 +87,16 @@ from .inbox import Inbox, InboxIssue from .analytic import AnalyticView -from .notification import Notification +from .notification import ( + Notification, + UserNotificationPreference, + EmailNotificationLog, +) from .exporter import ExporterHistory from .webhook import Webhook, WebhookLog + +from .dashboard import Dashboard, DashboardWidget, Widget + +from .favorite import UserFavorite diff --git a/apiserver/plane/db/models/analytic.py b/apiserver/plane/db/models/analytic.py index d097051af..68747e8c4 100644 --- a/apiserver/plane/db/models/analytic.py +++ b/apiserver/plane/db/models/analytic.py @@ -1,6 +1,5 @@ # Django models from django.db import models -from django.conf import settings from .base import BaseModel diff --git a/apiserver/plane/db/models/api.py b/apiserver/plane/db/models/api.py index 0fa1d4aba..78da81814 100644 --- a/apiserver/plane/db/models/api.py +++ b/apiserver/plane/db/models/api.py @@ -38,7 +38,10 @@ class APIToken(BaseModel): choices=((0, "Human"), (1, "Bot")), default=0 ) workspace = models.ForeignKey( - "db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True + "db.Workspace", + related_name="api_tokens", + on_delete=models.CASCADE, + null=True, ) expired_at = models.DateTimeField(blank=True, null=True) diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index ab3c38d9c..86e5ceef8 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -1,16 +1,18 @@ # Python imports from uuid import uuid4 +from django.conf import settings +from django.core.exceptions import ValidationError + # Django import from django.db import models -from django.core.exceptions import ValidationError -from django.conf import settings # Module import -from . import BaseModel +from .base import BaseModel def get_upload_path(instance, filename): + filename = filename[:50] if instance.workspace_id is not None: return f"{instance.workspace.id}/{uuid4().hex}-{filename}" return f"user-{uuid4().hex}-{filename}" @@ -34,7 +36,10 @@ class FileAsset(BaseModel): ], ) workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets" + "db.Workspace", + on_delete=models.CASCADE, + null=True, + related_name="assets", ) is_deleted = models.BooleanField(default=False) diff --git a/apiserver/plane/db/models/base.py b/apiserver/plane/db/models/base.py index d0531e881..63c08afa4 100644 --- a/apiserver/plane/db/models/base.py +++ b/apiserver/plane/db/models/base.py @@ -12,7 +12,11 @@ from ..mixins import AuditModel class BaseModel(AuditModel): id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + default=uuid.uuid4, + unique=True, + editable=False, + db_index=True, + primary_key=True, ) class Meta: diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index 56301e3d3..5128ecbc5 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -1,15 +1,63 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models # Module imports -from . import ProjectBaseModel +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.DateField(verbose_name="Start Date", blank=True, null=True) + description = models.TextField( + verbose_name="Cycle Description", blank=True + ) + start_date = models.DateField( + verbose_name="Start Date", blank=True, null=True + ) end_date = models.DateField(verbose_name="End Date", blank=True, null=True) owned_by = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -18,6 +66,11 @@ class Cycle(ProjectBaseModel): ) 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) class Meta: verbose_name = "Cycle" @@ -87,3 +140,31 @@ class CycleFavorite(ProjectBaseModel): def __str__(self): """Return user and the cycle""" return f"{self.user.email} <{self.cycle.name}>" + + +class CycleUserProperties(ProjectBaseModel): + cycle = models.ForeignKey( + "db.Cycle", + on_delete=models.CASCADE, + related_name="cycle_user_properties", + ) + 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 + ) + + class Meta: + unique_together = ["cycle", "user"] + 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/apiserver/plane/db/models/dashboard.py b/apiserver/plane/db/models/dashboard.py new file mode 100644 index 000000000..f21557a54 --- /dev/null +++ b/apiserver/plane/db/models/dashboard.py @@ -0,0 +1,95 @@ +import uuid + +# Django imports +from django.db import models + +# Module imports +from ..mixins import TimeAuditModel +from .base import BaseModel + + +class Dashboard(BaseModel): + DASHBOARD_CHOICES = ( + ("workspace", "Workspace"), + ("project", "Project"), + ("home", "Home"), + ("team", "Team"), + ("user", "User"), + ) + name = models.CharField(max_length=255) + description_html = models.TextField(blank=True, default="

    ") + identifier = models.UUIDField(null=True) + owned_by = models.ForeignKey( + "db.User", + on_delete=models.CASCADE, + related_name="dashboards", + ) + is_default = models.BooleanField(default=False) + type_identifier = models.CharField( + max_length=30, + choices=DASHBOARD_CHOICES, + verbose_name="Dashboard Type", + default="home", + ) + logo_props = models.JSONField(default=dict) + + def __str__(self): + """Return name of the dashboard""" + return f"{self.name}" + + class Meta: + verbose_name = "Dashboard" + verbose_name_plural = "Dashboards" + db_table = "dashboards" + ordering = ("-created_at",) + + +class Widget(TimeAuditModel): + id = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + db_index=True, + primary_key=True, + ) + key = models.CharField(max_length=255) + filters = models.JSONField(default=dict) + logo_props = models.JSONField(default=dict) + + def __str__(self): + """Return name of the widget""" + return f"{self.key}" + + class Meta: + verbose_name = "Widget" + verbose_name_plural = "Widgets" + db_table = "widgets" + ordering = ("-created_at",) + + +class DashboardWidget(BaseModel): + widget = models.ForeignKey( + Widget, + on_delete=models.CASCADE, + related_name="dashboard_widgets", + ) + dashboard = models.ForeignKey( + Dashboard, + on_delete=models.CASCADE, + related_name="dashboard_widgets", + ) + is_visible = models.BooleanField(default=True) + sort_order = models.FloatField(default=65535) + filters = models.JSONField(default=dict) + properties = models.JSONField(default=dict) + + def __str__(self): + """Return name of the dashboard""" + return f"{self.dashboard.name} {self.widget.key}" + + class Meta: + unique_together = ("widget", "dashboard") + verbose_name = "Dashboard Widget" + verbose_name_plural = "Dashboard Widgets" + db_table = "dashboard_widgets" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py index d95a86316..6ff1186c3 100644 --- a/apiserver/plane/db/models/estimate.py +++ b/apiserver/plane/db/models/estimate.py @@ -1,14 +1,17 @@ # Django imports +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.core.validators import MinValueValidator, MaxValueValidator # Module imports -from . import ProjectBaseModel +from .project import ProjectBaseModel class Estimate(ProjectBaseModel): name = models.CharField(max_length=255) - description = models.TextField(verbose_name="Estimate Description", blank=True) + description = models.TextField( + verbose_name="Estimate Description", blank=True + ) + type = models.CharField(max_length=255, default="Categories") def __str__(self): """Return name of the estimate""" @@ -29,7 +32,7 @@ class EstimatePoint(ProjectBaseModel): related_name="points", ) key = models.IntegerField( - default=0, validators=[MinValueValidator(0), MaxValueValidator(7)] + default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] ) description = models.TextField(blank=True) value = models.CharField(max_length=20) diff --git a/apiserver/plane/db/models/exporter.py b/apiserver/plane/db/models/exporter.py index 0383807b7..9790db68d 100644 --- a/apiserver/plane/db/models/exporter.py +++ b/apiserver/plane/db/models/exporter.py @@ -3,22 +3,29 @@ import uuid # Python imports from uuid import uuid4 -# Django imports -from django.db import models from django.conf import settings from django.contrib.postgres.fields import ArrayField +# Django imports +from django.db import models + # Module imports -from . import BaseModel +from .base import BaseModel + def generate_token(): return uuid4().hex + class ExporterHistory(BaseModel): workspace = models.ForeignKey( - "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters" + "db.WorkSpace", + on_delete=models.CASCADE, + related_name="workspace_exporters", + ) + project = ArrayField( + models.UUIDField(default=uuid.uuid4), blank=True, null=True ) - project = ArrayField(models.UUIDField(default=uuid.uuid4), blank=True, null=True) provider = models.CharField( max_length=50, choices=( @@ -40,9 +47,13 @@ class ExporterHistory(BaseModel): reason = models.TextField(blank=True) key = models.TextField(blank=True) url = models.URLField(max_length=800, blank=True, null=True) - token = models.CharField(max_length=255, default=generate_token, unique=True) + token = models.CharField( + max_length=255, default=generate_token, unique=True + ) initiated_by = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="workspace_exporters" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workspace_exporters", ) class Meta: diff --git a/apiserver/plane/db/models/favorite.py b/apiserver/plane/db/models/favorite.py new file mode 100644 index 000000000..2ea1014bc --- /dev/null +++ b/apiserver/plane/db/models/favorite.py @@ -0,0 +1,52 @@ +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.IntegerField(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"] + verbose_name = "User Favorite" + verbose_name_plural = "User Favorites" + db_table = "user_favorites" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self._state.adding: + largest_sequence = UserFavorite.objects.filter( + workspace=self.project.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/apiserver/plane/db/models/importer.py b/apiserver/plane/db/models/importer.py index a2d1d3166..ebc7571d5 100644 --- a/apiserver/plane/db/models/importer.py +++ b/apiserver/plane/db/models/importer.py @@ -1,9 +1,9 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models # Module imports -from . import ProjectBaseModel +from .project import ProjectBaseModel class Importer(ProjectBaseModel): @@ -25,7 +25,9 @@ class Importer(ProjectBaseModel): default="queued", ) initiated_by = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="imports" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="imports", ) metadata = models.JSONField(default=dict) config = models.JSONField(default=dict) diff --git a/apiserver/plane/db/models/inbox.py b/apiserver/plane/db/models/inbox.py index 497a20f00..f45e90042 100644 --- a/apiserver/plane/db/models/inbox.py +++ b/apiserver/plane/db/models/inbox.py @@ -2,14 +2,17 @@ from django.db import models # Module imports -from plane.db.models import ProjectBaseModel +from plane.db.models.project import ProjectBaseModel class Inbox(ProjectBaseModel): name = models.CharField(max_length=255) - description = models.TextField(verbose_name="Inbox Description", blank=True) + description = models.TextField( + verbose_name="Inbox Description", blank=True + ) is_default = models.BooleanField(default=False) view_props = models.JSONField(default=dict) + logo_props = models.JSONField(default=dict) def __str__(self): """Return name of the Inbox""" @@ -31,14 +34,25 @@ class InboxIssue(ProjectBaseModel): "db.Issue", related_name="issue_inbox", on_delete=models.CASCADE ) status = models.IntegerField( - choices=((-2, "Pending"), (-1, "Rejected"), (0, "Snoozed"), (1, "Accepted"), (2, "Duplicate")), + 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="inbox_duplicate", on_delete=models.SET_NULL, null=True + "db.Issue", + related_name="inbox_duplicate", + on_delete=models.SET_NULL, + null=True, ) source = 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) class Meta: verbose_name = "InboxIssue" diff --git a/apiserver/plane/db/models/integration/__init__.py b/apiserver/plane/db/models/integration/__init__.py index 3bef68708..34b40e57d 100644 --- a/apiserver/plane/db/models/integration/__init__.py +++ b/apiserver/plane/db/models/integration/__init__.py @@ -1,3 +1,8 @@ from .base import Integration, WorkspaceIntegration -from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync +from .github import ( + GithubRepository, + GithubRepositorySync, + GithubIssueSync, + GithubCommentSync, +) from .slack import SlackProjectSync diff --git a/apiserver/plane/db/models/integration/base.py b/apiserver/plane/db/models/integration/base.py index 47db0483c..0c68adfd2 100644 --- a/apiserver/plane/db/models/integration/base.py +++ b/apiserver/plane/db/models/integration/base.py @@ -11,7 +11,11 @@ from plane.db.mixins import AuditModel class Integration(AuditModel): id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + default=uuid.uuid4, + unique=True, + editable=False, + db_index=True, + primary_key=True, ) title = models.CharField(max_length=400) provider = models.CharField(max_length=400, unique=True) @@ -40,14 +44,18 @@ class Integration(AuditModel): class WorkspaceIntegration(BaseModel): workspace = models.ForeignKey( - "db.Workspace", related_name="workspace_integrations", on_delete=models.CASCADE + "db.Workspace", + related_name="workspace_integrations", + on_delete=models.CASCADE, ) # Bot user actor = models.ForeignKey( "db.User", related_name="integrations", on_delete=models.CASCADE ) integration = models.ForeignKey( - "db.Integration", related_name="integrated_workspaces", on_delete=models.CASCADE + "db.Integration", + related_name="integrated_workspaces", + on_delete=models.CASCADE, ) api_token = models.ForeignKey( "db.APIToken", related_name="integrations", on_delete=models.CASCADE diff --git a/apiserver/plane/db/models/integration/github.py b/apiserver/plane/db/models/integration/github.py index f4d152bb1..9e4294175 100644 --- a/apiserver/plane/db/models/integration/github.py +++ b/apiserver/plane/db/models/integration/github.py @@ -1,11 +1,10 @@ # Python imports -import uuid # Django imports from django.db import models # Module imports -from plane.db.models import ProjectBaseModel +from plane.db.models.project import ProjectBaseModel class GithubRepository(ProjectBaseModel): @@ -36,10 +35,15 @@ class GithubRepositorySync(ProjectBaseModel): "db.User", related_name="user_syncs", on_delete=models.CASCADE ) workspace_integration = models.ForeignKey( - "db.WorkspaceIntegration", related_name="github_syncs", on_delete=models.CASCADE + "db.WorkspaceIntegration", + related_name="github_syncs", + on_delete=models.CASCADE, ) label = models.ForeignKey( - "db.Label", on_delete=models.SET_NULL, null=True, related_name="repo_syncs" + "db.Label", + on_delete=models.SET_NULL, + null=True, + related_name="repo_syncs", ) def __str__(self): @@ -62,7 +66,9 @@ class GithubIssueSync(ProjectBaseModel): "db.Issue", related_name="github_syncs", on_delete=models.CASCADE ) repository_sync = models.ForeignKey( - "db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE + "db.GithubRepositorySync", + related_name="issue_syncs", + on_delete=models.CASCADE, ) def __str__(self): @@ -80,10 +86,14 @@ class GithubIssueSync(ProjectBaseModel): class GithubCommentSync(ProjectBaseModel): repo_comment_id = models.BigIntegerField() comment = models.ForeignKey( - "db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE + "db.IssueComment", + related_name="comment_syncs", + on_delete=models.CASCADE, ) issue_sync = models.ForeignKey( - "db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE + "db.GithubIssueSync", + related_name="comment_syncs", + on_delete=models.CASCADE, ) def __str__(self): diff --git a/apiserver/plane/db/models/integration/slack.py b/apiserver/plane/db/models/integration/slack.py index 6b29968f6..94d5d7d83 100644 --- a/apiserver/plane/db/models/integration/slack.py +++ b/apiserver/plane/db/models/integration/slack.py @@ -1,11 +1,10 @@ # Python imports -import uuid # Django imports from django.db import models # Module imports -from plane.db.models import ProjectBaseModel +from plane.db.models.project import ProjectBaseModel class SlackProjectSync(ProjectBaseModel): @@ -17,7 +16,9 @@ class SlackProjectSync(ProjectBaseModel): team_id = models.CharField(max_length=30) team_name = models.CharField(max_length=300) workspace_integration = models.ForeignKey( - "db.WorkspaceIntegration", related_name="slack_syncs", on_delete=models.CASCADE + "db.WorkspaceIntegration", + related_name="slack_syncs", + on_delete=models.CASCADE, ) def __str__(self): diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 9b293a75d..527597ddc 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -2,18 +2,20 @@ from uuid import uuid4 # Django imports -from django.contrib.postgres.fields import ArrayField -from django.db import models 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 from django.db.models.signals import post_save from django.dispatch import receiver -from django.core.validators import MinValueValidator, MaxValueValidator -from django.core.exceptions import ValidationError +from django.utils import timezone # Module imports -from . import ProjectBaseModel from plane.utils.html_processor import strip_tags +from .project import ProjectBaseModel + def get_default_properties(): return { @@ -33,6 +35,50 @@ def get_default_properties(): } +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(models.Manager): def get_queryset(self): @@ -46,6 +92,7 @@ class IssueManager(models.Manager): | models.Q(issue_inbox__isnull=True) ) .exclude(archived_at__isnull=False) + .exclude(project__archived_at__isnull=False) .exclude(is_draft=True) ) @@ -73,12 +120,15 @@ class Issue(ProjectBaseModel): related_name="state_issue", ) estimate_point = models.IntegerField( - validators=[MinValueValidator(0), MaxValueValidator(7)], null=True, blank=True + validators=[MinValueValidator(0), MaxValueValidator(12)], + 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, @@ -94,7 +144,9 @@ class Issue(ProjectBaseModel): through="IssueAssignee", through_fields=("issue", "assignee"), ) - sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") + sequence_id = models.IntegerField( + default=1, verbose_name="Issue Sequence ID" + ) labels = models.ManyToManyField( "db.Label", blank=True, related_name="labels", through="IssueLabel" ) @@ -102,6 +154,8 @@ class Issue(ProjectBaseModel): 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) objects = models.Manager() issue_objects = IssueManager() @@ -119,25 +173,37 @@ class Issue(ProjectBaseModel): from plane.db.models import State default_state = State.objects.filter( - ~models.Q(name="Triage"), project=self.project, default=True + ~models.Q(is_triage=True), + project=self.project, + default=True, ).first() # if there is no default state assign any random state if default_state is None: random_state = State.objects.filter( - ~models.Q(name="Triage"), project=self.project + ~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 + # Check if the current issue state group is completed or not + if self.state.group == "completed": + self.completed_at = timezone.now() + else: + self.completed_at = None + except ImportError: + pass if self._state.adding: # Get the maximum display_id value from the database - last_id = IssueSequence.objects.filter(project=self.project).aggregate( - largest=models.Max("sequence") - )["largest"] + last_id = IssueSequence.objects.filter( + project=self.project + ).aggregate(largest=models.Max("sequence"))["largest"] # aggregate can return None! Check it first. # If it isn't none, just use the last ID specified (which should be the greatest) and add one to it if last_id: @@ -210,8 +276,9 @@ class IssueRelation(ProjectBaseModel): ordering = ("-created_at",) def __str__(self): - return f"{self.issue.name} {self.related_issue.name}" - + return f"{self.issue.name} {self.related_issue.name}" + + class IssueMention(ProjectBaseModel): issue = models.ForeignKey( Issue, on_delete=models.CASCADE, related_name="issue_mention" @@ -221,6 +288,7 @@ class IssueMention(ProjectBaseModel): on_delete=models.CASCADE, related_name="issue_mention", ) + class Meta: unique_together = ["issue", "mention"] verbose_name = "Issue Mention" @@ -229,7 +297,7 @@ class IssueMention(ProjectBaseModel): ordering = ("-created_at",) def __str__(self): - return f"{self.issue.name} {self.mention.email}" + return f"{self.issue.name} {self.mention.email}" class IssueAssignee(ProjectBaseModel): @@ -255,7 +323,7 @@ class IssueAssignee(ProjectBaseModel): class IssueLink(ProjectBaseModel): title = models.CharField(max_length=255, null=True, blank=True) - url = models.URLField() + url = models.TextField() issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="issue_link" ) @@ -305,17 +373,28 @@ class IssueAttachment(ProjectBaseModel): class IssueActivity(ProjectBaseModel): issue = models.ForeignKey( - Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity" + Issue, + on_delete=models.SET_NULL, + null=True, + related_name="issue_activity", + ) + verb = models.CharField( + max_length=255, verbose_name="Action", default="created" ) - verb = models.CharField(max_length=255, verbose_name="Action", default="created") field = models.CharField( max_length=255, verbose_name="Field Name", blank=True, null=True ) - old_value = models.TextField(verbose_name="Old Value", blank=True, null=True) - new_value = models.TextField(verbose_name="New Value", blank=True, null=True) + old_value = models.TextField( + verbose_name="Old Value", blank=True, null=True + ) + new_value = models.TextField( + verbose_name="New Value", blank=True, null=True + ) comment = models.TextField(verbose_name="Comment", blank=True) - attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + attachments = ArrayField( + models.URLField(), size=10, blank=True, default=list + ) issue_comment = models.ForeignKey( "db.IssueComment", on_delete=models.SET_NULL, @@ -347,7 +426,9 @@ class IssueComment(ProjectBaseModel): comment_stripped = models.TextField(verbose_name="Comment", blank=True) comment_json = models.JSONField(blank=True, default=dict) comment_html = models.TextField(blank=True, default="

    ") - attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + attachments = ArrayField( + models.URLField(), size=10, blank=True, default=list + ) issue = models.ForeignKey( Issue, on_delete=models.CASCADE, related_name="issue_comments" ) @@ -366,6 +447,8 @@ class IssueComment(ProjectBaseModel): 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) def save(self, *args, **kwargs): self.comment_stripped = ( @@ -390,7 +473,11 @@ class IssueProperty(ProjectBaseModel): on_delete=models.CASCADE, related_name="issue_property_user", ) - properties = models.JSONField(default=get_default_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 + ) class Meta: verbose_name = "Issue Property" @@ -416,6 +503,8 @@ class Label(ProjectBaseModel): description = models.TextField(blank=True) color = models.CharField(max_length=255, blank=True) sort_order = models.FloatField(default=65535) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) class Meta: unique_together = ["name", "project"] @@ -460,7 +549,10 @@ class IssueLabel(ProjectBaseModel): class IssueSequence(ProjectBaseModel): issue = models.ForeignKey( - Issue, on_delete=models.SET_NULL, related_name="issue_sequence", null=True + Issue, + on_delete=models.SET_NULL, + related_name="issue_sequence", + null=True, ) sequence = models.PositiveBigIntegerField(default=1) deleted = models.BooleanField(default=False) @@ -522,7 +614,9 @@ class CommentReaction(ProjectBaseModel): related_name="comment_reactions", ) comment = models.ForeignKey( - IssueComment, on_delete=models.CASCADE, related_name="comment_reactions" + IssueComment, + on_delete=models.CASCADE, + related_name="comment_reactions", ) reaction = models.CharField(max_length=20) @@ -538,9 +632,13 @@ class CommentReaction(ProjectBaseModel): class IssueVote(ProjectBaseModel): - issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes") + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="votes" + ) actor = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="votes", ) vote = models.IntegerField( choices=( @@ -569,5 +667,7 @@ class IssueVote(ProjectBaseModel): def create_issue_sequence(sender, instance, created, **kwargs): if created: IssueSequence.objects.create( - issue=instance, sequence=instance.sequence_id, project=instance.project + issue=instance, + sequence=instance.sequence_id, + project=instance.project, ) diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index ae540cc6c..a6b55f246 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -1,14 +1,60 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models # Module imports -from . import ProjectBaseModel +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 Module(ProjectBaseModel): name = models.CharField(max_length=255, verbose_name="Module Name") - description = models.TextField(verbose_name="Module Description", blank=True) + description = models.TextField( + verbose_name="Module Description", blank=True + ) description_text = models.JSONField( verbose_name="Module Description RT", blank=True, null=True ) @@ -30,7 +76,10 @@ class Module(ProjectBaseModel): max_length=20, ) lead = models.ForeignKey( - "db.User", on_delete=models.SET_NULL, related_name="module_leads", null=True + "db.User", + on_delete=models.SET_NULL, + related_name="module_leads", + null=True, ) members = models.ManyToManyField( settings.AUTH_USER_MODEL, @@ -41,6 +90,10 @@ class Module(ProjectBaseModel): ) 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"] @@ -51,9 +104,9 @@ class Module(ProjectBaseModel): def save(self, *args, **kwargs): if self._state.adding: - smallest_sort_order = Module.objects.filter(project=self.project).aggregate( - smallest=models.Min("sort_order") - )["smallest"] + smallest_sort_order = Module.objects.filter( + project=self.project + ).aggregate(smallest=models.Min("sort_order"))["smallest"] if smallest_sort_order is not None: self.sort_order = smallest_sort_order - 10000 @@ -83,11 +136,12 @@ class ModuleIssue(ProjectBaseModel): module = models.ForeignKey( "db.Module", on_delete=models.CASCADE, related_name="issue_module" ) - issue = models.OneToOneField( + issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="issue_module" ) class Meta: + unique_together = ["issue", "module"] verbose_name = "Module Issue" verbose_name_plural = "Module Issues" db_table = "module_issues" @@ -139,3 +193,31 @@ class ModuleFavorite(ProjectBaseModel): def __str__(self): """Return user and the module""" return f"{self.user.email} <{self.module.name}>" + + +class ModuleUserProperties(ProjectBaseModel): + module = models.ForeignKey( + "db.Module", + on_delete=models.CASCADE, + related_name="module_user_properties", + ) + 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 + ) + + class Meta: + unique_together = ["module", "user"] + 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/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 3df935718..33241e05d 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -1,16 +1,21 @@ # Django imports +from django.conf import settings from django.db import models -# Third party imports +# 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 + "db.Project", + related_name="notifications", + on_delete=models.CASCADE, + null=True, ) data = models.JSONField(null=True) entity_identifier = models.UUIDField(null=True) @@ -20,8 +25,17 @@ class Notification(BaseModel): 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) + 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) @@ -35,3 +49,91 @@ class Notification(BaseModel): 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/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index de65cb98f..e079dcbe5 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -1,21 +1,30 @@ import uuid +from django.conf import settings + # Django imports from django.db import models -from django.conf import settings # Module imports -from . import ProjectBaseModel from plane.utils.html_processor import strip_tags +from .project import ProjectBaseModel + + +def get_view_props(): + return {"full_width": False} + class Page(ProjectBaseModel): - name = models.CharField(max_length=255) + name = models.CharField(max_length=255, 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" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="pages", ) access = models.PositiveSmallIntegerField( choices=((0, "Public"), (1, "Private")), default=0 @@ -33,6 +42,8 @@ class Page(ProjectBaseModel): ) 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) class Meta: verbose_name = "Page" @@ -53,7 +64,7 @@ class PageLog(ProjectBaseModel): ("video", "Video"), ("file", "File"), ("link", "Link"), - ("cycle","Cycle"), + ("cycle", "Cycle"), ("module", "Module"), ("back_link", "Back Link"), ("forward_link", "Forward Link"), @@ -77,13 +88,15 @@ class PageLog(ProjectBaseModel): verbose_name_plural = "Page Logs" db_table = "page_logs" ordering = ("-created_at",) - + def __str__(self): - return f"{self.page.name} {self.type}" + return f"{self.page.name} {self.entity_name}" class PageBlock(ProjectBaseModel): - page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="blocks") + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, related_name="blocks" + ) name = models.CharField(max_length=255) description = models.JSONField(default=dict, blank=True) description_html = models.TextField(blank=True, default="

    ") @@ -112,13 +125,15 @@ class PageBlock(ProjectBaseModel): if self.completed_at and self.issue: try: - from plane.db.models import State, Issue + from plane.db.models import Issue, State completed_state = State.objects.filter( group="completed", project=self.project ).first() if completed_state is not None: - Issue.objects.update(pk=self.issue_id, state=completed_state) + Issue.objects.update( + pk=self.issue_id, state=completed_state + ) except ImportError: pass super(PageBlock, self).save(*args, **kwargs) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index fe72c260b..49fca1323 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -2,15 +2,15 @@ from uuid import uuid4 # Django imports -from django.db import models from django.conf import settings -from django.core.validators import MinValueValidator, MaxValueValidator +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models # Modeule imports from plane.db.mixins import AuditModel # Module imports -from . import BaseModel +from .base import BaseModel ROLE_CHOICES = ( (20, "Admin"), @@ -35,7 +35,7 @@ def get_default_props(): }, "display_filters": { "group_by": None, - "order_by": '-created_at', + "order_by": "-created_at", "type": None, "sub_issue": True, "show_empty_groups": True, @@ -52,16 +52,22 @@ def get_default_preferences(): class Project(BaseModel): NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) name = models.CharField(max_length=255, verbose_name="Project Name") - description = models.TextField(verbose_name="Project Description", blank=True) + description = models.TextField( + verbose_name="Project Description", blank=True + ) description_text = models.JSONField( verbose_name="Project Description RT", blank=True, null=True ) description_html = models.JSONField( verbose_name="Project Description HTML", blank=True, null=True ) - network = models.PositiveSmallIntegerField(default=2, choices=NETWORK_CHOICES) + network = models.PositiveSmallIntegerField( + default=2, choices=NETWORK_CHOICES + ) workspace = models.ForeignKey( - "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project" + "db.WorkSpace", + on_delete=models.CASCADE, + related_name="workspace_project", ) identifier = models.CharField( max_length=12, @@ -90,7 +96,10 @@ class Project(BaseModel): inbox_view = models.BooleanField(default=False) cover_image = models.URLField(blank=True, null=True, max_length=800) estimate = models.ForeignKey( - "db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True + "db.Estimate", + on_delete=models.SET_NULL, + related_name="projects", + null=True, ) archive_in = models.IntegerField( default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] @@ -98,9 +107,14 @@ class Project(BaseModel): 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" + "db.State", + on_delete=models.SET_NULL, + null=True, + related_name="default_state", ) + archived_at = models.DateTimeField(null=True) def __str__(self): """Return name of the project""" @@ -195,7 +209,10 @@ class ProjectMember(ProjectBaseModel): # TODO: Remove workspace relation later class ProjectIdentifier(AuditModel): workspace = models.ForeignKey( - "db.Workspace", models.CASCADE, related_name="project_identifiers", null=True + "db.Workspace", + models.CASCADE, + related_name="project_identifiers", + null=True, ) project = models.OneToOneField( Project, on_delete=models.CASCADE, related_name="project_identifier" @@ -250,7 +267,10 @@ class ProjectDeployBoard(ProjectBaseModel): comments = models.BooleanField(default=False) reactions = models.BooleanField(default=False) inbox = models.ForeignKey( - "db.Inbox", related_name="bord_inbox", on_delete=models.SET_NULL, null=True + "db.Inbox", + related_name="bord_inbox", + on_delete=models.SET_NULL, + null=True, ) votes = models.BooleanField(default=False) views = models.JSONField(default=get_default_views) diff --git a/apiserver/plane/db/models/session.py b/apiserver/plane/db/models/session.py new file mode 100644 index 000000000..95e8e0b7d --- /dev/null +++ b/apiserver/plane/db/models/session.py @@ -0,0 +1,65 @@ +# 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, + ) + + @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/apiserver/plane/db/models/social_connection.py b/apiserver/plane/db/models/social_connection.py index 938a73a62..96fbbb967 100644 --- a/apiserver/plane/db/models/social_connection.py +++ b/apiserver/plane/db/models/social_connection.py @@ -1,16 +1,16 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models from django.utils import timezone # Module import -from . import BaseModel +from .base import BaseModel class SocialLoginConnection(BaseModel): medium = models.CharField( max_length=20, - choices=(("Google", "google"), ("Github", "github")), + choices=(("Google", "google"), ("Github", "github"), ("Jira", "jira")), default=None, ) last_login_at = models.DateTimeField(default=timezone.now, null=True) diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py index 2fa1ebe38..36e053e22 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -3,12 +3,14 @@ from django.db import models from django.template.defaultfilters import slugify # Module imports -from . import ProjectBaseModel +from .project import ProjectBaseModel class State(ProjectBaseModel): name = models.CharField(max_length=255, verbose_name="State Name") - description = models.TextField(verbose_name="State Description", blank=True) + description = models.TextField( + verbose_name="State Description", blank=True + ) color = models.CharField(max_length=255, verbose_name="State Color") slug = models.SlugField(max_length=100, blank=True) sequence = models.FloatField(default=65535) @@ -19,11 +21,15 @@ class State(ProjectBaseModel): ("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""" diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index fe75a6a26..c083b631c 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -1,21 +1,23 @@ # Python imports -import uuid -import string 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.contrib.auth.models import AbstractBaseUser, UserManager, PermissionsMixin from django.utils import timezone -from django.conf import settings -# Third party imports -from sentry_sdk import capture_exception -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError +# Module imports +from ..mixins import TimeAuditModel def get_default_onboarding(): @@ -29,22 +31,36 @@ def get_default_onboarding(): class User(AbstractBaseUser, PermissionsMixin): id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + default=uuid.uuid4, + unique=True, + editable=False, + db_index=True, + primary_key=True, ) username = models.CharField(max_length=128, unique=True) - # user fields mobile_number = models.CharField(max_length=255, blank=True, null=True) - email = models.CharField(max_length=255, null=True, blank=True, unique=True) + email = models.CharField( + max_length=255, null=True, blank=True, unique=True + ) + + # identity + display_name = models.CharField(max_length=255, default="") first_name = models.CharField(max_length=255, blank=True) last_name = models.CharField(max_length=255, blank=True) - avatar = models.CharField(max_length=255, blank=True) + avatar = models.TextField(blank=True) cover_image = models.URLField(blank=True, null=True, max_length=800) # tracking metrics - date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At") - created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At") - updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At") + date_joined = models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ) + created_at = models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ) + updated_at = models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ) last_location = models.CharField(max_length=255, blank=True) created_location = models.CharField(max_length=255, blank=True) @@ -56,17 +72,10 @@ class User(AbstractBaseUser, PermissionsMixin): 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) + # random token generated token = models.CharField(max_length=64, blank=True) - billing_address_country = models.CharField(max_length=255, default="INDIA") - billing_address = models.JSONField(null=True) - has_billing_address = models.BooleanField(default=False) - - USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) - user_timezone = models.CharField(max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES) - last_active = models.DateTimeField(default=timezone.now, null=True) last_login_time = models.DateTimeField(null=True) last_logout_time = models.DateTimeField(null=True) @@ -78,18 +87,17 @@ class User(AbstractBaseUser, PermissionsMixin): ) last_login_uagent = models.TextField(blank=True) token_updated_at = models.DateTimeField(null=True) - last_workspace_id = models.UUIDField(null=True) - my_issues_prop = models.JSONField(null=True) - role = models.CharField(max_length=300, null=True, blank=True) + # my_issues_prop = models.JSONField(null=True) + is_bot = models.BooleanField(default=False) - theme = models.JSONField(default=dict) - display_name = models.CharField(max_length=255, default="") - is_tour_completed = models.BooleanField(default=False) - onboarding_step = models.JSONField(default=get_default_onboarding) - use_case = models.TextField(blank=True, null=True) + + # timezone + USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + user_timezone = models.CharField( + max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES + ) USERNAME_FIELD = "email" - REQUIRED_FIELDS = ["username"] objects = UserManager() @@ -115,7 +123,9 @@ class User(AbstractBaseUser, PermissionsMixin): self.display_name = ( self.email.split("@")[0] if len(self.email.split("@")) - else "".join(random.choice(string.ascii_letters) for _ in range(6)) + else "".join( + random.choice(string.ascii_letters) for _ in range(6) + ) ) if self.is_superuser: @@ -124,21 +134,84 @@ class User(AbstractBaseUser, PermissionsMixin): super(User, self).save(*args, **kwargs) +class Profile(TimeAuditModel): + 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) + # 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) + + class Meta: + verbose_name = "Profile" + verbose_name_plural = "Profiles" + db_table = "profiles" + ordering = ("-created_at",) + + +class Account(TimeAuditModel): + 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=(("google", "Google"), ("github", "Github")), + ) + 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 send_welcome_slack(sender, instance, created, **kwargs): - try: - if created and not instance.is_bot: - # Send message on slack as well - if settings.SLACK_BOT_TOKEN: - client = WebClient(token=settings.SLACK_BOT_TOKEN) - try: - _ = client.chat_postMessage( - channel="#trackers", - text=f"New user {instance.email} has signed up and begun the onboarding journey.", - ) - except SlackApiError as e: - print(f"Got an error: {e.response['error']}") - return - except Exception as e: - capture_exception(e) - return +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=False, + state_change=False, + comment=False, + mention=False, + issue_completed=False, + ) diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index 44bc994d0..8916bd406 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -1,11 +1,58 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models # Module import -from . import ProjectBaseModel, BaseModel +from .base import BaseModel +from .project import ProjectBaseModel +from .workspace import WorkspaceBaseModel +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, + } + + +# DEPRECATED TODO: - Remove in next release class GlobalView(BaseModel): workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="global_views" @@ -18,13 +65,14 @@ class GlobalView(BaseModel): ) query_data = models.JSONField(default=dict) sort_order = models.FloatField(default=65535) + logo_props = models.JSONField(default=dict) class Meta: verbose_name = "Global View" verbose_name_plural = "Global Views" db_table = "global_views" ordering = ("-created_at",) - + def save(self, *args, **kwargs): if self._state.adding: largest_sort_order = GlobalView.objects.filter( @@ -40,14 +88,20 @@ class GlobalView(BaseModel): return f"{self.name} <{self.workspace.name}>" -class IssueView(ProjectBaseModel): +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 + ) access = models.PositiveSmallIntegerField( default=1, choices=((0, "Private"), (1, "Public")) ) - query_data = models.JSONField(default=dict) + sort_order = models.FloatField(default=65535) + logo_props = models.JSONField(default=dict) class Meta: verbose_name = "Issue View" diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py index ea2b508e5..fbe74d03a 100644 --- a/apiserver/plane/db/models/webhook.py +++ b/apiserver/plane/db/models/webhook.py @@ -17,7 +17,9 @@ def generate_token(): def validate_schema(value): parsed_url = urlparse(value) if parsed_url.scheme not in ["http", "https"]: - raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.") + raise ValidationError( + "Invalid schema. Only HTTP and HTTPS are allowed." + ) def validate_domain(value): @@ -63,7 +65,9 @@ class WebhookLog(BaseModel): "db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs" ) # Associated webhook - webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="logs") + webhook = models.ForeignKey( + Webhook, on_delete=models.CASCADE, related_name="logs" + ) # Basic request details event_type = models.CharField(max_length=255, blank=True, null=True) diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 505bfbcfa..f9cd681ec 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -1,11 +1,10 @@ # Django imports -from django.db import models from django.conf import settings from django.core.exceptions import ValidationError +from django.db import models # Module imports -from . import BaseModel - +from .base import BaseModel ROLE_CHOICES = ( (20, "Owner"), @@ -55,6 +54,54 @@ def get_default_props(): } +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, @@ -89,7 +136,14 @@ class Workspace(BaseModel): on_delete=models.CASCADE, related_name="owner_workspace", ) - slug = models.SlugField(max_length=48, db_index=True, unique=True, validators=[slug_validator,]) + slug = models.SlugField( + max_length=48, + db_index=True, + unique=True, + validators=[ + slug_validator, + ], + ) organization_size = models.CharField(max_length=20, blank=True, null=True) def __str__(self): @@ -103,9 +157,31 @@ class Workspace(BaseModel): 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" + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_member", ) member = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -133,7 +209,9 @@ class WorkspaceMember(BaseModel): class WorkspaceMemberInvite(BaseModel): workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite" + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_member_invite", ) email = models.CharField(max_length=255) accepted = models.BooleanField(default=False) @@ -166,6 +244,7 @@ class Team(BaseModel): 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""" @@ -183,9 +262,13 @@ class TeamMember(BaseModel): workspace = models.ForeignKey( Workspace, on_delete=models.CASCADE, related_name="team_member" ) - team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="team_member") + team = models.ForeignKey( + Team, on_delete=models.CASCADE, related_name="team_member" + ) member = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="team_member" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="team_member", ) def __str__(self): @@ -205,7 +288,9 @@ class WorkspaceTheme(BaseModel): ) name = models.CharField(max_length=300) actor = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="themes", ) colors = models.JSONField(default=dict) @@ -218,3 +303,31 @@ class WorkspaceTheme(BaseModel): 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 + ) + + class Meta: + unique_together = ["workspace", "user"] + 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}" diff --git a/apiserver/plane/license/api/permissions/instance.py b/apiserver/plane/license/api/permissions/instance.py index dff16605a..9ee85404b 100644 --- a/apiserver/plane/license/api/permissions/instance.py +++ b/apiserver/plane/license/api/permissions/instance.py @@ -7,7 +7,6 @@ from plane.license.models import Instance, InstanceAdmin class InstanceAdminPermission(BasePermission): def has_permission(self, request, view): - if request.user.is_anonymous: return False diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py index b658ff148..7b9cb676f 100644 --- a/apiserver/plane/license/api/serializers/__init__.py +++ b/apiserver/plane/license/api/serializers/__init__.py @@ -1 +1,6 @@ -from .instance import InstanceSerializer, InstanceAdminSerializer, InstanceConfigurationSerializer \ No newline at end of file +from .instance import ( + InstanceSerializer, +) + +from .configuration import InstanceConfigurationSerializer +from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer diff --git a/apiserver/plane/license/api/serializers/admin.py b/apiserver/plane/license/api/serializers/admin.py new file mode 100644 index 000000000..848e94ef7 --- /dev/null +++ b/apiserver/plane/license/api/serializers/admin.py @@ -0,0 +1,41 @@ +# 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", + "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/apiserver/plane/license/api/serializers/base.py b/apiserver/plane/license/api/serializers/base.py new file mode 100644 index 000000000..0c6bba468 --- /dev/null +++ b/apiserver/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/apiserver/plane/license/api/serializers/configuration.py b/apiserver/plane/license/api/serializers/configuration.py new file mode 100644 index 000000000..1766f2113 --- /dev/null +++ b/apiserver/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/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py index 173d718d9..3b905e64d 100644 --- a/apiserver/plane/license/api/serializers/instance.py +++ b/apiserver/plane/license/api/serializers/instance.py @@ -1,49 +1,22 @@ # Module imports -from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration +from plane.license.models import Instance from plane.app.serializers import BaseSerializer from plane.app.serializers import UserAdminLiteSerializer -from plane.license.utils.encryption import decrypt_data + class InstanceSerializer(BaseSerializer): - primary_owner_details = UserAdminLiteSerializer(source="primary_owner", read_only=True) + primary_owner_details = UserAdminLiteSerializer( + source="primary_owner", read_only=True + ) class Meta: model = Instance - fields = "__all__" + exclude = [ + "license_key", + ] read_only_fields = [ "id", - "instance_id", - "license_key", - "api_key", - "version", "email", "last_checked_at", "is_setup_done", ] - - -class InstanceAdminSerializer(BaseSerializer): - user_detail = UserAdminLiteSerializer(source="user", read_only=True) - - class Meta: - model = InstanceAdmin - fields = "__all__" - read_only_fields = [ - "id", - "instance", - "user", - ] - -class InstanceConfigurationSerializer(BaseSerializer): - - class Meta: - model = InstanceConfiguration - fields = "__all__" - - def to_representation(self, instance): - data = super().to_representation(instance) - # Decrypt secrets value - if instance.is_encrypted and instance.value is not None: - data["value"] = decrypt_data(instance.value) - - return data diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py index 3a66c94c5..b10702b8a 100644 --- a/apiserver/plane/license/api/views/__init__.py +++ b/apiserver/plane/license/api/views/__init__.py @@ -1,7 +1,20 @@ from .instance import ( InstanceEndpoint, - InstanceAdminEndpoint, - InstanceConfigurationEndpoint, - InstanceAdminSignInEndpoint, SignUpScreenVisitedEndpoint, ) + + +from .configuration import ( + EmailCredentialCheckEndpoint, + InstanceConfigurationEndpoint, +) + + +from .admin import ( + InstanceAdminEndpoint, + InstanceAdminSignInEndpoint, + InstanceAdminSignUpEndpoint, + InstanceAdminUserMeEndpoint, + InstanceAdminSignOutEndpoint, + InstanceAdminUserSessionEndpoint, +) diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py new file mode 100644 index 000000000..5d93aba49 --- /dev/null +++ b/apiserver/plane/license/api/views/admin.py @@ -0,0 +1,457 @@ +# 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, +) + + +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 = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + # Register the user as an instance admin + _ = InstanceAdmin.objects.create( + user=user, + instance=instance, + ) + # Make the setup flag True + instance.is_setup_done = True + instance.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() + + # 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) + + # 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) + + # 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 = request.META.get("REMOTE_ADDR") + 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 = urljoin(base_host(request=request, is_admin=True)) + return HttpResponseRedirect(url) + except Exception: + return HttpResponseRedirect( + base_host(request=request, is_admin=True) + ) diff --git a/apiserver/plane/license/api/views/base.py b/apiserver/plane/license/api/views/base.py new file mode 100644 index 000000000..7e367f941 --- /dev/null +++ b/apiserver/plane/license/api/views/base.py @@ -0,0 +1,132 @@ +# 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/apiserver/plane/license/api/views/configuration.py b/apiserver/plane/license/api/views/configuration.py new file mode 100644 index 000000000..06f53b753 --- /dev/null +++ b/apiserver/plane/license/api/views/configuration.py @@ -0,0 +1,168 @@ +# Python imports +from smtplib import ( + SMTPAuthenticationError, + SMTPConnectError, + SMTPRecipientsRefused, + SMTPSenderRefused, + SMTPServerDisconnected, +) + +# Django imports +from django.core.mail import ( + BadHeaderError, + EmailMultiAlternatives, + get_connection, +) + +# 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 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/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index c88b3b75f..1ec09fbb5 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -1,37 +1,28 @@ # Python imports -import json import os -import requests -import uuid -import random -import string # Django imports -from django.utils import timezone -from django.contrib.auth.hashers import make_password -from django.core.validators import validate_email -from django.core.exceptions import ValidationError from django.conf import settings # Third party imports from rest_framework import status -from rest_framework.response import Response from rest_framework.permissions import AllowAny -from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework.response import Response # Module imports from plane.app.views import BaseAPIView -from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration -from plane.license.api.serializers import ( - InstanceSerializer, - InstanceAdminSerializer, - InstanceConfigurationSerializer, -) +from plane.db.models import Workspace from plane.license.api.permissions import ( InstanceAdminPermission, ) -from plane.db.models import User, WorkspaceMember, ProjectMember -from plane.license.utils.encryption import encrypt_data +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 class InstanceEndpoint(BaseAPIView): @@ -44,8 +35,10 @@ class InstanceEndpoint(BaseAPIView): AllowAny(), ] + @cache_response(60 * 60 * 2, user=False) def get(self, request): instance = Instance.objects.first() + # get the instance if instance is None: return Response( @@ -56,203 +49,129 @@ class InstanceEndpoint(BaseAPIView): serializer = InstanceSerializer(instance) data = serializer.data data["is_activated"] = True - return Response(data, status=status.HTTP_200_OK) + # Get all the configuration + ( + IS_GOOGLE_ENABLED, + IS_GITHUB_ENABLED, + GITHUB_APP_NAME, + EMAIL_HOST, + ENABLE_MAGIC_LINK_LOGIN, + ENABLE_EMAIL_PASSWORD, + SLACK_CLIENT_ID, + POSTHOG_API_KEY, + POSTHOG_HOST, + UNSPLASH_ACCESS_KEY, + OPENAI_API_KEY, + ) = get_configuration_value( + [ + { + "key": "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": "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": "OPENAI_API_KEY", + "default": os.environ.get("OPENAI_API_KEY", ""), + }, + ] + ) + data = {} + # Authentication + data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1" + data["is_github_enabled"] = IS_GITHUB_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_openai_configured"] = bool(OPENAI_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) + + # 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 + + 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) + serializer = InstanceSerializer( + instance, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class InstanceAdminEndpoint(BaseAPIView): - permission_classes = [ - InstanceAdminPermission, - ] - - # Create an instance admin - def post(self, request): - email = request.data.get("email", False) - role = request.data.get("role", 20) - - if not email: - return Response( - {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - instance = Instance.objects.first() - if instance is None: - return Response( - {"error": "Instance is not registered yet"}, - status=status.HTTP_403_FORBIDDEN, - ) - - # Fetch the user - user = User.objects.get(email=email) - - instance_admin = InstanceAdmin.objects.create( - instance=instance, - user=user, - role=role, - ) - serializer = InstanceAdminSerializer(instance_admin) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def get(self, request): - instance = Instance.objects.first() - if instance is None: - return Response( - {"error": "Instance is not registered yet"}, - status=status.HTTP_403_FORBIDDEN, - ) - instance_admins = InstanceAdmin.objects.filter(instance=instance) - serializer = InstanceAdminSerializer(instance_admins, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def delete(self, request, pk): - instance = Instance.objects.first() - instance_admin = InstanceAdmin.objects.filter(instance=instance, pk=pk).delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class InstanceConfigurationEndpoint(BaseAPIView): - permission_classes = [ - InstanceAdminPermission, - ] - - def get(self, request): - instance_configurations = InstanceConfiguration.objects.all() - serializer = InstanceConfigurationSerializer(instance_configurations, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def patch(self, request): - configurations = InstanceConfiguration.objects.filter( - key__in=request.data.keys() - ) - - bulk_configurations = [] - for configuration in configurations: - value = request.data.get(configuration.key, configuration.value) - if configuration.is_encrypted: - configuration.value = encrypt_data(value) - else: - configuration.value = value - bulk_configurations.append(configuration) - - InstanceConfiguration.objects.bulk_update( - bulk_configurations, ["value"], batch_size=100 - ) - - serializer = InstanceConfigurationSerializer(configurations, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - -def get_tokens_for_user(user): - refresh = RefreshToken.for_user(user) - return ( - str(refresh.access_token), - str(refresh), - ) - - -class InstanceAdminSignInEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - # Check instance first - instance = Instance.objects.first() - if instance is None: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # check if the instance is already activated - if InstanceAdmin.objects.first(): - return Response( - {"error": "Admin for this instance is already registered"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get the email and password from all the user - email = request.data.get("email", False) - password = request.data.get("password", False) - - # return error if the email and password is not present - if not email or not password: - return Response( - {"error": "Email and password are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Validate the email - email = email.strip().lower() - try: - validate_email(email) - except ValidationError as e: - return Response( - {"error": "Please provide a valid email address."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check if already a user exists or not - user = User.objects.filter(email=email).first() - - # Existing user - if user: - # Check user password - if not user.check_password(password): - return Response( - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - status=status.HTTP_403_FORBIDDEN, - ) - else: - user = User.objects.create( - email=email, - username=uuid.uuid4().hex, - password=make_password(password), - is_password_autoset=False, - ) - - # settings last active for the user - user.is_active = True - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - # Register the user as an instance admin - _ = InstanceAdmin.objects.create( - user=user, - instance=instance, - ) - # Make the setup flag True - instance.is_setup_done = True - instance.save() - - # get tokens for user - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - return Response(data, status=status.HTTP_200_OK) - - class SignUpScreenVisitedEndpoint(BaseAPIView): permission_classes = [ AllowAny, ] + @invalidate_cache(path="/api/instances/", user=False) def post(self, request): instance = Instance.objects.first() if instance is None: diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 67137d0d9..5a6eadc2e 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -3,7 +3,6 @@ import os # Django imports from django.core.management.base import BaseCommand -from django.conf import settings # Module imports from plane.license.models import InstanceConfiguration @@ -14,6 +13,7 @@ class Command(BaseCommand): def handle(self, *args, **options): from plane.license.utils.encryption import encrypt_data + from plane.license.utils.instance_value import get_configuration_value config_keys = [ # Authentication Settings @@ -21,7 +21,7 @@ class Command(BaseCommand): "key": "ENABLE_SIGNUP", "value": os.environ.get("ENABLE_SIGNUP", "1"), "category": "AUTHENTICATION", - "is_encrypted": False, + "is_encrypted": False, }, { "key": "ENABLE_EMAIL_PASSWORD", @@ -41,6 +41,12 @@ class Command(BaseCommand): "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"), @@ -89,6 +95,12 @@ class Command(BaseCommand): "category": "SMTP", "is_encrypted": False, }, + { + "key": "EMAIL_USE_SSL", + "value": os.environ.get("EMAIL_USE_SSL", "0"), + "category": "SMTP", + "is_encrypted": False, + }, { "key": "OPENAI_API_KEY", "value": os.environ.get("OPENAI_API_KEY"), @@ -128,5 +140,84 @@ class Command(BaseCommand): ) else: self.stdout.write( - self.style.WARNING(f"{obj.key} configuration already exists") + self.style.WARNING( + f"{obj.key} configuration already exists" + ) + ) + + keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED"] + 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." + ) + ) + else: + for key in keys: + self.stdout.write( + self.style.WARNING(f"{key} configuration already exists") ) diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index e6cfa7167..42676bb72 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -1,6 +1,5 @@ # Python imports import json -import requests import secrets # Django imports @@ -12,13 +11,15 @@ from django.conf import settings from plane.license.models import Instance from plane.db.models import User + class Command(BaseCommand): help = "Check if instance in registered else register" def add_arguments(self, parser): # Positional argument - parser.add_argument('machine_signature', type=str, help='Machine signature') - + parser.add_argument( + "machine_signature", type=str, help="Machine signature" + ) def handle(self, *args, **options): # Check if the instance is registered @@ -30,7 +31,9 @@ class Command(BaseCommand): # Load JSON content from the file data = json.load(file) - machine_signature = options.get("machine_signature", "machine-signature") + machine_signature = options.get( + "machine_signature", "machine-signature" + ) if not machine_signature: raise CommandError("Machine signature is required") @@ -43,24 +46,18 @@ class Command(BaseCommand): } instance = Instance.objects.create( - instance_name="Plane Free", + instance_name="Plane Community Edition", instance_id=secrets.token_hex(12), license_key=None, - api_key=secrets.token_hex(8), - version=payload.get("version"), + current_version=payload.get("version"), + latest_version=payload.get("version"), last_checked_at=timezone.now(), user_count=payload.get("user_count", 0), ) - self.stdout.write( - self.style.SUCCESS( - f"Instance registered" - ) - ) + self.stdout.write(self.style.SUCCESS("Instance registered")) else: self.stdout.write( - self.style.SUCCESS( - f"Instance already registered" - ) + self.style.SUCCESS("Instance already registered") ) return diff --git a/apiserver/plane/license/migrations/0001_initial.py b/apiserver/plane/license/migrations/0001_initial.py index c8b5f1f02..4eed3adf7 100644 --- a/apiserver/plane/license/migrations/0001_initial.py +++ b/apiserver/plane/license/migrations/0001_initial.py @@ -7,7 +7,6 @@ import uuid class Migration(migrations.Migration): - initial = True dependencies = [ @@ -16,74 +15,220 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Instance', + 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')), + ( + "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',), + "verbose_name": "Instance", + "verbose_name_plural": "Instances", + "db_table": "instances", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='InstanceConfiguration', + 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')), + ( + "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',), + "verbose_name": "Instance Configuration", + "verbose_name_plural": "Instance Configurations", + "db_table": "instance_configurations", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='InstanceAdmin', + 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)), + ( + "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')}, + "verbose_name": "Instance Admin", + "verbose_name_plural": "Instance Admins", + "db_table": "instance_admins", + "ordering": ("-created_at",), + "unique_together": {("instance", "user")}, }, ), ] diff --git a/apiserver/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py b/apiserver/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py new file mode 100644 index 000000000..3cdea7902 --- /dev/null +++ b/apiserver/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/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py b/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py new file mode 100644 index 000000000..8d7b9a402 --- /dev/null +++ b/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.11 on 2024-06-05 13:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("license", "0002_rename_version_instance_current_version_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="changelog", + name="title", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="changelog", + name="version", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="instance", + name="current_version", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="instance", + name="latest_version", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="instance", + name="namespace", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="instance", + name="product", + field=models.CharField(default="plane-ce", max_length=255), + ), + ] diff --git a/apiserver/plane/license/models/__init__.py b/apiserver/plane/license/models/__init__.py index 28f2c4352..0f35f718d 100644 --- a/apiserver/plane/license/models/__init__.py +++ b/apiserver/plane/license/models/__init__.py @@ -1 +1 @@ -from .instance import Instance, InstanceAdmin, InstanceConfiguration \ No newline at end of file +from .instance import Instance, InstanceAdmin, InstanceConfiguration diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py index 86845c34b..0c0581c8b 100644 --- a/apiserver/plane/license/models/instance.py +++ b/apiserver/plane/license/models/instance.py @@ -1,3 +1,6 @@ +# Python imports +from enum import Enum + # Django imports from django.db import models from django.conf import settings @@ -5,22 +8,28 @@ from django.conf import settings # Module imports from plane.db.models import BaseModel -ROLE_CHOICES = ( - (20, "Admin"), -) +ROLE_CHOICES = ((20, "Admin"),) + + +class ProductTypes(Enum): + PLANE_CE = "plane-ce" class Instance(BaseModel): - # General informations + # General information instance_name = models.CharField(max_length=255) whitelist_emails = models.TextField(blank=True, null=True) - instance_id = models.CharField(max_length=25, unique=True) + instance_id = models.CharField(max_length=255, unique=True) license_key = models.CharField(max_length=256, null=True, blank=True) - api_key = models.CharField(max_length=16) - version = models.CharField(max_length=10) - # Instnace specifics + current_version = models.CharField(max_length=255) + latest_version = models.CharField(max_length=255, null=True, blank=True) + product = models.CharField( + max_length=255, default=ProductTypes.PLANE_CE.value + ) + domain = models.TextField(blank=True) + # Instance specifics last_checked_at = models.DateTimeField() - namespace = models.CharField(max_length=50, blank=True, null=True) + namespace = models.CharField(max_length=255, blank=True, null=True) # telemetry and support is_telemetry_enabled = models.BooleanField(default=True) is_support_required = models.BooleanField(default=True) @@ -46,7 +55,9 @@ class InstanceAdmin(BaseModel): null=True, related_name="instance_owner", ) - instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins") + 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) @@ -71,3 +82,19 @@ class InstanceConfiguration(BaseModel): 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/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index 807833a7e..b4f19e52c 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -1,42 +1,72 @@ from django.urls import path from plane.license.api.views import ( - InstanceEndpoint, + EmailCredentialCheckEndpoint, InstanceAdminEndpoint, - InstanceConfigurationEndpoint, InstanceAdminSignInEndpoint, + InstanceAdminSignUpEndpoint, + InstanceConfigurationEndpoint, + InstanceEndpoint, SignUpScreenVisitedEndpoint, + InstanceAdminUserMeEndpoint, + InstanceAdminSignOutEndpoint, + InstanceAdminUserSessionEndpoint, ) urlpatterns = [ path( - "instances/", + "", InstanceEndpoint.as_view(), name="instance", ), path( - "instances/admins/", + "admins/", InstanceAdminEndpoint.as_view(), name="instance-admins", ), path( - "instances/admins//", + "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( - "instances/configurations/", + "configurations/", InstanceConfigurationEndpoint.as_view(), name="instance-configuration", ), path( - "instances/admins/sign-in/", + "admins/sign-in/", InstanceAdminSignInEndpoint.as_view(), name="instance-admin-sign-in", ), path( - "instances/admins/sign-up-screen-visited/", + "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", + ), ] diff --git a/apiserver/plane/license/utils/encryption.py b/apiserver/plane/license/utils/encryption.py index c2d369c2e..6781605dd 100644 --- a/apiserver/plane/license/utils/encryption.py +++ b/apiserver/plane/license/utils/encryption.py @@ -3,26 +3,40 @@ 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) + dk = hashlib.pbkdf2_hmac("sha256", secret_key.encode(), b"salt", 100000) return base64.urlsafe_b64encode(dk) + # Encrypt data def encrypt_data(data): - 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: + 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 + +# Decrypt data def decrypt_data(encrypted_data): - 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 "" \ No newline at end of file + 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/apiserver/plane/license/utils/instance_value.py b/apiserver/plane/license/utils/instance_value.py index e56525893..4c191feda 100644 --- a/apiserver/plane/license/utils/instance_value.py +++ b/apiserver/plane/license/utils/instance_value.py @@ -22,7 +22,9 @@ def get_configuration_value(keys): for item in instance_configuration: if key.get("key") == item.get("key"): if item.get("is_encrypted", False): - environment_list.append(decrypt_data(item.get("value"))) + environment_list.append( + decrypt_data(item.get("value")) + ) else: environment_list.append(item.get("value")) @@ -32,40 +34,45 @@ def get_configuration_value(keys): else: # Get the configuration from os for key in keys: - environment_list.append(os.environ.get(key.get("key"), key.get("default"))) + environment_list.append( + os.environ.get(key.get("key"), key.get("default")) + ) return tuple(environment_list) def get_email_configuration(): - return ( - get_configuration_value( - [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST"), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER"), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD"), - }, - { - "key": "EMAIL_PORT", - "default": os.environ.get("EMAIL_PORT", 587), - }, - { - "key": "EMAIL_USE_TLS", - "default": os.environ.get("EMAIL_USE_TLS", "1"), - }, - { - "key": "EMAIL_FROM", - "default": os.environ.get("EMAIL_FROM", "Team Plane "), - }, - ] - ) + 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/apiserver/plane/middleware/api_log_middleware.py b/apiserver/plane/middleware/api_log_middleware.py index a1894fad5..96c62c2fd 100644 --- a/apiserver/plane/middleware/api_log_middleware.py +++ b/apiserver/plane/middleware/api_log_middleware.py @@ -1,4 +1,4 @@ -from plane.db.models import APIToken, APIActivityLog +from plane.db.models import APIActivityLog class APITokenLogMiddleware: @@ -23,9 +23,13 @@ class APITokenLogMiddleware: method=request.method, query_params=request.META.get("QUERY_STRING", ""), headers=str(request.headers), - body=(request_body.decode('utf-8') if request_body else None), + body=( + request_body.decode("utf-8") if request_body else None + ), response_body=( - response.content.decode("utf-8") if response.content else None + response.content.decode("utf-8") + if response.content + else None ), response_code=response.status_code, ip_address=request.META.get("REMOTE_ADDR", None), @@ -35,6 +39,5 @@ class APITokenLogMiddleware: except Exception as e: print(e) # If the token does not exist, you can decide whether to log this as an invalid attempt - pass return None diff --git a/apiserver/plane/middleware/apps.py b/apiserver/plane/middleware/apps.py index 3da4958c1..9deac8091 100644 --- a/apiserver/plane/middleware/apps.py +++ b/apiserver/plane/middleware/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class Middleware(AppConfig): - name = 'plane.middleware' + name = "plane.middleware" diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 971ed5543..40128f9ad 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -1,20 +1,21 @@ """Global Settings""" + # Python imports import os import ssl -import certifi -from datetime import timedelta from urllib.parse import urlparse -# Django imports -from django.core.management.utils import get_random_secret_key +import certifi # Third party imports import dj_database_url import sentry_sdk + +# Django imports +from django.core.management.utils import get_random_secret_key +from sentry_sdk.integrations.celery import CeleryIntegration from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration -from sentry_sdk.integrations.celery import CeleryIntegration BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -22,7 +23,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key()) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = False +DEBUG = int(os.environ.get("DEBUG", "0")) # Allowed Hosts ALLOWED_HOSTS = ["*"] @@ -43,10 +44,9 @@ INSTALLED_APPS = [ "plane.middleware", "plane.license", "plane.api", + "plane.authentication", # Third-party things "rest_framework", - "rest_framework.authtoken", - "rest_framework_simplejwt.token_blacklist", "corsheaders", "django_celery_beat", "storages", @@ -56,7 +56,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", + "plane.authentication.middleware.session.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", @@ -69,15 +69,22 @@ MIDDLEWARE = [ # Rest Framework settings REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticated", ), - "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), - "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), + "DEFAULT_FILTER_BACKENDS": ( + "django_filters.rest_framework.DjangoFilterBackend", + ), + "EXCEPTION_HANDLER": "plane.authentication.adapter.exception.auth_exception_handler", } # Django Auth Backend -AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) # default +AUTHENTICATION_BACKENDS = ( + "django.contrib.auth.backends.ModelBackend", +) # default # Root Urls ROOT_URLCONF = "plane.urls" @@ -101,9 +108,6 @@ TEMPLATES = [ }, ] -# Cookie Settings -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True # CORS Settings CORS_ALLOW_CREDENTIALS = True @@ -114,8 +118,14 @@ cors_allowed_origins = [ ] 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 # Application Settings WSGI_APPLICATION = "plane.wsgi.application" @@ -141,6 +151,7 @@ else: "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"), } } @@ -214,6 +225,9 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 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", @@ -229,44 +243,15 @@ 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: +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}:" -# JWT Auth Configuration -SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=43200), - "REFRESH_TOKEN_LIFETIME": timedelta(days=43200), - "ROTATE_REFRESH_TOKENS": False, - "BLACKLIST_AFTER_ROTATION": False, - "UPDATE_LAST_LOGIN": False, - "ALGORITHM": "HS256", - "SIGNING_KEY": SECRET_KEY, - "VERIFYING_KEY": None, - "AUDIENCE": None, - "ISSUER": None, - "JWK_URL": None, - "LEEWAY": 0, - "AUTH_HEADER_TYPES": ("Bearer",), - "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", - "USER_ID_FIELD": "id", - "USER_ID_CLAIM": "user_id", - "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", - "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), - "TOKEN_TYPE_CLAIM": "token_type", - "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", - "JTI_CLAIM": "jti", - "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", - "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), - "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), -} - - # Celery Configuration CELERY_TIMEZONE = TIME_ZONE CELERY_TASK_SERIALIZER = "json" @@ -274,19 +259,20 @@ CELERY_ACCEPT_CONTENT = ["application/json"] if REDIS_SSL: redis_url = os.environ.get("REDIS_URL") - broker_url = ( - f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" - ) + broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" CELERY_BROKER_URL = broker_url - CELERY_RESULT_BACKEND = broker_url else: CELERY_BROKER_URL = REDIS_URL - CELERY_RESULT_BACKEND = REDIS_URL CELERY_IMPORTS = ( + # scheduled tasks "plane.bgtasks.issue_automation_task", "plane.bgtasks.exporter_expired_task", "plane.bgtasks.file_asset_task", + "plane.bgtasks.email_notification_task", + "plane.bgtasks.api_logs_task", + # management tasks + "plane.bgtasks.dummy_data_task", ) # Sentry Settings @@ -304,13 +290,15 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get( traces_sample_rate=1, send_default_pii=True, environment=os.environ.get("SENTRY_ENVIRONMENT", "development"), - profiles_sample_rate=1.0, + profiles_sample_rate=float( + os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0.5) + ), ) # Application Envs PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) # For External -SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) + FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) # Unsplash Access key @@ -322,8 +310,6 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) -# Use Minio settings -USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 # Posthog settings POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False) @@ -331,10 +317,38 @@ POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) # instance key INSTANCE_KEY = os.environ.get( - "INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3" + "INSTANCE_KEY", + "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3", ) # Skip environment variable configuration 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 = os.environ.get("SESSION_COOKIE_AGE", 604800) +SESSION_COOKIE_NAME = "plane-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 = "plane-admin-session-id" +ADMIN_SESSION_COOKIE_AGE = 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 = os.environ.get("ADMIN_BASE_URL", None) +SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) +APP_BASE_URL = os.environ.get("APP_BASE_URL") diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 8f27d4234..b175e4c83 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -1,31 +1,68 @@ """Development settings""" + +import os + from .common import * # noqa DEBUG = True # Debug Toolbar settings -INSTALLED_APPS += ("debug_toolbar",) -MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) +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 = "django.core.mail.backends.console.EmailBackend" +EMAIL_BACKEND = os.environ.get( + "EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend" +) CACHES = { "default": { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "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") +MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") # noqa -CORS_ALLOWED_ORIGINS = [ - "http://localhost:3000", - "http://127.0.0.1:3000", - "http://localhost:4000", - "http://127.0.0.1:4000", -] +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": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "loggers": { + "django.request": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + "plane": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + }, +} diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 90eb04dd5..806f83aca 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -1,4 +1,7 @@ """Production settings""" + +import os + from .common import * # noqa # SECURITY WARNING: don't run with debug turned on in production! @@ -7,12 +10,69 @@ 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",) +INSTALLED_APPS += ("scout_apm.django",) # noqa -# Honor the 'X-Forwarded-Proto' header for request.is_secure() -SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # 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": False, + "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": { + "class": "logging.StreamHandler", + "formatter": "verbose", + "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": { + "django": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": True, + }, + "django.request": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": False, + }, + "plane": { + "level": "DEBUG" if DEBUG else "ERROR", + "handlers": ["console", "file"], + "propagate": False, + }, + }, +} diff --git a/apiserver/plane/settings/redis.py b/apiserver/plane/settings/redis.py index 5b09a1277..628a3d8e6 100644 --- a/apiserver/plane/settings/redis.py +++ b/apiserver/plane/settings/redis.py @@ -1,4 +1,3 @@ -import os import redis from django.conf import settings from urllib.parse import urlparse diff --git a/apiserver/plane/settings/test.py b/apiserver/plane/settings/test.py index 34ae16555..a86b044a3 100644 --- a/apiserver/plane/settings/test.py +++ b/apiserver/plane/settings/test.py @@ -1,9 +1,12 @@ """Test Settings""" -from .common import * # noqa + +from .common import * # noqa DEBUG = True # Send it in a dummy outbox EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" -INSTALLED_APPS.append("plane.tests",) +INSTALLED_APPS.append( # noqa + "plane.tests", +) diff --git a/apiserver/plane/space/serializer/base.py b/apiserver/plane/space/serializer/base.py index 89c9725d9..4b92b06fc 100644 --- a/apiserver/plane/space/serializer/base.py +++ b/apiserver/plane/space/serializer/base.py @@ -4,8 +4,8 @@ from rest_framework import serializers class BaseSerializer(serializers.ModelSerializer): id = serializers.PrimaryKeyRelatedField(read_only=True) -class DynamicBaseSerializer(BaseSerializer): +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. @@ -31,7 +31,7 @@ class DynamicBaseSerializer(BaseSerializer): # loop through its keys and values. if isinstance(field_name, dict): for key, value in field_name.items(): - # If the value of this nested field is a list, + # If the value of this nested field is a list, # perform a recursive filter on it. if isinstance(value, list): self._filter_fields(self.fields[key], value) @@ -52,7 +52,7 @@ class DynamicBaseSerializer(BaseSerializer): allowed = set(allowed) # Remove fields from the serializer that aren't in the 'allowed' list. - for field_name in (existing - allowed): + for field_name in existing - allowed: self.fields.pop(field_name) return self.fields diff --git a/apiserver/plane/space/serializer/cycle.py b/apiserver/plane/space/serializer/cycle.py index ab4d9441d..d4f5d86e0 100644 --- a/apiserver/plane/space/serializer/cycle.py +++ b/apiserver/plane/space/serializer/cycle.py @@ -4,6 +4,7 @@ from plane.db.models import ( Cycle, ) + class CycleBaseSerializer(BaseSerializer): class Meta: model = Cycle @@ -15,4 +16,4 @@ class CycleBaseSerializer(BaseSerializer): "updated_by", "created_at", "updated_at", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/space/serializer/inbox.py b/apiserver/plane/space/serializer/inbox.py index 05d99ac55..48ec7c89d 100644 --- a/apiserver/plane/space/serializer/inbox.py +++ b/apiserver/plane/space/serializer/inbox.py @@ -36,12 +36,16 @@ class InboxIssueLiteSerializer(BaseSerializer): class IssueStateInboxSerializer(BaseSerializer): state_detail = StateLiteSerializer(read_only=True, source="state") project_detail = ProjectLiteSerializer(read_only=True, source="project") - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + label_details = LabelLiteSerializer( + read_only=True, source="labels", many=True + ) + assignee_details = UserLiteSerializer( + read_only=True, source="assignees", many=True + ) sub_issues_count = serializers.IntegerField(read_only=True) bridge_id = serializers.UUIDField(read_only=True) issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True) class Meta: model = Issue - fields = "__all__" \ No newline at end of file + fields = "__all__" diff --git a/apiserver/plane/space/serializer/issue.py b/apiserver/plane/space/serializer/issue.py index 1a9a872ef..c7b044b21 100644 --- a/apiserver/plane/space/serializer/issue.py +++ b/apiserver/plane/space/serializer/issue.py @@ -1,4 +1,3 @@ - # Django imports from django.utils import timezone @@ -47,7 +46,9 @@ class IssueStateFlatSerializer(BaseSerializer): class LabelSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) project_detail = ProjectLiteSerializer(source="project", read_only=True) class Meta: @@ -74,7 +75,9 @@ class IssueProjectLiteSerializer(BaseSerializer): class IssueRelationSerializer(BaseSerializer): - issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") + issue_detail = IssueProjectLiteSerializer( + read_only=True, source="related_issue" + ) class Meta: model = IssueRelation @@ -83,13 +86,14 @@ class IssueRelationSerializer(BaseSerializer): "relation_type", "related_issue", "issue", - "id" + "id", ] read_only_fields = [ "workspace", "project", ] + class RelatedIssueSerializer(BaseSerializer): issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") @@ -100,7 +104,7 @@ class RelatedIssueSerializer(BaseSerializer): "relation_type", "related_issue", "issue", - "id" + "id", ] read_only_fields = [ "workspace", @@ -159,7 +163,8 @@ class IssueLinkSerializer(BaseSerializer): # Validation if url already exists def create(self, validated_data): if IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=validated_data.get("issue_id") + url=validated_data.get("url"), + issue_id=validated_data.get("issue_id"), ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} @@ -183,9 +188,8 @@ class IssueAttachmentSerializer(BaseSerializer): class IssueReactionSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - + class Meta: model = IssueReaction fields = "__all__" @@ -202,9 +206,15 @@ class IssueSerializer(BaseSerializer): state_detail = StateSerializer(read_only=True, source="state") parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") label_details = LabelSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) - issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", 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) @@ -261,8 +271,12 @@ class IssueCommentSerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") issue_detail = IssueFlatSerializer(read_only=True, source="issue") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) + comment_reactions = CommentReactionLiteSerializer( + read_only=True, many=True + ) is_member = serializers.BooleanField(read_only=True) class Meta: @@ -285,7 +299,9 @@ class IssueCreateSerializer(BaseSerializer): state_detail = StateSerializer(read_only=True, source="state") created_by_detail = UserLiteSerializer(read_only=True, source="created_by") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) assignees = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), @@ -313,8 +329,10 @@ class IssueCreateSerializer(BaseSerializer): def to_representation(self, instance): data = super().to_representation(instance) - data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()] - data['labels'] = [str(label.id) for label in instance.labels.all()] + data["assignees"] = [ + str(assignee.id) for assignee in instance.assignees.all() + ] + data["labels"] = [str(label.id) for label in instance.labels.all()] return data def validate(self, data): @@ -323,7 +341,9 @@ class IssueCreateSerializer(BaseSerializer): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError("Start date cannot exceed target date") + raise serializers.ValidationError( + "Start date cannot exceed target date" + ) return data def create(self, validated_data): @@ -432,12 +452,11 @@ class IssueCreateSerializer(BaseSerializer): # Time updation occues even when other related models are updated instance.updated_at = timezone.now() return super().update(instance, validated_data) - + class IssueReactionSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - + class Meta: model = IssueReaction fields = "__all__" @@ -457,19 +476,27 @@ class CommentReactionSerializer(BaseSerializer): class IssueVoteSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") class Meta: model = IssueVote - fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"] + fields = [ + "issue", + "vote", + "workspace", + "project", + "actor", + "actor_detail", + ] read_only_fields = fields class IssuePublicSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") state_detail = StateLiteSerializer(read_only=True, source="state") - reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions") + reactions = IssueReactionSerializer( + read_only=True, many=True, source="issue_reactions" + ) votes = IssueVoteSerializer(read_only=True, many=True) class Meta: @@ -500,7 +527,3 @@ class LabelLiteSerializer(BaseSerializer): "name", "color", ] - - - - diff --git a/apiserver/plane/space/serializer/module.py b/apiserver/plane/space/serializer/module.py index 39ce9ec32..dda1861d1 100644 --- a/apiserver/plane/space/serializer/module.py +++ b/apiserver/plane/space/serializer/module.py @@ -4,6 +4,7 @@ from plane.db.models import ( Module, ) + class ModuleBaseSerializer(BaseSerializer): class Meta: model = Module @@ -15,4 +16,4 @@ class ModuleBaseSerializer(BaseSerializer): "updated_by", "created_at", "updated_at", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/space/serializer/state.py b/apiserver/plane/space/serializer/state.py index 903bcc2f4..55064ed0e 100644 --- a/apiserver/plane/space/serializer/state.py +++ b/apiserver/plane/space/serializer/state.py @@ -6,7 +6,6 @@ from plane.db.models import ( class StateSerializer(BaseSerializer): - class Meta: model = State fields = "__all__" diff --git a/apiserver/plane/space/serializer/workspace.py b/apiserver/plane/space/serializer/workspace.py index ecf99079f..a31bb3744 100644 --- a/apiserver/plane/space/serializer/workspace.py +++ b/apiserver/plane/space/serializer/workspace.py @@ -4,6 +4,7 @@ from plane.db.models import ( Workspace, ) + class WorkspaceLiteSerializer(BaseSerializer): class Meta: model = Workspace @@ -12,4 +13,4 @@ class WorkspaceLiteSerializer(BaseSerializer): "slug", "id", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/space/views/base.py b/apiserver/plane/space/views/base.py index b1d749a09..6b18a1546 100644 --- a/apiserver/plane/space/views/base.py +++ b/apiserver/plane/space/views/base.py @@ -1,27 +1,27 @@ # 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.conf import settings from django.utils import timezone -from django.db import IntegrityError -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django_filters.rest_framework import DjangoFilterBackend # Third part imports from rest_framework import status -from rest_framework import status -from rest_framework.viewsets import ModelViewSet -from rest_framework.response import Response from rest_framework.exceptions import APIException -from rest_framework.views import APIView from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated -from sentry_sdk import capture_exception -from django_filters.rest_framework import DjangoFilterBackend +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: @@ -50,6 +50,10 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): SearchFilter, ) + authentication_classes = [ + BaseSessionAuthentication, + ] + filterset_fields = [] search_fields = [] @@ -58,8 +62,10 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): try: return self.model.objects.all() except Exception as e: - capture_exception(e) - raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) + log_exception(e) + raise APIException( + "Please check the view", status.HTTP_400_BAD_REQUEST + ) def handle_exception(self, exc): """ @@ -83,23 +89,23 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": "The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): - capture_exception(e) + log_exception(e) return Response( - {"error": f"key {e} does not exist"}, + {"error": "The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST, ) - - print(e) if settings.DEBUG else print("Server Error") - capture_exception(e) - return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + 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: @@ -145,6 +151,10 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): 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) @@ -172,20 +182,22 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": "The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) - + if isinstance(e, KeyError): - return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) - - if settings.DEBUG: - print(e) - capture_exception(e) - return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + 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: diff --git a/apiserver/plane/space/views/inbox.py b/apiserver/plane/space/views/inbox.py index 53960f672..9f681c160 100644 --- a/apiserver/plane/space/views/inbox.py +++ b/apiserver/plane/space/views/inbox.py @@ -48,7 +48,8 @@ class InboxIssuePublicViewSet(BaseViewSet): super() .get_queryset() .filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + Q(snoozed_till__gte=timezone.now()) + | Q(snoozed_till__isnull=True), project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), inbox_id=self.kwargs.get("inbox_id"), @@ -80,7 +81,9 @@ class InboxIssuePublicViewSet(BaseViewSet): .prefetch_related("assignees", "labels") .order_by("issue_inbox__snoozed_till", "issue_inbox__status") .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -92,7 +95,9 @@ class InboxIssuePublicViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -124,11 +129,12 @@ class InboxIssuePublicViewSet(BaseViewSet): if not request.data.get("issue", {}).get("name", False): return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Name is required"}, + status=status.HTTP_400_BAD_REQUEST, ) # Check for valid priority - if not request.data.get("issue", {}).get("priority", "none") in [ + if request.data.get("issue", {}).get("priority", "none") not in [ "low", "medium", "high", @@ -136,7 +142,8 @@ class InboxIssuePublicViewSet(BaseViewSet): "none", ]: return Response( - {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Invalid priority"}, + status=status.HTTP_400_BAD_REQUEST, ) # Create or get state @@ -192,7 +199,10 @@ class InboxIssuePublicViewSet(BaseViewSet): ) inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + pk=pk, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox_id, ) # Get the project member if str(inbox_issue.created_by_id) != str(request.user.id): @@ -205,7 +215,9 @@ class InboxIssuePublicViewSet(BaseViewSet): issue_data = request.data.pop("issue", False) issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, ) # viewers and guests since only viewers and guests issue_data = { @@ -216,7 +228,9 @@ class InboxIssuePublicViewSet(BaseViewSet): "description": issue_data.get("description", issue.description), } - issue_serializer = IssueCreateSerializer(issue, data=issue_data, partial=True) + issue_serializer = IssueCreateSerializer( + issue, data=issue_data, partial=True + ) if issue_serializer.is_valid(): current_instance = issue @@ -237,7 +251,9 @@ class InboxIssuePublicViewSet(BaseViewSet): ) issue_serializer.save() return Response(issue_serializer.data, status=status.HTTP_200_OK) - return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) def retrieve(self, request, slug, project_id, inbox_id, pk): project_deploy_board = ProjectDeployBoard.objects.get( @@ -250,10 +266,15 @@ class InboxIssuePublicViewSet(BaseViewSet): ) inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + pk=pk, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox_id, ) issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, ) serializer = IssueStateInboxSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) @@ -269,7 +290,10 @@ class InboxIssuePublicViewSet(BaseViewSet): ) inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + pk=pk, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox_id, ) if str(inbox_issue.created_by_id) != str(request.user.id): diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index faab8834d..8c4d6e150 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -9,7 +9,6 @@ from django.db.models import ( Func, F, Q, - Count, Case, Value, CharField, @@ -128,7 +127,9 @@ class IssueCommentPublicViewSet(BaseViewSet): ) issue_activity.delay( type="comment.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + serializer.data, cls=DjangoJSONEncoder + ), actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), @@ -162,7 +163,9 @@ class IssueCommentPublicViewSet(BaseViewSet): comment = IssueComment.objects.get( workspace__slug=slug, pk=pk, actor=request.user ) - serializer = IssueCommentSerializer(comment, data=request.data, partial=True) + serializer = IssueCommentSerializer( + comment, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -191,7 +194,10 @@ class IssueCommentPublicViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) comment = IssueComment.objects.get( - workspace__slug=slug, pk=pk, project_id=project_id, actor=request.user + workspace__slug=slug, + pk=pk, + project_id=project_id, + actor=request.user, ) issue_activity.delay( type="comment.activity.deleted", @@ -261,7 +267,9 @@ class IssueReactionPublicViewSet(BaseViewSet): ) issue_activity.delay( type="issue_reaction.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), @@ -343,7 +351,9 @@ class CommentReactionPublicViewSet(BaseViewSet): serializer = CommentReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( - project_id=project_id, comment_id=comment_id, actor=request.user + project_id=project_id, + comment_id=comment_id, + actor=request.user, ) if not ProjectMember.objects.filter( project_id=project_id, @@ -357,7 +367,9 @@ class CommentReactionPublicViewSet(BaseViewSet): ) issue_activity.delay( type="comment_reaction.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=None, project_id=str(self.kwargs.get("project_id", None)), @@ -445,7 +457,9 @@ class IssueVotePublicViewSet(BaseViewSet): issue_vote.save() issue_activity.delay( type="issue_vote.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), @@ -499,21 +513,33 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): ] def get(self, request, slug, project_id): - project_deploy_board = ProjectDeployBoard.objects.get( + if not ProjectDeployBoard.objects.filter( workspace__slug=slug, project_id=project_id - ) + ).exists(): + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) filters = issue_filters(request.query_params, "GET") # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -544,7 +570,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -554,7 +582,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] + priority_order + if order_by_param == "priority" + else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -602,7 +632,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): else order_by_param ) ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" + "-max_values" + if order_by_param.startswith("-") + else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) @@ -653,4 +685,4 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): "labels": labels, }, status=status.HTTP_200_OK, - ) \ No newline at end of file + ) diff --git a/apiserver/plane/space/views/project.py b/apiserver/plane/space/views/project.py index 8cd3f55c5..10a3c3879 100644 --- a/apiserver/plane/space/views/project.py +++ b/apiserver/plane/space/views/project.py @@ -12,7 +12,6 @@ from rest_framework.permissions import AllowAny # Module imports from .base import BaseAPIView from plane.app.serializers import ProjectDeployBoardSerializer -from plane.app.permissions import ProjectMemberPermission from plane.db.models import ( Project, ProjectDeployBoard, diff --git a/apiserver/plane/tests/api/base.py b/apiserver/plane/tests/api/base.py index e3209a281..f6843c1b6 100644 --- a/apiserver/plane/tests/api/base.py +++ b/apiserver/plane/tests/api/base.py @@ -8,7 +8,9 @@ from plane.app.views.authentication import get_tokens_for_user class BaseAPITest(APITestCase): def setUp(self): - self.client = APIClient(HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10") + self.client = APIClient( + HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10" + ) class AuthenticatedAPITest(BaseAPITest): diff --git a/apiserver/plane/tests/api/test_asset.py b/apiserver/plane/tests/api/test_asset.py index 51a36ba2f..b15d32e40 100644 --- a/apiserver/plane/tests/api/test_asset.py +++ b/apiserver/plane/tests/api/test_asset.py @@ -1 +1 @@ -# TODO: Tests for File Asset Uploads \ No newline at end of file +# TODO: Tests for File Asset Uploads diff --git a/apiserver/plane/tests/api/test_auth_extended.py b/apiserver/plane/tests/api/test_auth_extended.py index 92ad92d6e..af6450ef4 100644 --- a/apiserver/plane/tests/api/test_auth_extended.py +++ b/apiserver/plane/tests/api/test_auth_extended.py @@ -1 +1 @@ -#TODO: Tests for ChangePassword and other Endpoints \ No newline at end of file +# TODO: Tests for ChangePassword and other Endpoints diff --git a/apiserver/plane/tests/api/test_authentication.py b/apiserver/plane/tests/api/test_authentication.py index 4fc46e008..36a0f7a24 100644 --- a/apiserver/plane/tests/api/test_authentication.py +++ b/apiserver/plane/tests/api/test_authentication.py @@ -21,16 +21,16 @@ class SignInEndpointTests(BaseAPITest): user.save() def test_without_data(self): - url = reverse("sign-in") response = self.client.post(url, {}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_email_validity(self): - url = reverse("sign-in") response = self.client.post( - url, {"email": "useremail.com", "password": "user@123"}, format="json" + url, + {"email": "useremail.com", "password": "user@123"}, + format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( @@ -40,7 +40,9 @@ class SignInEndpointTests(BaseAPITest): def test_password_validity(self): url = reverse("sign-in") response = self.client.post( - url, {"email": "user@plane.so", "password": "user123"}, format="json" + url, + {"email": "user@plane.so", "password": "user123"}, + format="json", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual( @@ -53,7 +55,9 @@ class SignInEndpointTests(BaseAPITest): def test_user_exists(self): url = reverse("sign-in") response = self.client.post( - url, {"email": "user@email.so", "password": "user123"}, format="json" + url, + {"email": "user@email.so", "password": "user123"}, + format="json", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual( @@ -87,15 +91,15 @@ class MagicLinkGenerateEndpointTests(BaseAPITest): user.save() def test_without_data(self): - url = reverse("magic-generate") response = self.client.post(url, {}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_email_validity(self): - url = reverse("magic-generate") - response = self.client.post(url, {"email": "useremail.com"}, format="json") + response = self.client.post( + url, {"email": "useremail.com"}, format="json" + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( response.data, {"error": "Please provide a valid email address."} @@ -107,7 +111,9 @@ class MagicLinkGenerateEndpointTests(BaseAPITest): ri = redis_instance() ri.delete("magic_user@plane.so") - response = self.client.post(url, {"email": "user@plane.so"}, format="json") + response = self.client.post( + url, {"email": "user@plane.so"}, format="json" + ) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_max_generate_attempt(self): @@ -131,7 +137,8 @@ class MagicLinkGenerateEndpointTests(BaseAPITest): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, {"error": "Max attempts exhausted. Please try again later."} + response.data, + {"error": "Max attempts exhausted. Please try again later."}, ) @@ -143,14 +150,14 @@ class MagicSignInEndpointTests(BaseAPITest): user.save() def test_without_data(self): - url = reverse("magic-sign-in") response = self.client.post(url, {}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, {"error": "User token and key are required"}) + self.assertEqual( + response.data, {"error": "User token and key are required"} + ) def test_expired_invalid_magic_link(self): - ri = redis_instance() ri.delete("magic_user@plane.so") @@ -162,11 +169,11 @@ class MagicSignInEndpointTests(BaseAPITest): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, {"error": "The magic code/link has expired please try again"} + response.data, + {"error": "The magic code/link has expired please try again"}, ) def test_invalid_magic_code(self): - ri = redis_instance() ri.delete("magic_user@plane.so") ## Create Token @@ -181,11 +188,11 @@ class MagicSignInEndpointTests(BaseAPITest): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, {"error": "Your login code was incorrect. Please try again."} + response.data, + {"error": "Your login code was incorrect. Please try again."}, ) def test_magic_code_sign_in(self): - ri = redis_instance() ri.delete("magic_user@plane.so") ## Create Token diff --git a/apiserver/plane/tests/api/test_cycle.py b/apiserver/plane/tests/api/test_cycle.py index 04c2d6ba2..72b580c99 100644 --- a/apiserver/plane/tests/api/test_cycle.py +++ b/apiserver/plane/tests/api/test_cycle.py @@ -1 +1 @@ -# TODO: Write Test for Cycle Endpoints \ No newline at end of file +# TODO: Write Test for Cycle Endpoints diff --git a/apiserver/plane/tests/api/test_issue.py b/apiserver/plane/tests/api/test_issue.py index 3e59613e0..a45ff36b1 100644 --- a/apiserver/plane/tests/api/test_issue.py +++ b/apiserver/plane/tests/api/test_issue.py @@ -1 +1 @@ -# TODO: Write Test for Issue Endpoints \ No newline at end of file +# TODO: Write Test for Issue Endpoints diff --git a/apiserver/plane/tests/api/test_oauth.py b/apiserver/plane/tests/api/test_oauth.py index e70e4fccb..1e7dac0ef 100644 --- a/apiserver/plane/tests/api/test_oauth.py +++ b/apiserver/plane/tests/api/test_oauth.py @@ -1 +1 @@ -#TODO: Tests for OAuth Authentication Endpoint \ No newline at end of file +# TODO: Tests for OAuth Authentication Endpoint diff --git a/apiserver/plane/tests/api/test_people.py b/apiserver/plane/tests/api/test_people.py index c4750f9b8..624281a2f 100644 --- a/apiserver/plane/tests/api/test_people.py +++ b/apiserver/plane/tests/api/test_people.py @@ -1 +1 @@ -# TODO: Write Test for people Endpoint \ No newline at end of file +# TODO: Write Test for people Endpoint diff --git a/apiserver/plane/tests/api/test_project.py b/apiserver/plane/tests/api/test_project.py index 49dae5581..9a7c50f19 100644 --- a/apiserver/plane/tests/api/test_project.py +++ b/apiserver/plane/tests/api/test_project.py @@ -1 +1 @@ -# TODO: Write Tests for project endpoints \ No newline at end of file +# TODO: Write Tests for project endpoints diff --git a/apiserver/plane/tests/api/test_shortcut.py b/apiserver/plane/tests/api/test_shortcut.py index 2e939af70..5103b5059 100644 --- a/apiserver/plane/tests/api/test_shortcut.py +++ b/apiserver/plane/tests/api/test_shortcut.py @@ -1 +1 @@ -# TODO: Write Test for shortcuts \ No newline at end of file +# TODO: Write Test for shortcuts diff --git a/apiserver/plane/tests/api/test_state.py b/apiserver/plane/tests/api/test_state.py index ef9631bc2..a336d955a 100644 --- a/apiserver/plane/tests/api/test_state.py +++ b/apiserver/plane/tests/api/test_state.py @@ -1 +1 @@ -# TODO: Wrote test for state endpoints \ No newline at end of file +# TODO: Wrote test for state endpoints diff --git a/apiserver/plane/tests/api/test_workspace.py b/apiserver/plane/tests/api/test_workspace.py index a1da2997a..c1e487fbe 100644 --- a/apiserver/plane/tests/api/test_workspace.py +++ b/apiserver/plane/tests/api/test_workspace.py @@ -14,7 +14,6 @@ class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest): super().setUp() def test_create_workspace(self): - url = reverse("workspace") # Test with empty data @@ -32,7 +31,9 @@ class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest): # Check other values workspace = Workspace.objects.get(pk=response.data["id"]) - workspace_member = WorkspaceMember.objects.get(workspace=workspace, member_id=self.user_id) + workspace_member = WorkspaceMember.objects.get( + workspace=workspace, member_id=self.user_id + ) self.assertEqual(workspace.owner_id, self.user_id) self.assertEqual(workspace_member.role, 20) diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index e437da078..aac6459b3 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -2,18 +2,19 @@ """ -from django.urls import path, include, re_path +from django.conf import settings +from django.urls import include, path, re_path from django.views.generic import TemplateView -from django.conf import settings - +handler404 = "plane.app.views.error_404.custom_404_view" urlpatterns = [ path("", TemplateView.as_view(template_name="index.html")), path("api/", include("plane.app.urls")), path("api/public/", include("plane.space.urls")), - path("api/licenses/", include("plane.license.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")), ] diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index be52bcce4..cd57690c6 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -1,12 +1,18 @@ # Python imports -from itertools import groupby from datetime import timedelta +from itertools import groupby # Django import from django.db import models -from django.db.models.functions import TruncDate -from django.db.models import Count, F, Sum, Value, Case, When, CharField -from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Concat +from django.db.models import Case, CharField, Count, F, Sum, Value, When +from django.db.models.functions import ( + Coalesce, + Concat, + ExtractMonth, + ExtractYear, + TruncDate, +) +from django.utils import timezone # Module imports from plane.db.models import Issue @@ -21,14 +27,18 @@ def annotate_with_monthly_dimension(queryset, field_name, attribute): # 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") + 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": @@ -37,6 +47,7 @@ def sort_data(data, temp_axis): 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 @@ -45,9 +56,11 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): 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") + queryset = annotate_with_monthly_dimension( + queryset, segment, "segmented" + ) segment = "segmented" queryset = queryset.values(x_axis) @@ -62,31 +75,56 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): ), dimension_ex=Coalesce("dimension", Value("null")), ).values("dimension") - queryset = queryset.annotate(segment=F(segment)) if segment else queryset - queryset = queryset.values("dimension", "segment") if segment else queryset.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("estimate_point")).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") + queryset = queryset.annotate(estimate=Sum("estimate_point")).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")])} + 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, cycle_id=None, module_id=None): # Total Issues in Cycle or Module total_issues = queryset.total_issues if cycle_id: - # Get all dates between the two dates - date_range = [ - queryset.start_date + timedelta(days=x) - for x in range((queryset.end_date - queryset.start_date).days + 1) - ] + if queryset.end_date and queryset.start_date: + # Get all dates between the two dates + date_range = [ + queryset.start_date + timedelta(days=x) + for x in range( + (queryset.end_date - queryset.start_date).days + 1 + ) + ] + else: + date_range = [] chart_data = {str(date): 0 for date in date_range} @@ -107,7 +145,9 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): # 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) + for x in range( + (queryset.target_date - queryset.start_date).days + 1 + ) ] chart_data = {str(date): 0 for date in date_range} @@ -134,6 +174,9 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): if item["date"] is not None and item["date"] <= date ) cumulative_pending_issues -= total_completed - chart_data[str(date)] = cumulative_pending_issues + if date > timezone.now().date(): + chart_data[str(date)] = None + else: + chart_data[str(date)] = cumulative_pending_issues return chart_data diff --git a/apiserver/plane/utils/cache.py b/apiserver/plane/utils/cache.py new file mode 100644 index 000000000..bda942899 --- /dev/null +++ b/apiserver/plane/utils/cache.py @@ -0,0 +1,96 @@ +# 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/apiserver/plane/utils/exception_logger.py b/apiserver/plane/utils/exception_logger.py new file mode 100644 index 000000000..0938f054b --- /dev/null +++ b/apiserver/plane/utils/exception_logger.py @@ -0,0 +1,16 @@ +# Python imports +import logging + +# Third party imports +from sentry_sdk import capture_exception + + +def log_exception(e): + print(e) + # Log the error + logger = logging.getLogger("plane") + logger.error(e) + + # Capture in sentry if configured + capture_exception(e) + return diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index 853874b31..edc7adc15 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -40,77 +40,144 @@ def group_results(results_data, group_by, sub_group_by=False): for value in results_data: main_group_attribute = resolve_keys(sub_group_by, value) group_attribute = resolve_keys(group_by, value) - if isinstance(main_group_attribute, list) and not isinstance(group_attribute, list): + if isinstance(main_group_attribute, list) and not isinstance( + group_attribute, list + ): if len(main_group_attribute): for attrib in main_group_attribute: if str(attrib) not in main_responsive_dict: main_responsive_dict[str(attrib)] = {} - if str(group_attribute) in main_responsive_dict[str(attrib)]: - main_responsive_dict[str(attrib)][str(group_attribute)].append(value) + if ( + str(group_attribute) + in main_responsive_dict[str(attrib)] + ): + main_responsive_dict[str(attrib)][ + str(group_attribute) + ].append(value) else: - main_responsive_dict[str(attrib)][str(group_attribute)] = [] - main_responsive_dict[str(attrib)][str(group_attribute)].append(value) + main_responsive_dict[str(attrib)][ + str(group_attribute) + ] = [] + main_responsive_dict[str(attrib)][ + str(group_attribute) + ].append(value) else: if str(None) not in main_responsive_dict: main_responsive_dict[str(None)] = {} if str(group_attribute) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][str(group_attribute)].append(value) + main_responsive_dict[str(None)][ + str(group_attribute) + ].append(value) else: - main_responsive_dict[str(None)][str(group_attribute)] = [] - main_responsive_dict[str(None)][str(group_attribute)].append(value) + main_responsive_dict[str(None)][ + str(group_attribute) + ] = [] + main_responsive_dict[str(None)][ + str(group_attribute) + ].append(value) - elif isinstance(group_attribute, list) and not isinstance(main_group_attribute, list): + elif isinstance(group_attribute, list) and not isinstance( + main_group_attribute, list + ): if str(main_group_attribute) not in main_responsive_dict: main_responsive_dict[str(main_group_attribute)] = {} if len(group_attribute): for attrib in group_attribute: - if str(attrib) in main_responsive_dict[str(main_group_attribute)]: - main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value) + if ( + str(attrib) + in main_responsive_dict[str(main_group_attribute)] + ): + main_responsive_dict[str(main_group_attribute)][ + str(attrib) + ].append(value) else: - main_responsive_dict[str(main_group_attribute)][str(attrib)] = [] - main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value) + main_responsive_dict[str(main_group_attribute)][ + str(attrib) + ] = [] + main_responsive_dict[str(main_group_attribute)][ + str(attrib) + ].append(value) else: - if str(None) in main_responsive_dict[str(main_group_attribute)]: - main_responsive_dict[str(main_group_attribute)][str(None)].append(value) + if ( + str(None) + in main_responsive_dict[str(main_group_attribute)] + ): + main_responsive_dict[str(main_group_attribute)][ + str(None) + ].append(value) else: - main_responsive_dict[str(main_group_attribute)][str(None)] = [] - main_responsive_dict[str(main_group_attribute)][str(None)].append(value) + main_responsive_dict[str(main_group_attribute)][ + str(None) + ] = [] + main_responsive_dict[str(main_group_attribute)][ + str(None) + ].append(value) - elif isinstance(group_attribute, list) and isinstance(main_group_attribute, list): + elif isinstance(group_attribute, list) and isinstance( + main_group_attribute, list + ): if len(main_group_attribute): for main_attrib in main_group_attribute: if str(main_attrib) not in main_responsive_dict: main_responsive_dict[str(main_attrib)] = {} if len(group_attribute): for attrib in group_attribute: - if str(attrib) in main_responsive_dict[str(main_attrib)]: - main_responsive_dict[str(main_attrib)][str(attrib)].append(value) + if ( + str(attrib) + in main_responsive_dict[str(main_attrib)] + ): + main_responsive_dict[str(main_attrib)][ + str(attrib) + ].append(value) else: - main_responsive_dict[str(main_attrib)][str(attrib)] = [] - main_responsive_dict[str(main_attrib)][str(attrib)].append(value) + main_responsive_dict[str(main_attrib)][ + str(attrib) + ] = [] + main_responsive_dict[str(main_attrib)][ + str(attrib) + ].append(value) else: - if str(None) in main_responsive_dict[str(main_attrib)]: - main_responsive_dict[str(main_attrib)][str(None)].append(value) + if ( + str(None) + in main_responsive_dict[str(main_attrib)] + ): + main_responsive_dict[str(main_attrib)][ + str(None) + ].append(value) else: - main_responsive_dict[str(main_attrib)][str(None)] = [] - main_responsive_dict[str(main_attrib)][str(None)].append(value) + main_responsive_dict[str(main_attrib)][ + str(None) + ] = [] + main_responsive_dict[str(main_attrib)][ + str(None) + ].append(value) else: if str(None) not in main_responsive_dict: main_responsive_dict[str(None)] = {} if len(group_attribute): for attrib in group_attribute: if str(attrib) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][str(attrib)].append(value) + main_responsive_dict[str(None)][ + str(attrib) + ].append(value) else: - main_responsive_dict[str(None)][str(attrib)] = [] - main_responsive_dict[str(None)][str(attrib)].append(value) + main_responsive_dict[str(None)][ + str(attrib) + ] = [] + main_responsive_dict[str(None)][ + str(attrib) + ].append(value) else: if str(None) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][str(None)].append(value) + main_responsive_dict[str(None)][str(None)].append( + value + ) else: main_responsive_dict[str(None)][str(None)] = [] - main_responsive_dict[str(None)][str(None)].append(value) + main_responsive_dict[str(None)][str(None)].append( + value + ) else: main_group_attribute = resolve_keys(sub_group_by, value) group_attribute = resolve_keys(group_by, value) @@ -118,13 +185,22 @@ def group_results(results_data, group_by, sub_group_by=False): if str(main_group_attribute) not in main_responsive_dict: main_responsive_dict[str(main_group_attribute)] = {} - if str(group_attribute) in main_responsive_dict[str(main_group_attribute)]: - main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) + if ( + str(group_attribute) + in main_responsive_dict[str(main_group_attribute)] + ): + main_responsive_dict[str(main_group_attribute)][ + str(group_attribute) + ].append(value) else: - main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = [] - main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) + main_responsive_dict[str(main_group_attribute)][ + str(group_attribute) + ] = [] + main_responsive_dict[str(main_group_attribute)][ + str(group_attribute) + ].append(value) - return main_responsive_dict + return main_responsive_dict else: response_dict = {} diff --git a/apiserver/plane/utils/html_processor.py b/apiserver/plane/utils/html_processor.py index 5f61607e9..18d103b64 100644 --- a/apiserver/plane/utils/html_processor.py +++ b/apiserver/plane/utils/html_processor.py @@ -1,15 +1,17 @@ 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.convert_charrefs = True self.text = StringIO() def handle_data(self, d): @@ -18,6 +20,7 @@ class MLStripper(HTMLParser): def get_data(self): return self.text.getvalue() + def strip_tags(html): s = MLStripper() s.feed(html) diff --git a/apiserver/plane/utils/importers/jira.py b/apiserver/plane/utils/importers/jira.py deleted file mode 100644 index b427ba14f..000000000 --- a/apiserver/plane/utils/importers/jira.py +++ /dev/null @@ -1,53 +0,0 @@ -import requests -from requests.auth import HTTPBasicAuth -from sentry_sdk import capture_exception - - -def jira_project_issue_summary(email, api_token, project_key, hostname): - try: - auth = HTTPBasicAuth(email, api_token) - headers = {"Accept": "application/json"} - - issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Story" - issue_response = requests.request( - "GET", issue_url, headers=headers, auth=auth - ).json()["total"] - - module_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Epic" - module_response = requests.request( - "GET", module_url, headers=headers, auth=auth - ).json()["total"] - - status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_key}" - status_response = requests.request( - "GET", status_url, headers=headers, auth=auth - ).json() - - labels_url = f"https://{hostname}/rest/api/3/label/?jql=project={project_key}" - labels_response = requests.request( - "GET", labels_url, headers=headers, auth=auth - ).json()["total"] - - users_url = ( - f"https://{hostname}/rest/api/3/users/search?jql=project={project_key}" - ) - users_response = requests.request( - "GET", users_url, headers=headers, auth=auth - ).json() - - return { - "issues": issue_response, - "modules": module_response, - "labels": labels_response, - "states": len(status_response), - "users": ( - [ - user - for user in users_response - if user.get("accountType") == "atlassian" - ] - ), - } - except Exception as e: - capture_exception(e) - return {"error": "Something went wrong could not fetch information from jira"} diff --git a/apiserver/plane/utils/imports.py b/apiserver/plane/utils/imports.py index 5f9f1c98c..89753ef1d 100644 --- a/apiserver/plane/utils/imports.py +++ b/apiserver/plane/utils/imports.py @@ -8,13 +8,12 @@ def import_submodules(context, root_module, path): >>> import_submodules(locals(), __name__, __path__) """ for loader, module_name, is_pkg in pkgutil.walk_packages( - path, - root_module + - '.'): + 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__']) + module = __import__(module_name, globals(), locals(), ["__name__"]) for k, v in six.iteritems(vars(module)): - if not k.startswith('_'): + if not k.startswith("_"): context[k] = v context[module_name] = module diff --git a/apiserver/plane/utils/integrations/github.py b/apiserver/plane/utils/integrations/github.py deleted file mode 100644 index 45cb5925a..000000000 --- a/apiserver/plane/utils/integrations/github.py +++ /dev/null @@ -1,152 +0,0 @@ -import os -import jwt -import requests -from urllib.parse import urlparse, parse_qs -from datetime import datetime, timedelta -from cryptography.hazmat.primitives.serialization import load_pem_private_key -from cryptography.hazmat.backends import default_backend -from django.conf import settings - - -def get_jwt_token(): - app_id = os.environ.get("GITHUB_APP_ID", "") - secret = bytes(os.environ.get("GITHUB_APP_PRIVATE_KEY", ""), encoding="utf8") - current_timestamp = int(datetime.now().timestamp()) - due_date = datetime.now() + timedelta(minutes=10) - expiry = int(due_date.timestamp()) - payload = { - "iss": app_id, - "sub": app_id, - "exp": expiry, - "iat": current_timestamp, - "aud": "https://github.com/login/oauth/access_token", - } - - priv_rsakey = load_pem_private_key(secret, None, default_backend()) - token = jwt.encode(payload, priv_rsakey, algorithm="RS256") - return token - - -def get_github_metadata(installation_id): - token = get_jwt_token() - - url = f"https://api.github.com/app/installations/{installation_id}" - headers = { - "Authorization": "Bearer " + str(token), - "Accept": "application/vnd.github+json", - } - response = requests.get(url, headers=headers).json() - return response - - -def get_github_repos(access_tokens_url, repositories_url): - token = get_jwt_token() - - headers = { - "Authorization": "Bearer " + str(token), - "Accept": "application/vnd.github+json", - } - - oauth_response = requests.post( - access_tokens_url, - headers=headers, - ).json() - - oauth_token = oauth_response.get("token", "") - headers = { - "Authorization": "Bearer " + str(oauth_token), - "Accept": "application/vnd.github+json", - } - response = requests.get( - repositories_url, - headers=headers, - ).json() - return response - - -def delete_github_installation(installation_id): - token = get_jwt_token() - - url = f"https://api.github.com/app/installations/{installation_id}" - headers = { - "Authorization": "Bearer " + str(token), - "Accept": "application/vnd.github+json", - } - response = requests.delete(url, headers=headers) - return response - - -def get_github_repo_details(access_tokens_url, owner, repo): - token = get_jwt_token() - - headers = { - "Authorization": "Bearer " + str(token), - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } - - oauth_response = requests.post( - access_tokens_url, - headers=headers, - ).json() - - oauth_token = oauth_response.get("token") - headers = { - "Authorization": "Bearer " + oauth_token, - "Accept": "application/vnd.github+json", - } - open_issues = requests.get( - f"https://api.github.com/repos/{owner}/{repo}", - headers=headers, - ).json()["open_issues_count"] - - total_labels = 0 - - labels_response = requests.get( - f"https://api.github.com/repos/{owner}/{repo}/labels?per_page=100&page=1", - headers=headers, - ) - - # Check if there are more pages - if len(labels_response.links.keys()): - # get the query parameter of last - last_url = labels_response.links.get("last").get("url") - parsed_url = urlparse(last_url) - last_page_value = parse_qs(parsed_url.query)["page"][0] - total_labels = total_labels + 100 * (int(last_page_value) - 1) - - # Get labels in last page - last_page_labels = requests.get(last_url, headers=headers).json() - total_labels = total_labels + len(last_page_labels) - else: - total_labels = len(labels_response.json()) - - # Currently only supporting upto 100 collaborators - # TODO: Update this function to fetch all collaborators - collaborators = requests.get( - f"https://api.github.com/repos/{owner}/{repo}/collaborators?per_page=100&page=1", - headers=headers, - ).json() - - return open_issues, total_labels, collaborators - - -def get_release_notes(): - token = settings.GITHUB_ACCESS_TOKEN - - if token: - headers = { - "Authorization": "Bearer " + str(token), - "Accept": "application/vnd.github.v3+json", - } - else: - headers = { - "Accept": "application/vnd.github.v3+json", - } - url = "https://api.github.com/repos/makeplane/plane/releases?per_page=5&page=1" - response = requests.get(url, headers=headers) - - if response.status_code != 200: - return {"error": "Unable to render information from Github Repository"} - - return response.json() diff --git a/apiserver/plane/utils/integrations/slack.py b/apiserver/plane/utils/integrations/slack.py deleted file mode 100644 index 70f26e160..000000000 --- a/apiserver/plane/utils/integrations/slack.py +++ /dev/null @@ -1,20 +0,0 @@ -import os -import requests - -def slack_oauth(code): - SLACK_OAUTH_URL = os.environ.get("SLACK_OAUTH_URL", False) - SLACK_CLIENT_ID = os.environ.get("SLACK_CLIENT_ID", False) - SLACK_CLIENT_SECRET = os.environ.get("SLACK_CLIENT_SECRET", False) - - # Oauth Slack - if SLACK_OAUTH_URL and SLACK_CLIENT_ID and SLACK_CLIENT_SECRET: - response = requests.get( - SLACK_OAUTH_URL, - params={ - "code": code, - "client_id": SLACK_CLIENT_ID, - "client_secret": SLACK_CLIENT_SECRET, - }, - ) - return response.json() - return {} diff --git a/apiserver/plane/utils/ip_address.py b/apiserver/plane/utils/ip_address.py index 06ca4353d..01789c431 100644 --- a/apiserver/plane/utils/ip_address.py +++ b/apiserver/plane/utils/ip_address.py @@ -1,7 +1,7 @@ def get_client_ip(request): - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") if x_forwarded_for: - ip = x_forwarded_for.split(',')[0] + ip = x_forwarded_for.split(",")[0] else: - ip = request.META.get('REMOTE_ADDR') + ip = request.META.get("REMOTE_ADDR") return ip diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 2da24092a..531ef93ec 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -3,10 +3,10 @@ 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 = [] @@ -21,19 +21,29 @@ def filter_valid_uuids(uuid_list): # Get the 2_weeks, 3_months -def string_date_filter(filter, duration, subsequent, term, date_filter, offset): +def string_date_filter( + filter, duration, subsequent, term, date_filter, offset +): now = timezone.now().date() if term == "months": if subsequent == "after": if offset == "fromnow": - filter[f"{date_filter}__gte"] = now + timedelta(days=duration * 30) + filter[f"{date_filter}__gte"] = now + timedelta( + days=duration * 30 + ) else: - filter[f"{date_filter}__gte"] = now - timedelta(days=duration * 30) + filter[f"{date_filter}__gte"] = now - timedelta( + days=duration * 30 + ) else: if offset == "fromnow": - filter[f"{date_filter}__lte"] = now + timedelta(days=duration * 30) + filter[f"{date_filter}__lte"] = now + timedelta( + days=duration * 30 + ) else: - filter[f"{date_filter}__lte"] = now - timedelta(days=duration * 30) + filter[f"{date_filter}__lte"] = now - timedelta( + days=duration * 30 + ) if term == "weeks": if subsequent == "after": if offset == "fromnow": @@ -42,14 +52,14 @@ def string_date_filter(filter, duration, subsequent, term, date_filter, offset): filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration) else: if offset == "fromnow": - filter[f"{date_filter}__lte"] = now + timedelta(days=duration) + filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration) else: - filter[f"{date_filter}__lte"] = now - timedelta(days=duration) + filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration) def date_filter(filter, date_term, queries): """ - Handle all date filters + Handle all date filters """ for query in queries: date_query = query.split(";") @@ -73,169 +83,270 @@ def date_filter(filter, date_term, queries): filter[f"{date_term}__lte"] = date_query[0] -def filter_state(params, filter, method): +def filter_state(params, filter, method, prefix=""): if method == "GET": - states = [item for item in params.get("state").split(",") if item != 'null'] + states = [ + item for item in params.get("state").split(",") if item != "null" + ] states = filter_valid_uuids(states) if len(states) and "" not in states: - filter["state__in"] = states + filter[f"{prefix}state__in"] = states else: - if params.get("state", None) and len(params.get("state")) and params.get("state") != 'null': - filter["state__in"] = params.get("state") + if ( + params.get("state", None) + and len(params.get("state")) + and params.get("state") != "null" + ): + filter[f"{prefix}state__in"] = params.get("state") return filter -def filter_state_group(params, filter, method): +def filter_state_group(params, filter, method, prefix=""): if method == "GET": - state_group = [item for item in params.get("state_group").split(",") if item != 'null'] + state_group = [ + item + for item in params.get("state_group").split(",") + if item != "null" + ] if len(state_group) and "" not in state_group: - filter["state__group__in"] = state_group + 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': - filter["state__group__in"] = params.get("state_group") + if ( + params.get("state_group", None) + and len(params.get("state_group")) + and params.get("state_group") != "null" + ): + filter[f"{prefix}state__group__in"] = params.get("state_group") return filter -def filter_estimate_point(params, filter, method): +def filter_estimate_point(params, filter, method, prefix=""): if method == "GET": - estimate_points = [item for item in params.get("estimate_point").split(",") if item != 'null'] + estimate_points = [ + item + for item in params.get("estimate_point").split(",") + if item != "null" + ] if len(estimate_points) and "" not in estimate_points: - filter["estimate_point__in"] = estimate_points + 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': - filter["estimate_point__in"] = params.get("estimate_point") + if ( + params.get("estimate_point", None) + and len(params.get("estimate_point")) + and params.get("estimate_point") != "null" + ): + filter[f"{prefix}estimate_point__in"] = params.get( + "estimate_point" + ) return filter -def filter_priority(params, filter, method): +def filter_priority(params, filter, method, prefix=""): if method == "GET": - priorities = [item for item in params.get("priority").split(",") if item != 'null'] + priorities = [ + item + for item in params.get("priority").split(",") + if item != "null" + ] if len(priorities) and "" not in priorities: - filter["priority__in"] = priorities + filter[f"{prefix}priority__in"] = priorities return filter -def filter_parent(params, filter, method): +def filter_parent(params, filter, method, prefix=""): if method == "GET": - parents = [item for item in params.get("parent").split(",") if item != 'null'] + parents = [ + item for item in params.get("parent").split(",") if item != "null" + ] parents = filter_valid_uuids(parents) if len(parents) and "" not in parents: - filter["parent__in"] = parents + filter[f"{prefix}parent__in"] = parents else: - if params.get("parent", None) and len(params.get("parent")) and params.get("parent") != 'null': - filter["parent__in"] = params.get("parent") + if ( + params.get("parent", None) + and len(params.get("parent")) + and params.get("parent") != "null" + ): + filter[f"{prefix}parent__in"] = params.get("parent") return filter -def filter_labels(params, filter, method): +def filter_labels(params, filter, method, prefix=""): if method == "GET": - labels = [item for item in params.get("labels").split(",") if item != 'null'] + labels = [ + item for item in params.get("labels").split(",") if item != "null" + ] labels = filter_valid_uuids(labels) if len(labels) and "" not in labels: - filter["labels__in"] = labels + filter[f"{prefix}labels__in"] = labels else: - if params.get("labels", None) and len(params.get("labels")) and params.get("labels") != 'null': - filter["labels__in"] = params.get("labels") + if ( + params.get("labels", None) + and len(params.get("labels")) + and params.get("labels") != "null" + ): + filter[f"{prefix}labels__in"] = params.get("labels") return filter -def filter_assignees(params, filter, method): +def filter_assignees(params, filter, method, prefix=""): if method == "GET": - assignees = [item for item in params.get("assignees").split(",") if item != 'null'] + assignees = [ + item + for item in params.get("assignees").split(",") + if item != "null" + ] assignees = filter_valid_uuids(assignees) if len(assignees) and "" not in assignees: - filter["assignees__in"] = assignees + filter[f"{prefix}assignees__in"] = assignees else: - if params.get("assignees", None) and len(params.get("assignees")) and params.get("assignees") != 'null': - filter["assignees__in"] = params.get("assignees") + if ( + params.get("assignees", None) + and len(params.get("assignees")) + and params.get("assignees") != "null" + ): + filter[f"{prefix}assignees__in"] = params.get("assignees") return filter -def filter_mentions(params, filter, method): + +def filter_mentions(params, filter, method, prefix=""): if method == "GET": - mentions = [item for item in params.get("mentions").split(",") if item != 'null'] + mentions = [ + item + for item in params.get("mentions").split(",") + if item != "null" + ] mentions = filter_valid_uuids(mentions) if len(mentions) and "" not in mentions: - filter["issue_mention__mention__id__in"] = mentions + 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': - filter["issue_mention__mention__id__in"] = params.get("mentions") + if ( + params.get("mentions", None) + and len(params.get("mentions")) + and params.get("mentions") != "null" + ): + filter[f"{prefix}issue_mention__mention__id__in"] = params.get( + "mentions" + ) return filter -def filter_created_by(params, filter, method): +def filter_created_by(params, filter, method, prefix=""): if method == "GET": - created_bys = [item for item in params.get("created_by").split(",") if item != 'null'] + created_bys = [ + item + for item in params.get("created_by").split(",") + if item != "null" + ] created_bys = filter_valid_uuids(created_bys) if len(created_bys) and "" not in created_bys: - filter["created_by__in"] = created_bys + 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': - filter["created_by__in"] = params.get("created_by") + if ( + params.get("created_by", None) + and len(params.get("created_by")) + and params.get("created_by") != "null" + ): + filter[f"{prefix}created_by__in"] = params.get("created_by") return filter -def filter_name(params, filter, method): +def filter_name(params, filter, method, prefix=""): if params.get("name", "") != "": - filter["name__icontains"] = params.get("name") + filter[f"{prefix}name__icontains"] = params.get("name") return filter -def filter_created_at(params, filter, method): +def filter_created_at(params, filter, method, prefix=""): if method == "GET": created_ats = params.get("created_at").split(",") if len(created_ats) and "" not in created_ats: - date_filter(filter=filter, date_term="created_at__date", queries=created_ats) + date_filter( + filter=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(filter=filter, date_term="created_at__date", queries=params.get("created_at", [])) + date_filter( + filter=filter, + date_term=f"{prefix}created_at__date", + queries=params.get("created_at", []), + ) return filter -def filter_updated_at(params, filter, method): +def filter_updated_at(params, filter, method, prefix=""): if method == "GET": updated_ats = params.get("updated_at").split(",") if len(updated_ats) and "" not in updated_ats: - date_filter(filter=filter, date_term="created_at__date", queries=updated_ats) + date_filter( + filter=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(filter=filter, date_term="created_at__date", queries=params.get("updated_at", [])) + date_filter( + filter=filter, + date_term=f"{prefix}created_at__date", + queries=params.get("updated_at", []), + ) return filter -def filter_start_date(params, filter, method): +def filter_start_date(params, filter, method, prefix=""): if method == "GET": start_dates = params.get("start_date").split(",") if len(start_dates) and "" not in start_dates: - date_filter(filter=filter, date_term="start_date", queries=start_dates) + date_filter( + filter=filter, + date_term=f"{prefix}start_date", + queries=start_dates, + ) else: if params.get("start_date", None) and len(params.get("start_date")): - filter["start_date"] = params.get("start_date") + filter[f"{prefix}start_date"] = params.get("start_date") return filter -def filter_target_date(params, filter, method): +def filter_target_date(params, filter, method, prefix=""): if method == "GET": target_dates = params.get("target_date").split(",") if len(target_dates) and "" not in target_dates: - date_filter(filter=filter, date_term="target_date", queries=target_dates) + date_filter( + filter=filter, + date_term=f"{prefix}target_date", + queries=target_dates, + ) else: if params.get("target_date", None) and len(params.get("target_date")): - filter["target_date"] = params.get("target_date") + filter[f"{prefix}target_date"] = params.get("target_date") return filter -def filter_completed_at(params, filter, method): +def filter_completed_at(params, filter, method, prefix=""): if method == "GET": completed_ats = params.get("completed_at").split(",") if len(completed_ats) and "" not in completed_ats: - date_filter(filter=filter, date_term="completed_at__date", queries=completed_ats) + date_filter( + filter=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(filter=filter, date_term="completed_at__date", queries=params.get("completed_at", [])) + if params.get("completed_at", None) and len( + params.get("completed_at") + ): + date_filter( + filter=filter, + date_term=f"{prefix}completed_at__date", + queries=params.get("completed_at", []), + ) return filter -def filter_issue_state_type(params, filter, method): +def filter_issue_state_type(params, filter, method, prefix=""): type = params.get("type", "all") group = ["backlog", "unstarted", "started", "completed", "cancelled"] if type == "backlog": @@ -243,90 +354,132 @@ def filter_issue_state_type(params, filter, method): if type == "active": group = ["unstarted", "started"] - filter["state__group__in"] = group + filter[f"{prefix}state__group__in"] = group return filter -def filter_project(params, filter, method): +def filter_project(params, filter, method, prefix=""): if method == "GET": - projects = [item for item in params.get("project").split(",") if item != 'null'] + projects = [ + item for item in params.get("project").split(",") if item != "null" + ] projects = filter_valid_uuids(projects) if len(projects) and "" not in projects: - filter["project__in"] = projects + filter[f"{prefix}project__in"] = projects else: - if params.get("project", None) and len(params.get("project")) and params.get("project") != 'null': - filter["project__in"] = params.get("project") + if ( + params.get("project", None) + and len(params.get("project")) + and params.get("project") != "null" + ): + filter[f"{prefix}project__in"] = params.get("project") return filter -def filter_cycle(params, filter, method): +def filter_cycle(params, filter, method, prefix=""): if method == "GET": - cycles = [item for item in params.get("cycle").split(",") if item != 'null'] + cycles = [ + item for item in params.get("cycle").split(",") if item != "null" + ] cycles = filter_valid_uuids(cycles) if len(cycles) and "" not in cycles: - filter["issue_cycle__cycle_id__in"] = cycles + 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': - filter["issue_cycle__cycle_id__in"] = params.get("cycle") + if ( + params.get("cycle", None) + and len(params.get("cycle")) + and params.get("cycle") != "null" + ): + filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle") return filter -def filter_module(params, filter, method): +def filter_module(params, filter, method, prefix=""): if method == "GET": - modules = [item for item in params.get("module").split(",") if item != 'null'] + modules = [ + item for item in params.get("module").split(",") if item != "null" + ] modules = filter_valid_uuids(modules) if len(modules) and "" not in modules: - filter["issue_module__module_id__in"] = modules + 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': - filter["issue_module__module_id__in"] = params.get("module") + if ( + params.get("module", None) + and len(params.get("module")) + and params.get("module") != "null" + ): + filter[f"{prefix}issue_module__module_id__in"] = params.get( + "module" + ) return filter -def filter_inbox_status(params, filter, method): +def filter_inbox_status(params, filter, method, prefix=""): if method == "GET": - status = [item for item in params.get("inbox_status").split(",") if item != 'null'] + status = [ + item + for item in params.get("inbox_status").split(",") + if item != "null" + ] if len(status) and "" not in status: - filter["issue_inbox__status__in"] = status + filter[f"{prefix}issue_inbox__status__in"] = status else: - if params.get("inbox_status", None) and len(params.get("inbox_status")) and params.get("inbox_status") != 'null': - filter["issue_inbox__status__in"] = params.get("inbox_status") + if ( + params.get("inbox_status", None) + and len(params.get("inbox_status")) + and params.get("inbox_status") != "null" + ): + filter[f"{prefix}issue_inbox__status__in"] = params.get( + "inbox_status" + ) return filter -def filter_sub_issue_toggle(params, filter, method): +def filter_sub_issue_toggle(params, filter, method, prefix=""): if method == "GET": sub_issue = params.get("sub_issue", "false") if sub_issue == "false": - filter["parent__isnull"] = True + filter[f"{prefix}parent__isnull"] = True else: sub_issue = params.get("sub_issue", "false") if sub_issue == "false": - filter["parent__isnull"] = True + filter[f"{prefix}parent__isnull"] = True return filter -def filter_subscribed_issues(params, filter, method): +def filter_subscribed_issues(params, filter, method, prefix=""): if method == "GET": - subscribers = [item for item in params.get("subscriber").split(",") if item != 'null'] + subscribers = [ + item + for item in params.get("subscriber").split(",") + if item != "null" + ] subscribers = filter_valid_uuids(subscribers) if len(subscribers) and "" not in subscribers: - filter["issue_subscribers__subscriber_id__in"] = subscribers + 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': - filter["issue_subscribers__subscriber_id__in"] = params.get("subscriber") + if ( + params.get("subscriber", None) + and len(params.get("subscriber")) + and params.get("subscriber") != "null" + ): + filter[f"{prefix}issue_subscribers__subscriber_id__in"] = ( + params.get("subscriber") + ) return filter -def filter_start_target_date_issues(params, filter, method): +def filter_start_target_date_issues(params, filter, method, prefix=""): start_target_date = params.get("start_target_date", "false") if start_target_date == "true": - filter["target_date__isnull"] = False - filter["start_date__isnull"] = False + filter[f"{prefix}target_date__isnull"] = False + filter[f"{prefix}start_date__isnull"] = False return filter -def issue_filters(query_params, method): +def issue_filters(query_params, method, prefix=""): filter = {} ISSUE_FILTER = { @@ -358,6 +511,5 @@ def issue_filters(query_params, method): for key, value in ISSUE_FILTER.items(): if key in query_params: func = value - func(query_params, filter, method) - + func(query_params, filter, method, prefix) return filter diff --git a/apiserver/plane/utils/issue_search.py b/apiserver/plane/utils/issue_search.py index 40f85dde4..74d1e8019 100644 --- a/apiserver/plane/utils/issue_search.py +++ b/apiserver/plane/utils/issue_search.py @@ -5,15 +5,14 @@ import re from django.db.models import Q # Module imports -from plane.db.models import Issue def search_issues(query, queryset): - fields = ["name", "sequence_id"] + fields = ["name", "sequence_id", "project__identifier"] q = Q() for field in fields: - if field == "sequence_id": - sequences = re.findall(r"\d+\.\d+|\d+", query) + 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: diff --git a/apiserver/plane/utils/logging.py b/apiserver/plane/utils/logging.py new file mode 100644 index 000000000..8021689e9 --- /dev/null +++ b/apiserver/plane/utils/logging.py @@ -0,0 +1,46 @@ +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/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index 793614cc0..8cc853370 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -31,8 +31,10 @@ class Cursor: try: bits = value.split(":") if len(bits) != 3: - raise ValueError("Cursor must be in the format 'value:offset:is_prev'") - + raise ValueError( + "Cursor must be in the format 'value:offset:is_prev'" + ) + value = float(bits[0]) if "." in bits[0] else int(bits[0]) return cls(value, int(bits[1]), bool(int(bits[2]))) except (TypeError, ValueError) as e: @@ -132,7 +134,7 @@ class OffsetPaginator: results=results, next=next_cursor, prev=prev_cursor, - hits=None, + hits=count, max_hits=max_hits, ) @@ -178,7 +180,9 @@ class BasePaginator: input_cursor = None if request.GET.get(self.cursor_name): try: - input_cursor = cursor_cls.from_string(request.GET.get(self.cursor_name)) + input_cursor = cursor_cls.from_string( + request.GET.get(self.cursor_name) + ) except ValueError: raise ParseError(detail="Invalid cursor parameter.") @@ -186,9 +190,11 @@ class BasePaginator: paginator = paginator_cls(**paginator_kwargs) try: - cursor_result = paginator.get_result(limit=per_page, cursor=input_cursor) - except BadPaginationError as e: - raise ParseError(detail=str(e)) + cursor_result = paginator.get_result( + limit=per_page, cursor=input_cursor + ) + except BadPaginationError: + raise ParseError(detail="Error in parsing") # Serialize result according to the on_result function if on_results: @@ -211,6 +217,7 @@ class BasePaginator: "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, } diff --git a/apiserver/plane/utils/user_timezone_converter.py b/apiserver/plane/utils/user_timezone_converter.py new file mode 100644 index 000000000..c946cfb27 --- /dev/null +++ b/apiserver/plane/utils/user_timezone_converter.py @@ -0,0 +1,25 @@ +import pytz + +def user_timezone_converter(queryset, datetime_fields, user_timezone): + # Create a timezone object for the user's timezone + user_tz = pytz.timezone(user_timezone) + + # 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 diff --git a/apiserver/plane/web/apps.py b/apiserver/plane/web/apps.py index 76ca3c4e6..a5861f9b5 100644 --- a/apiserver/plane/web/apps.py +++ b/apiserver/plane/web/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class WebConfig(AppConfig): - name = 'plane.web' + name = "plane.web" diff --git a/apiserver/plane/web/urls.py b/apiserver/plane/web/urls.py index 568b99037..24a3e7b57 100644 --- a/apiserver/plane/web/urls.py +++ b/apiserver/plane/web/urls.py @@ -2,6 +2,5 @@ from django.urls import path from django.views.generic import TemplateView urlpatterns = [ - path('about/', TemplateView.as_view(template_name='about.html')) - + path("about/", TemplateView.as_view(template_name="about.html")) ] diff --git a/apiserver/plane/web/views.py b/apiserver/plane/web/views.py index 91ea44a21..60f00ef0e 100644 --- a/apiserver/plane/web/views.py +++ b/apiserver/plane/web/views.py @@ -1,3 +1 @@ -from django.shortcuts import render - # Create your views here. diff --git a/apiserver/plane/wsgi.py b/apiserver/plane/wsgi.py index ef3ea2780..b3051f9ff 100644 --- a/apiserver/plane/wsgi.py +++ b/apiserver/plane/wsgi.py @@ -9,7 +9,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', - 'plane.settings.production') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") application = get_wsgi_application() diff --git a/apiserver/pyproject.toml b/apiserver/pyproject.toml new file mode 100644 index 000000000..a6c07b855 --- /dev/null +++ b/apiserver/pyproject.toml @@ -0,0 +1,25 @@ +[tool.black] +line-length = 79 +target-version = ['py36'] +include = '\.pyi?$' +exclude = ''' + /( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + | venv + )/ +''' + +[tool.ruff] +line-length = 79 +exclude = [ + "**/__init__.py", +] + diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 0e7a18fa8..a6bd2ab50 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,36 +1,63 @@ # base requirements -Django==4.2.7 -psycopg==3.1.12 -djangorestframework==3.14.0 -redis==4.6.0 -django-cors-headers==4.2.0 -whitenoise==6.5.0 -django-allauth==0.55.2 -faker==18.11.2 -django-filter==23.2 -jsonmodels==2.6.0 -djangorestframework-simplejwt==5.3.0 -sentry-sdk==1.30.0 -django-storages==1.14 -django-crum==0.7.9 -google-auth==2.22.0 -google-api-python-client==2.97.0 -django-redis==5.3.0 -uvicorn==0.23.2 -channels==4.0.0 -openai==1.2.4 -slack-sdk==3.21.3 -celery==5.3.4 -django_celery_beat==2.5.0 -psycopg-binary==3.1.12 -psycopg-c==3.1.12 -scout-apm==2.26.1 -openpyxl==3.1.2 -beautifulsoup4==4.12.2 +# django +Django==4.2.11 +# rest framework +djangorestframework==3.15.1 +# postgres +psycopg==3.1.18 +psycopg-binary==3.1.18 +psycopg-c==3.1.18 dj-database-url==2.1.0 -posthog==3.0.2 -cryptography==41.0.6 -lxml==4.9.3 -boto3==1.28.40 - +# 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 +# file serve +whitenoise==6.6.0 +# fake data +faker==25.0.0 +# filters +django-filter==24.2 +# json model +jsonmodels==2.7.0 +# sentry +sentry-sdk==2.0.1 +# 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.25.0 +# slack +slack-sdk==3.27.1 +# apm +scout-apm==3.1.0 +# xlsx generation +openpyxl==3.1.2 +# logging +python-json-logger==2.0.7 +# html parser +beautifulsoup4==4.12.3 +# analytics +posthog==3.5.0 +# crypto +cryptography==42.0.5 +# html validator +lxml==5.2.1 +# s3 +boto3==1.34.96 +# password validator +zxcvbn==4.4.28 +# timezone +pytz==2024.1 +# jwt +PyJWT==2.8.0 \ No newline at end of file diff --git a/apiserver/requirements/local.txt b/apiserver/requirements/local.txt index 426236ed8..02792201b 100644 --- a/apiserver/requirements/local.txt +++ b/apiserver/requirements/local.txt @@ -1,3 +1,5 @@ -r base.txt - -django-debug-toolbar==4.1.0 \ No newline at end of file +# debug toolbar +django-debug-toolbar==4.3.0 +# formatter +ruff==0.4.2 \ No newline at end of file diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt index a0e9f8a17..ed763c0df 100644 --- a/apiserver/requirements/production.txt +++ b/apiserver/requirements/production.txt @@ -1,3 +1,3 @@ -r base.txt - -gunicorn==21.2.0 +# server +gunicorn==22.0.0 diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt index d3272191e..1ffc82d00 100644 --- a/apiserver/requirements/test.txt +++ b/apiserver/requirements/test.txt @@ -1,4 +1,4 @@ -r base.txt - +# test checker pytest==7.1.2 coverage==6.5.0 \ No newline at end of file diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt index dfe813b86..8cf46af5f 100644 --- a/apiserver/runtime.txt +++ b/apiserver/runtime.txt @@ -1 +1 @@ -python-3.11.6 \ No newline at end of file +python-3.12.3 \ No newline at end of file diff --git a/apiserver/templates/csrf_failure.html b/apiserver/templates/csrf_failure.html new file mode 100644 index 000000000..b5a58cb02 --- /dev/null +++ b/apiserver/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/apiserver/templates/emails/auth/forgot_password.html b/apiserver/templates/emails/auth/forgot_password.html index a58a8cef7..9df90724f 100644 --- a/apiserver/templates/emails/auth/forgot_password.html +++ b/apiserver/templates/emails/auth/forgot_password.html @@ -1,492 +1,47 @@ - - + + Set a new password to your Plane account - - - - - - + + + + + + - - + +