mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
commit
002fb4547b
@ -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"
|
|
84
.github/workflows/auto-merge.yml
vendored
Normal file
84
.github/workflows/auto-merge.yml
vendored
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
name: Auto Merge or Create PR on Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "sync/**"
|
||||||
|
|
||||||
|
env:
|
||||||
|
CURRENT_BRANCH: ${{ github.ref_name }}
|
||||||
|
SOURCE_BRANCH: ${{ secrets.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
|
||||||
|
TARGET_BRANCH: ${{ secrets.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: ${{ secrets.SYNC_PR_REVIEWER }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
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: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4.1.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Fetch all history for all branches and tags
|
||||||
|
|
||||||
|
- name: Setup Git
|
||||||
|
run: |
|
||||||
|
git config user.name "GitHub Actions"
|
||||||
|
git config user.email "actions@github.com"
|
||||||
|
|
||||||
|
- name: Setup GH CLI and Git Config
|
||||||
|
run: |
|
||||||
|
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||||
|
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||||
|
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install gh -y
|
||||||
|
|
||||||
|
- name: Check for merge conflicts
|
||||||
|
id: conflicts
|
||||||
|
run: |
|
||||||
|
git fetch origin $TARGET_BRANCH
|
||||||
|
git checkout $TARGET_BRANCH
|
||||||
|
# Attempt to merge the main branch into the current branch
|
||||||
|
if $(git merge --no-commit --no-ff $SOURCE_BRANCH); then
|
||||||
|
echo "No merge conflicts detected."
|
||||||
|
echo "HAS_CONFLICTS=false" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "Merge conflicts detected."
|
||||||
|
echo "HAS_CONFLICTS=true" >> $GITHUB_ENV
|
||||||
|
git merge --abort
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Merge Change to Target Branch
|
||||||
|
if: env.HAS_CONFLICTS == 'false'
|
||||||
|
run: |
|
||||||
|
git commit -m "Merge branch '$SOURCE_BRANCH' into $TARGET_BRANCH"
|
||||||
|
git push origin $TARGET_BRANCH
|
||||||
|
|
||||||
|
- name: Create PR to Target Branch
|
||||||
|
if: env.HAS_CONFLICTS == 'true'
|
||||||
|
run: |
|
||||||
|
# Replace 'username' with the actual GitHub username of the reviewer.
|
||||||
|
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "" --reviewer $REVIEWER)
|
||||||
|
echo "Pull Request created: $PR_URL"
|
12
.github/workflows/build-branch.yml
vendored
12
.github/workflows/build-branch.yml
vendored
@ -6,7 +6,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- preview
|
- preview
|
||||||
- develop
|
|
||||||
release:
|
release:
|
||||||
types: [released, prereleased]
|
types: [released, prereleased]
|
||||||
|
|
||||||
@ -18,7 +17,7 @@ jobs:
|
|||||||
name: Build-Push Web/Space/API/Proxy Docker Image
|
name: Build-Push Web/Space/API/Proxy Docker Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
|
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
|
||||||
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
|
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
|
||||||
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
|
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
|
||||||
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
||||||
@ -74,7 +73,7 @@ jobs:
|
|||||||
- nginx/**
|
- nginx/**
|
||||||
|
|
||||||
branch_build_push_frontend:
|
branch_build_push_frontend:
|
||||||
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -126,7 +125,7 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
branch_build_push_space:
|
branch_build_push_space:
|
||||||
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -178,7 +177,7 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
branch_build_push_backend:
|
branch_build_push_backend:
|
||||||
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -230,7 +229,7 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
branch_build_push_proxy:
|
branch_build_push_proxy:
|
||||||
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -280,4 +279,3 @@ jobs:
|
|||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
111
.github/workflows/build-test-pull-request.yml
vendored
111
.github/workflows/build-test-pull-request.yml
vendored
@ -1,27 +1,19 @@
|
|||||||
name: Build Pull Request Contents
|
name: Build and Lint on Pull Request
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: ["opened", "synchronize"]
|
types: ["opened", "synchronize"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-pull-request-contents:
|
get-changed-files:
|
||||||
name: Build Pull Request Contents
|
runs-on: ubuntu-latest
|
||||||
runs-on: ubuntu-20.04
|
outputs:
|
||||||
permissions:
|
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
|
||||||
pull-requests: read
|
web_changed: ${{ steps.changed-files.outputs.web_any_changed }}
|
||||||
|
space_changed: ${{ steps.changed-files.outputs.deploy_any_changed }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository to Actions
|
- uses: actions/checkout@v3
|
||||||
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
|
|
||||||
|
|
||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@v41
|
uses: tj-actions/changed-files@v41
|
||||||
@ -31,17 +23,82 @@ jobs:
|
|||||||
- apiserver/**
|
- apiserver/**
|
||||||
web:
|
web:
|
||||||
- web/**
|
- web/**
|
||||||
|
- packages/**
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'turbo.json'
|
||||||
deploy:
|
deploy:
|
||||||
- space/**
|
- space/**
|
||||||
|
- packages/**
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'turbo.json'
|
||||||
|
|
||||||
- name: Build Plane's Main App
|
lint-apiserver:
|
||||||
if: steps.changed-files.outputs.web_any_changed == 'true'
|
needs: get-changed-files
|
||||||
run: |
|
runs-on: ubuntu-latest
|
||||||
yarn
|
if: needs.get-changed-files.outputs.apiserver_changed == 'true'
|
||||||
yarn build --filter=web
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
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
|
lint-web:
|
||||||
if: steps.changed-files.outputs.deploy_any_changed == 'true'
|
needs: get-changed-files
|
||||||
run: |
|
if: needs.get-changed-files.outputs.web_changed == 'true'
|
||||||
yarn
|
runs-on: ubuntu-latest
|
||||||
yarn build --filter=space
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn lint --filter=web
|
||||||
|
|
||||||
|
lint-space:
|
||||||
|
needs: get-changed-files
|
||||||
|
if: needs.get-changed-files.outputs.space_changed == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn lint --filter=space
|
||||||
|
|
||||||
|
build-web:
|
||||||
|
needs: lint-web
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn build --filter=web
|
||||||
|
|
||||||
|
build-space:
|
||||||
|
needs: lint-space
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn build --filter=space
|
||||||
|
45
.github/workflows/check-version.yml
vendored
Normal file
45
.github/workflows/check-version.yml
vendored
Normal file
@ -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 }}
|
65
.github/workflows/codeql.yml
vendored
65
.github/workflows/codeql.yml
vendored
@ -1,13 +1,13 @@
|
|||||||
name: "CodeQL"
|
name: "CodeQL"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [ 'develop', 'preview', 'master' ]
|
branches: ["develop", "preview", "master"]
|
||||||
pull_request:
|
pull_request:
|
||||||
# The branches below must be a subset of the branches above
|
branches: ["develop", "preview", "master"]
|
||||||
branches: [ 'develop', 'preview', 'master' ]
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '53 19 * * 5'
|
- cron: "53 19 * * 5"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
@ -21,45 +21,44 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
language: [ 'python', 'javascript' ]
|
language: ["python", "javascript"]
|
||||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
# Use only 'java' to analyze code written in Java, Kotlin or both
|
# Use only 'java' to analyze code written in Java, Kotlin or both
|
||||||
# Use only 'javascript' to analyze code written in JavaScript, TypeScript 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
|
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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.
|
# 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.
|
# 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
|
# 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
|
# 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).
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
- name: Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@v2
|
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 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.
|
# - run: |
|
||||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
# echo "Run, Build Application using script"
|
||||||
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
# - run: |
|
- name: Perform CodeQL Analysis
|
||||||
# echo "Run, Build Application using script"
|
uses: github/codeql-action/analyze@v2
|
||||||
# ./location_of_script_within_repo/buildscript.sh
|
with:
|
||||||
|
category: "/language:${{matrix.language}}"
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@v2
|
|
||||||
with:
|
|
||||||
category: "/language:${{matrix.language}}"
|
|
||||||
|
199
.github/workflows/feature-deployment.yml
vendored
Normal file
199
.github/workflows/feature-deployment.yml
vendored
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
env:
|
||||||
|
BUILD_WEB: ${{ github.event.inputs.web-build }}
|
||||||
|
BUILD_SPACE: ${{ github.event.inputs.space-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"
|
||||||
|
outputs:
|
||||||
|
web-build: ${{ env.BUILD_WEB}}
|
||||||
|
space-build: ${{env.BUILD_SPACE}}
|
||||||
|
|
||||||
|
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_DEPLOY_WITH_NGINX: 1
|
||||||
|
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-deploy:
|
||||||
|
if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }}
|
||||||
|
needs: [feature-build-web, feature-build-space]
|
||||||
|
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
|
||||||
|
|
||||||
|
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == 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 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
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -51,6 +51,7 @@ staticfiles
|
|||||||
mediafiles
|
mediafiles
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
logs/
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
assets/dist/
|
assets/dist/
|
||||||
|
@ -50,7 +50,6 @@ chmod +x setup.sh
|
|||||||
docker compose -f docker-compose-local.yml up
|
docker compose -f docker-compose-local.yml up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Missing a Feature?
|
## Missing a Feature?
|
||||||
|
|
||||||
If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository.
|
If 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.
|
||||||
|
@ -53,7 +53,6 @@ NGINX_PORT=80
|
|||||||
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
|
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## {PROJECT_FOLDER}/apiserver/.env
|
## {PROJECT_FOLDER}/apiserver/.env
|
||||||
|
|
||||||
|
|
||||||
|
58
README.md
58
README.md
@ -17,10 +17,10 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="http://www.plane.so"><b>Website</b></a> •
|
<a href="https://dub.sh/plane-website-readme"><b>Website</b></a> •
|
||||||
<a href="https://github.com/makeplane/plane/releases"><b>Releases</b></a> •
|
<a href="https://git.new/releases"><b>Releases</b></a> •
|
||||||
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
|
<a href="https://dub.sh/planepowershq"><b>Twitter</b></a> •
|
||||||
<a href="https://docs.plane.so/"><b>Documentation</b></a>
|
<a href="https://dub.sh/planedocs"><b>Documentation</b></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@ -40,30 +40,28 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
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 software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘♀️
|
||||||
|
|
||||||
> 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 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 in our upcoming releases.
|
||||||
|
|
||||||
## ⚡ Installation
|
## ⚡ Installation
|
||||||
|
|
||||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users.
|
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users.
|
||||||
|
|
||||||
If you want more control over your data prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
|
If you want more control over your data, prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
|
||||||
|
|
||||||
| Installation Methods | Documentation Link |
|
| Installation Methods | Documentation Link |
|
||||||
|-----------------|----------------------------------------------------------------------------------------------------------|
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.plane.so/docker-compose) |
|
| 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) |
|
| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://docs.plane.so/kubernetes) |
|
||||||
|
|
||||||
`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature.
|
`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature.
|
||||||
|
|
||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
- **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.
|
- **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**
|
- **Cycles**:
|
||||||
Keep up your team's momentum with Cycles. Gain insights into your project's progress with burn-down charts and other valuable features.
|
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.
|
- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily.
|
||||||
@ -74,15 +72,13 @@ If you want more control over your data prefer to self-host Plane, please refer
|
|||||||
|
|
||||||
- **Analytics**: Get insights into all your Plane data in real-time. Visualize issue data to spot trends, remove blockers, and progress your work.
|
- **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.
|
- **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
|
||||||
|
|
||||||
## 🛠️ Contributors Quick Start
|
|
||||||
|
|
||||||
> Development system must have docker engine installed and running.
|
> 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
|
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:
|
1. Clone the code locally using:
|
||||||
```
|
```
|
||||||
@ -101,10 +97,10 @@ Setting up local environment is extremely easy and straight forward. Follow the
|
|||||||
./setup.sh
|
./setup.sh
|
||||||
```
|
```
|
||||||
5. Open the code on VSCode or similar equivalent IDE.
|
5. Open the code on VSCode or similar equivalent IDE.
|
||||||
6. Review the `.env` files available in various folders.
|
6. Review the `.env` files available in various folders.
|
||||||
Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system.
|
Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system.
|
||||||
7. Run the docker command to initiate services:
|
7. Run the docker command to initiate services:
|
||||||
```
|
```
|
||||||
docker compose -f docker-compose-local.yml up -d
|
docker compose -f docker-compose-local.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -119,6 +115,7 @@ The Plane community can be found on [GitHub Discussions](https://github.com/orgs
|
|||||||
Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects.
|
Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects.
|
||||||
|
|
||||||
### Repo Activity
|
### Repo Activity
|
||||||
|
|
||||||
![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image")
|
![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image")
|
||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
@ -181,20 +178,21 @@ Ask questions, report bugs, join discussions, voice ideas, make feature requests
|
|||||||
|
|
||||||
## ⛓️ Security
|
## ⛓️ 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.
|
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.
|
Email squawk@plane.so to disclose any security vulnerabilities.
|
||||||
|
|
||||||
## ❤️ Contribute
|
## ❤️ Contribute
|
||||||
|
|
||||||
There are many ways to contribute to Plane, including:
|
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.
|
- 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.
|
||||||
- Speaking or writing about Plane or any other ecosystem integration and [letting us know](https://discord.com/invite/A92xrEGCge)!
|
- Reviewing [the documentation](https://docs.plane.so/) and submitting [pull requests](https://github.com/makeplane/plane), from fixing typos to adding new features.
|
||||||
- Upvoting [popular feature requests](https://github.com/makeplane/plane/issues) to show your support.
|
- 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.
|
### We couldn't have done this without you.
|
||||||
|
|
||||||
<a href="https://github.com/makeplane/plane/graphs/contributors">
|
<a href="https://github.com/makeplane/plane/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=makeplane/plane" />
|
<img src="https://contrib.rocks/image?repo=makeplane/plane" />
|
||||||
</a>
|
</a>
|
||||||
|
@ -14,10 +14,6 @@ POSTGRES_HOST="plane-db"
|
|||||||
POSTGRES_DB="plane"
|
POSTGRES_DB="plane"
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
|
||||||
|
|
||||||
# Oauth variables
|
|
||||||
GOOGLE_CLIENT_ID=""
|
|
||||||
GITHUB_CLIENT_ID=""
|
|
||||||
GITHUB_CLIENT_SECRET=""
|
|
||||||
|
|
||||||
# Redis Settings
|
# Redis Settings
|
||||||
REDIS_HOST="plane-redis"
|
REDIS_HOST="plane-redis"
|
||||||
@ -34,11 +30,6 @@ AWS_S3_BUCKET_NAME="uploads"
|
|||||||
# Maximum file upload limit
|
# Maximum file upload limit
|
||||||
FILE_SIZE_LIMIT=5242880
|
FILE_SIZE_LIMIT=5242880
|
||||||
|
|
||||||
# GPT settings
|
|
||||||
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
|
|
||||||
OPENAI_API_KEY="sk-" # deprecated
|
|
||||||
GPT_ENGINE="gpt-3.5-turbo" # deprecated
|
|
||||||
|
|
||||||
# Settings related to Docker
|
# Settings related to Docker
|
||||||
DOCKERIZED=1 # deprecated
|
DOCKERIZED=1 # deprecated
|
||||||
|
|
||||||
@ -48,19 +39,8 @@ USE_MINIO=1
|
|||||||
# Nginx Configuration
|
# Nginx Configuration
|
||||||
NGINX_PORT=80
|
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
|
# Email redirections and minio domain settings
|
||||||
WEB_URL="http://localhost"
|
WEB_URL="http://localhost"
|
||||||
|
|
||||||
# Gunicorn Workers
|
# Gunicorn Workers
|
||||||
GUNICORN_WORKERS=2
|
GUNICORN_WORKERS=2
|
||||||
|
|
||||||
|
@ -48,8 +48,10 @@ USER root
|
|||||||
RUN apk --no-cache add "bash~=5.2"
|
RUN apk --no-cache add "bash~=5.2"
|
||||||
COPY ./bin ./bin/
|
COPY ./bin ./bin/
|
||||||
|
|
||||||
|
RUN mkdir -p /code/plane/logs
|
||||||
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
|
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
|
||||||
RUN chmod -R 777 /code
|
RUN chmod -R 777 /code
|
||||||
|
RUN chown -R captain:plane /code
|
||||||
|
|
||||||
USER captain
|
USER captain
|
||||||
|
|
||||||
|
@ -35,6 +35,7 @@ RUN addgroup -S plane && \
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /code/plane/logs
|
||||||
RUN chown -R captain.plane /code
|
RUN chown -R captain.plane /code
|
||||||
RUN chmod -R +x /code/bin
|
RUN chmod -R +x /code/bin
|
||||||
RUN chmod -R 777 /code
|
RUN chmod -R 777 /code
|
||||||
|
@ -182,7 +182,7 @@ def update_label_color():
|
|||||||
labels = Label.objects.filter(color="")
|
labels = Label.objects.filter(color="")
|
||||||
updated_labels = []
|
updated_labels = []
|
||||||
for label in 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)
|
updated_labels.append(label)
|
||||||
|
|
||||||
Label.objects.bulk_update(updated_labels, ["color"], batch_size=100)
|
Label.objects.bulk_update(updated_labels, ["color"], batch_size=100)
|
||||||
|
@ -21,11 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
|
|||||||
export MACHINE_SIGNATURE=$SIGNATURE
|
export MACHINE_SIGNATURE=$SIGNATURE
|
||||||
|
|
||||||
# Register instance
|
# Register instance
|
||||||
python manage.py register_instance $MACHINE_SIGNATURE
|
python manage.py register_instance "$MACHINE_SIGNATURE"
|
||||||
|
|
||||||
# Load the configuration variable
|
# Load the configuration variable
|
||||||
python manage.py configure_instance
|
python manage.py configure_instance
|
||||||
|
|
||||||
# Create the default bucket
|
# Create the default bucket
|
||||||
python manage.py create_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 -
|
||||||
|
@ -21,12 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
|
|||||||
export MACHINE_SIGNATURE=$SIGNATURE
|
export MACHINE_SIGNATURE=$SIGNATURE
|
||||||
|
|
||||||
# Register instance
|
# Register instance
|
||||||
python manage.py register_instance $MACHINE_SIGNATURE
|
python manage.py register_instance "$MACHINE_SIGNATURE"
|
||||||
# Load the configuration variable
|
# Load the configuration variable
|
||||||
python manage.py configure_instance
|
python manage.py configure_instance
|
||||||
|
|
||||||
# Create the default bucket
|
# Create the default bucket
|
||||||
python manage.py create_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
|
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "plane-api",
|
"name": "plane-api",
|
||||||
"version": "0.16.0"
|
"version": "0.17.0"
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,33 @@
|
|||||||
from lxml import html
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import URLValidator
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from lxml import html
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
User,
|
|
||||||
Issue,
|
Issue,
|
||||||
State,
|
IssueActivity,
|
||||||
IssueAssignee,
|
IssueAssignee,
|
||||||
Label,
|
IssueAttachment,
|
||||||
|
IssueComment,
|
||||||
IssueLabel,
|
IssueLabel,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueComment,
|
Label,
|
||||||
IssueAttachment,
|
|
||||||
IssueActivity,
|
|
||||||
ProjectMember,
|
ProjectMember,
|
||||||
|
State,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from .cycle import CycleSerializer, CycleLiteSerializer
|
from .cycle import CycleLiteSerializer, CycleSerializer
|
||||||
from .module import ModuleSerializer, ModuleLiteSerializer
|
from .module import ModuleLiteSerializer, ModuleSerializer
|
||||||
from .user import UserLiteSerializer
|
|
||||||
from .state import StateLiteSerializer
|
from .state import StateLiteSerializer
|
||||||
|
from .user import UserLiteSerializer
|
||||||
|
|
||||||
|
|
||||||
class IssueSerializer(BaseSerializer):
|
class IssueSerializer(BaseSerializer):
|
||||||
@ -78,7 +80,7 @@ class IssueSerializer(BaseSerializer):
|
|||||||
data["description_html"] = parsed_str
|
data["description_html"] = parsed_str
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
|
raise serializers.ValidationError("Invalid HTML passed")
|
||||||
|
|
||||||
# Validate assignees are from project
|
# Validate assignees are from project
|
||||||
if data.get("assignees", []):
|
if data.get("assignees", []):
|
||||||
@ -284,6 +286,20 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def validate_url(self, value):
|
||||||
|
# Check URL format
|
||||||
|
validate_url = URLValidator()
|
||||||
|
try:
|
||||||
|
validate_url(value)
|
||||||
|
except ValidationError:
|
||||||
|
raise serializers.ValidationError("Invalid URL format.")
|
||||||
|
|
||||||
|
# Check URL scheme
|
||||||
|
if not value.startswith(("http://", "https://")):
|
||||||
|
raise serializers.ValidationError("Invalid URL scheme.")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
# Validation if url already exists
|
# Validation if url already exists
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
if IssueLink.objects.filter(
|
if IssueLink.objects.filter(
|
||||||
@ -295,6 +311,17 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
)
|
)
|
||||||
return IssueLink.objects.create(**validated_data)
|
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,
|
||||||
|
).exists():
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"error": "URL already exists for this Issue"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class IssueAttachmentSerializer(BaseSerializer):
|
class IssueAttachmentSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -340,7 +367,7 @@ class IssueCommentSerializer(BaseSerializer):
|
|||||||
data["comment_html"] = parsed_str
|
data["comment_html"] = parsed_str
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
|
raise serializers.ValidationError("Invalid HTML passed")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,8 +6,6 @@ from plane.db.models import (
|
|||||||
Project,
|
Project,
|
||||||
ProjectIdentifier,
|
ProjectIdentifier,
|
||||||
WorkspaceMember,
|
WorkspaceMember,
|
||||||
State,
|
|
||||||
Estimate,
|
|
||||||
)
|
)
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ from plane.api.views.cycle import (
|
|||||||
CycleAPIEndpoint,
|
CycleAPIEndpoint,
|
||||||
CycleIssueAPIEndpoint,
|
CycleIssueAPIEndpoint,
|
||||||
TransferCycleIssueAPIEndpoint,
|
TransferCycleIssueAPIEndpoint,
|
||||||
|
CycleArchiveUnarchiveAPIEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -32,4 +33,14 @@ urlpatterns = [
|
|||||||
TransferCycleIssueAPIEndpoint.as_view(),
|
TransferCycleIssueAPIEndpoint.as_view(),
|
||||||
name="transfer-issues",
|
name="transfer-issues",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/archive/",
|
||||||
|
CycleArchiveUnarchiveAPIEndpoint.as_view(),
|
||||||
|
name="cycle-archive-unarchive",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/",
|
||||||
|
CycleArchiveUnarchiveAPIEndpoint.as_view(),
|
||||||
|
name="cycle-archive-unarchive",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from plane.api.views import ModuleAPIEndpoint, ModuleIssueAPIEndpoint
|
from plane.api.views import (
|
||||||
|
ModuleAPIEndpoint,
|
||||||
|
ModuleIssueAPIEndpoint,
|
||||||
|
ModuleArchiveUnarchiveAPIEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
@ -23,4 +27,14 @@ urlpatterns = [
|
|||||||
ModuleIssueAPIEndpoint.as_view(),
|
ModuleIssueAPIEndpoint.as_view(),
|
||||||
name="module-issues",
|
name="module-issues",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/archive/",
|
||||||
|
ModuleArchiveUnarchiveAPIEndpoint.as_view(),
|
||||||
|
name="module-archive-unarchive",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/",
|
||||||
|
ModuleArchiveUnarchiveAPIEndpoint.as_view(),
|
||||||
|
name="module-archive-unarchive",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from plane.api.views import ProjectAPIEndpoint
|
from plane.api.views import (
|
||||||
|
ProjectAPIEndpoint,
|
||||||
|
ProjectArchiveUnarchiveAPIEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
@ -13,4 +16,9 @@ urlpatterns = [
|
|||||||
ProjectAPIEndpoint.as_view(),
|
ProjectAPIEndpoint.as_view(),
|
||||||
name="project",
|
name="project",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/archive/",
|
||||||
|
ProjectArchiveUnarchiveAPIEndpoint.as_view(),
|
||||||
|
name="project-archive-unarchive",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from .project import ProjectAPIEndpoint
|
from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint
|
||||||
|
|
||||||
from .state import StateAPIEndpoint
|
from .state import StateAPIEndpoint
|
||||||
|
|
||||||
@ -14,8 +14,13 @@ from .cycle import (
|
|||||||
CycleAPIEndpoint,
|
CycleAPIEndpoint,
|
||||||
CycleIssueAPIEndpoint,
|
CycleIssueAPIEndpoint,
|
||||||
TransferCycleIssueAPIEndpoint,
|
TransferCycleIssueAPIEndpoint,
|
||||||
|
CycleArchiveUnarchiveAPIEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .module import ModuleAPIEndpoint, ModuleIssueAPIEndpoint
|
from .module import (
|
||||||
|
ModuleAPIEndpoint,
|
||||||
|
ModuleIssueAPIEndpoint,
|
||||||
|
ModuleArchiveUnarchiveAPIEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
from .inbox import InboxIssueAPIEndpoint
|
from .inbox import InboxIssueAPIEndpoint
|
||||||
|
@ -1,27 +1,26 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import zoneinfo
|
|
||||||
import json
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import zoneinfo
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import IntegrityError
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
|
from django.db import IntegrityError
|
||||||
from django.utils import timezone
|
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
|
# Third party imports
|
||||||
from rest_framework.views import APIView
|
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
|
# Module imports
|
||||||
from plane.api.middleware.api_authentication import APIKeyAuthentication
|
from plane.api.middleware.api_authentication import APIKeyAuthentication
|
||||||
from plane.api.rate_limit import ApiKeyRateThrottle
|
from plane.api.rate_limit import ApiKeyRateThrottle
|
||||||
from plane.utils.paginator import BasePaginator
|
|
||||||
from plane.bgtasks.webhook_task import send_webhook
|
from plane.bgtasks.webhook_task import send_webhook
|
||||||
|
from plane.utils.exception_logger import log_exception
|
||||||
|
from plane.utils.paginator import BasePaginator
|
||||||
|
|
||||||
|
|
||||||
class TimezoneMixin:
|
class TimezoneMixin:
|
||||||
@ -107,27 +106,23 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
|||||||
|
|
||||||
if isinstance(e, ValidationError):
|
if isinstance(e, ValidationError):
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{"error": "Please provide valid detail"},
|
||||||
"error": "The provided payload is not valid please try with a valid payload"
|
|
||||||
},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(e, ObjectDoesNotExist):
|
if isinstance(e, ObjectDoesNotExist):
|
||||||
return Response(
|
return Response(
|
||||||
{"error": f"The required object does not exist."},
|
{"error": "The requested resource does not exist."},
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(e, KeyError):
|
if isinstance(e, KeyError):
|
||||||
return Response(
|
return Response(
|
||||||
{"error": f" The required key does not exist."},
|
{"error": "The required key does not exist."},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.DEBUG:
|
log_exception(e)
|
||||||
print(e)
|
|
||||||
capture_exception(e)
|
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db.models import Q, Count, Sum, Prefetch, F, OuterRef, Func
|
from django.db.models import Q, Count, Sum, F, OuterRef, Func
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
|
|
||||||
@ -140,7 +140,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
|
|
||||||
def get(self, request, slug, project_id, pk=None):
|
def get(self, request, slug, project_id, pk=None):
|
||||||
if pk:
|
if pk:
|
||||||
queryset = self.get_queryset().get(pk=pk)
|
queryset = (
|
||||||
|
self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
|
||||||
|
)
|
||||||
data = CycleSerializer(
|
data = CycleSerializer(
|
||||||
queryset,
|
queryset,
|
||||||
fields=self.fields,
|
fields=self.fields,
|
||||||
@ -150,7 +152,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
data,
|
data,
|
||||||
status=status.HTTP_200_OK,
|
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")
|
cycle_view = request.GET.get("cycle_view", "all")
|
||||||
|
|
||||||
# Current Cycle
|
# Current Cycle
|
||||||
@ -291,6 +295,11 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
cycle = Cycle.objects.get(
|
cycle = Cycle.objects.get(
|
||||||
workspace__slug=slug, project_id=project_id, pk=pk
|
workspace__slug=slug, project_id=project_id, pk=pk
|
||||||
)
|
)
|
||||||
|
if cycle.archived_at:
|
||||||
|
return Response(
|
||||||
|
{"error": "Archived cycle cannot be edited"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
request_data = request.data
|
request_data = request.data
|
||||||
|
|
||||||
@ -321,7 +330,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
and Cycle.objects.filter(
|
and Cycle.objects.filter(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
external_source=request.data.get("external_source", cycle.external_source),
|
external_source=request.data.get(
|
||||||
|
"external_source", cycle.external_source
|
||||||
|
),
|
||||||
external_id=request.data.get("external_id"),
|
external_id=request.data.get("external_id"),
|
||||||
).exists()
|
).exists()
|
||||||
):
|
):
|
||||||
@ -366,6 +377,139 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
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, pk):
|
||||||
|
cycle = Cycle.objects.get(
|
||||||
|
pk=pk, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
cycle.archived_at = timezone.now()
|
||||||
|
cycle.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
def delete(self, request, slug, project_id, pk):
|
||||||
|
cycle = Cycle.objects.get(
|
||||||
|
pk=pk, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
cycle.archived_at = None
|
||||||
|
cycle.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||||
"""
|
"""
|
||||||
This viewset automatically provides `list`, `create`,
|
This viewset automatically provides `list`, `create`,
|
||||||
|
@ -119,7 +119,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check for valid priority
|
# 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",
|
"low",
|
||||||
"medium",
|
"medium",
|
||||||
"high",
|
"high",
|
||||||
|
@ -1,22 +1,22 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import json
|
import json
|
||||||
from itertools import chain
|
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
OuterRef,
|
|
||||||
Func,
|
|
||||||
Q,
|
|
||||||
F,
|
|
||||||
Case,
|
Case,
|
||||||
When,
|
|
||||||
Value,
|
|
||||||
CharField,
|
CharField,
|
||||||
Max,
|
|
||||||
Exists,
|
Exists,
|
||||||
|
F,
|
||||||
|
Func,
|
||||||
|
Max,
|
||||||
|
OuterRef,
|
||||||
|
Q,
|
||||||
|
Value,
|
||||||
|
When,
|
||||||
)
|
)
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
@ -24,30 +24,31 @@ from rest_framework import status
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
# Module imports
|
# 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 (
|
from plane.api.serializers import (
|
||||||
|
IssueActivitySerializer,
|
||||||
|
IssueCommentSerializer,
|
||||||
|
IssueLinkSerializer,
|
||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
LabelSerializer,
|
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, WebhookMixin
|
||||||
|
|
||||||
|
|
||||||
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||||
@ -356,6 +357,7 @@ class LabelAPIEndpoint(BaseAPIView):
|
|||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
|
.filter(project__archived_at__isnull=True)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("parent")
|
.select_related("parent")
|
||||||
@ -488,6 +490,7 @@ class IssueLinkAPIEndpoint(BaseAPIView):
|
|||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
|
.filter(project__archived_at__isnull=True)
|
||||||
.order_by(self.kwargs.get("order_by", "-created_at"))
|
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
@ -617,6 +620,7 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
|
.filter(project__archived_at__isnull=True)
|
||||||
.select_related("workspace", "project", "issue", "actor")
|
.select_related("workspace", "project", "issue", "actor")
|
||||||
.annotate(
|
.annotate(
|
||||||
is_member=Exists(
|
is_member=Exists(
|
||||||
@ -653,7 +657,6 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def post(self, request, slug, project_id, issue_id):
|
def post(self, request, slug, project_id, issue_id):
|
||||||
|
|
||||||
# Validation check if the issue already exists
|
# Validation check if the issue already exists
|
||||||
if (
|
if (
|
||||||
request.data.get("external_id")
|
request.data.get("external_id")
|
||||||
@ -679,7 +682,6 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
status=status.HTTP_409_CONFLICT,
|
status=status.HTTP_409_CONFLICT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
serializer = IssueCommentSerializer(data=request.data)
|
serializer = IssueCommentSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save(
|
serializer.save(
|
||||||
@ -717,7 +719,10 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
# Validation check if the issue already exists
|
# Validation check if the issue already exists
|
||||||
if (
|
if (
|
||||||
request.data.get("external_id")
|
request.data.get("external_id")
|
||||||
and (issue_comment.external_id != str(request.data.get("external_id")))
|
and (
|
||||||
|
issue_comment.external_id
|
||||||
|
!= str(request.data.get("external_id"))
|
||||||
|
)
|
||||||
and IssueComment.objects.filter(
|
and IssueComment.objects.filter(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
@ -735,7 +740,6 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
status=status.HTTP_409_CONFLICT,
|
status=status.HTTP_409_CONFLICT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
serializer = IssueCommentSerializer(
|
serializer = IssueCommentSerializer(
|
||||||
issue_comment, data=request.data, partial=True
|
issue_comment, data=request.data, partial=True
|
||||||
)
|
)
|
||||||
@ -792,6 +796,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
|
|||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
|
.filter(project__archived_at__isnull=True)
|
||||||
.select_related("actor", "workspace", "issue", "project")
|
.select_related("actor", "workspace", "issue", "project")
|
||||||
).order_by(request.GET.get("order_by", "created_at"))
|
).order_by(request.GET.get("order_by", "created_at"))
|
||||||
|
|
||||||
|
@ -67,6 +67,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
issue_module__issue__archived_at__isnull=True,
|
issue_module__issue__archived_at__isnull=True,
|
||||||
issue_module__issue__is_draft=False,
|
issue_module__issue__is_draft=False,
|
||||||
),
|
),
|
||||||
|
distinct=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
@ -77,6 +78,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
issue_module__issue__archived_at__isnull=True,
|
issue_module__issue__archived_at__isnull=True,
|
||||||
issue_module__issue__is_draft=False,
|
issue_module__issue__is_draft=False,
|
||||||
),
|
),
|
||||||
|
distinct=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
@ -87,6 +89,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
issue_module__issue__archived_at__isnull=True,
|
issue_module__issue__archived_at__isnull=True,
|
||||||
issue_module__issue__is_draft=False,
|
issue_module__issue__is_draft=False,
|
||||||
),
|
),
|
||||||
|
distinct=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
@ -97,6 +100,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
issue_module__issue__archived_at__isnull=True,
|
issue_module__issue__archived_at__isnull=True,
|
||||||
issue_module__issue__is_draft=False,
|
issue_module__issue__is_draft=False,
|
||||||
),
|
),
|
||||||
|
distinct=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
@ -107,6 +111,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
issue_module__issue__archived_at__isnull=True,
|
issue_module__issue__archived_at__isnull=True,
|
||||||
issue_module__issue__is_draft=False,
|
issue_module__issue__is_draft=False,
|
||||||
),
|
),
|
||||||
|
distinct=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
@ -117,6 +122,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
issue_module__issue__archived_at__isnull=True,
|
issue_module__issue__archived_at__isnull=True,
|
||||||
issue_module__issue__is_draft=False,
|
issue_module__issue__is_draft=False,
|
||||||
),
|
),
|
||||||
|
distinct=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by(self.kwargs.get("order_by", "-created_at"))
|
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||||
@ -165,6 +171,11 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
module = Module.objects.get(
|
module = Module.objects.get(
|
||||||
pk=pk, project_id=project_id, workspace__slug=slug
|
pk=pk, project_id=project_id, workspace__slug=slug
|
||||||
)
|
)
|
||||||
|
if module.archived_at:
|
||||||
|
return Response(
|
||||||
|
{"error": "Archived module cannot be edited"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
serializer = ModuleSerializer(
|
serializer = ModuleSerializer(
|
||||||
module,
|
module,
|
||||||
data=request.data,
|
data=request.data,
|
||||||
@ -178,7 +189,9 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
and Module.objects.filter(
|
and Module.objects.filter(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
external_source=request.data.get("external_source", module.external_source),
|
external_source=request.data.get(
|
||||||
|
"external_source", module.external_source
|
||||||
|
),
|
||||||
external_id=request.data.get("external_id"),
|
external_id=request.data.get("external_id"),
|
||||||
).exists()
|
).exists()
|
||||||
):
|
):
|
||||||
@ -195,7 +208,9 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
|
|
||||||
def get(self, request, slug, project_id, pk=None):
|
def get(self, request, slug, project_id, pk=None):
|
||||||
if pk:
|
if pk:
|
||||||
queryset = self.get_queryset().get(pk=pk)
|
queryset = (
|
||||||
|
self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
|
||||||
|
)
|
||||||
data = ModuleSerializer(
|
data = ModuleSerializer(
|
||||||
queryset,
|
queryset,
|
||||||
fields=self.fields,
|
fields=self.fields,
|
||||||
@ -207,7 +222,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
)
|
)
|
||||||
return self.paginate(
|
return self.paginate(
|
||||||
request=request,
|
request=request,
|
||||||
queryset=(self.get_queryset()),
|
queryset=(self.get_queryset().filter(archived_at__isnull=True)),
|
||||||
on_results=lambda modules: ModuleSerializer(
|
on_results=lambda modules: ModuleSerializer(
|
||||||
modules,
|
modules,
|
||||||
many=True,
|
many=True,
|
||||||
@ -277,6 +292,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
|
.filter(project__archived_at__isnull=True)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("module")
|
.select_related("module")
|
||||||
@ -444,3 +460,123 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
)
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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):
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
|
from django.utils import timezone
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import Exists, OuterRef, Q, F, Func, Subquery, Prefetch
|
from django.db.models import Exists, OuterRef, Q, F, Func, Subquery, Prefetch
|
||||||
|
|
||||||
@ -11,7 +12,6 @@ from rest_framework.serializers import ValidationError
|
|||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Workspace,
|
Workspace,
|
||||||
Project,
|
Project,
|
||||||
ProjectFavorite,
|
|
||||||
ProjectMember,
|
ProjectMember,
|
||||||
ProjectDeployBoard,
|
ProjectDeployBoard,
|
||||||
State,
|
State,
|
||||||
@ -40,7 +40,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
return (
|
return (
|
||||||
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(
|
.filter(
|
||||||
Q(project_projectmember__member=self.request.user)
|
Q(
|
||||||
|
project_projectmember__member=self.request.user,
|
||||||
|
project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
| Q(network=2)
|
| Q(network=2)
|
||||||
)
|
)
|
||||||
.select_related(
|
.select_related(
|
||||||
@ -150,7 +153,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
# Add the user as Administrator to the project
|
# Add the user as Administrator to the project
|
||||||
project_member = ProjectMember.objects.create(
|
_ = ProjectMember.objects.create(
|
||||||
project_id=serializer.data["id"],
|
project_id=serializer.data["id"],
|
||||||
member=request.user,
|
member=request.user,
|
||||||
role=20,
|
role=20,
|
||||||
@ -245,12 +248,12 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
{"name": "The project name is already taken"},
|
{"name": "The project name is already taken"},
|
||||||
status=status.HTTP_410_GONE,
|
status=status.HTTP_410_GONE,
|
||||||
)
|
)
|
||||||
except Workspace.DoesNotExist as e:
|
except Workspace.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Workspace does not exist"},
|
{"error": "Workspace does not exist"},
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
except ValidationError as e:
|
except ValidationError:
|
||||||
return Response(
|
return Response(
|
||||||
{"identifier": "The project identifier is already taken"},
|
{"identifier": "The project identifier is already taken"},
|
||||||
status=status.HTTP_410_GONE,
|
status=status.HTTP_410_GONE,
|
||||||
@ -261,6 +264,12 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
workspace = Workspace.objects.get(slug=slug)
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
project = Project.objects.get(pk=project_id)
|
project = Project.objects.get(pk=project_id)
|
||||||
|
|
||||||
|
if project.archived_at:
|
||||||
|
return Response(
|
||||||
|
{"error": "Archived project cannot be updated"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
serializer = ProjectSerializer(
|
serializer = ProjectSerializer(
|
||||||
project,
|
project,
|
||||||
data={**request.data},
|
data={**request.data},
|
||||||
@ -307,7 +316,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
{"error": "Project does not exist"},
|
{"error": "Project does not exist"},
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
except ValidationError as e:
|
except ValidationError:
|
||||||
return Response(
|
return Response(
|
||||||
{"identifier": "The project identifier is already taken"},
|
{"identifier": "The project identifier is already taken"},
|
||||||
status=status.HTTP_410_GONE,
|
status=status.HTTP_410_GONE,
|
||||||
@ -317,3 +326,22 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||||
project.delete()
|
project.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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.archived_at = None
|
||||||
|
project.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
@ -28,6 +28,7 @@ class StateAPIEndpoint(BaseAPIView):
|
|||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
|
.filter(project__archived_at__isnull=True)
|
||||||
.filter(~Q(name="Triage"))
|
.filter(~Q(name="Triage"))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
@ -66,8 +67,10 @@ class StateAPIEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
serializer.save(project_id=project_id)
|
serializer.save(project_id=project_id)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(
|
||||||
except IntegrityError as e:
|
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
state = State.objects.filter(
|
state = State.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@ -136,7 +139,9 @@ class StateAPIEndpoint(BaseAPIView):
|
|||||||
and State.objects.filter(
|
and State.objects.filter(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
external_source=request.data.get("external_source", state.external_source),
|
external_source=request.data.get(
|
||||||
|
"external_source", state.external_source
|
||||||
|
),
|
||||||
external_id=request.data.get("external_id"),
|
external_id=request.data.get("external_id"),
|
||||||
).exists()
|
).exists()
|
||||||
):
|
):
|
||||||
|
@ -86,16 +86,6 @@ from .module import (
|
|||||||
|
|
||||||
from .api import APITokenSerializer, APITokenReadSerializer
|
from .api import APITokenSerializer, APITokenReadSerializer
|
||||||
|
|
||||||
from .integration import (
|
|
||||||
IntegrationSerializer,
|
|
||||||
WorkspaceIntegrationSerializer,
|
|
||||||
GithubIssueSyncSerializer,
|
|
||||||
GithubRepositorySerializer,
|
|
||||||
GithubRepositorySyncSerializer,
|
|
||||||
GithubCommentSyncSerializer,
|
|
||||||
SlackProjectSyncSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .importer import ImporterSerializer
|
from .importer import ImporterSerializer
|
||||||
|
|
||||||
from .page import (
|
from .page import (
|
||||||
@ -121,7 +111,10 @@ from .inbox import (
|
|||||||
|
|
||||||
from .analytic import AnalyticViewSerializer
|
from .analytic import AnalyticViewSerializer
|
||||||
|
|
||||||
from .notification import NotificationSerializer, UserNotificationPreferenceSerializer
|
from .notification import (
|
||||||
|
NotificationSerializer,
|
||||||
|
UserNotificationPreferenceSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
from .exporter import ExporterHistorySerializer
|
from .exporter import ExporterHistorySerializer
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ from plane.db.models import (
|
|||||||
CycleUserProperties,
|
CycleUserProperties,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CycleWriteSerializer(BaseSerializer):
|
class CycleWriteSerializer(BaseSerializer):
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
if (
|
if (
|
||||||
@ -30,6 +31,7 @@ class CycleWriteSerializer(BaseSerializer):
|
|||||||
"workspace",
|
"workspace",
|
||||||
"project",
|
"project",
|
||||||
"owned_by",
|
"owned_by",
|
||||||
|
"archived_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -47,7 +49,6 @@ class CycleSerializer(BaseSerializer):
|
|||||||
# active | draft | upcoming | completed
|
# active | draft | upcoming | completed
|
||||||
status = serializers.CharField(read_only=True)
|
status = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Cycle
|
model = Cycle
|
||||||
fields = [
|
fields = [
|
||||||
|
@ -18,9 +18,4 @@ class WidgetSerializer(BaseSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Widget
|
model = Widget
|
||||||
fields = [
|
fields = ["id", "key", "is_visible", "widget_filters"]
|
||||||
"id",
|
|
||||||
"key",
|
|
||||||
"is_visible",
|
|
||||||
"widget_filters"
|
|
||||||
]
|
|
||||||
|
@ -74,5 +74,3 @@ class WorkspaceEstimateSerializer(BaseSerializer):
|
|||||||
"name",
|
"name",
|
||||||
"description",
|
"description",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
|
|
||||||
from .github import (
|
|
||||||
GithubRepositorySerializer,
|
|
||||||
GithubRepositorySyncSerializer,
|
|
||||||
GithubIssueSyncSerializer,
|
|
||||||
GithubCommentSyncSerializer,
|
|
||||||
)
|
|
||||||
from .slack import SlackProjectSyncSerializer
|
|
@ -1,22 +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__"
|
|
@ -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",
|
|
||||||
]
|
|
@ -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",
|
|
||||||
]
|
|
@ -1,5 +1,7 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.core.validators import URLValidator
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@ -7,7 +9,7 @@ from rest_framework import serializers
|
|||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer, DynamicBaseSerializer
|
from .base import BaseSerializer, DynamicBaseSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
from .state import StateSerializer, StateLiteSerializer
|
from .state import StateLiteSerializer
|
||||||
from .project import ProjectLiteSerializer
|
from .project import ProjectLiteSerializer
|
||||||
from .workspace import WorkspaceLiteSerializer
|
from .workspace import WorkspaceLiteSerializer
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
@ -31,7 +33,6 @@ from plane.db.models import (
|
|||||||
IssueVote,
|
IssueVote,
|
||||||
IssueRelation,
|
IssueRelation,
|
||||||
State,
|
State,
|
||||||
Project,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -432,6 +433,20 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
"issue",
|
"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
|
# Validation if url already exists
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
if IssueLink.objects.filter(
|
if IssueLink.objects.filter(
|
||||||
@ -443,9 +458,19 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
)
|
)
|
||||||
return IssueLink.objects.create(**validated_data)
|
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,
|
||||||
|
).exists():
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"error": "URL already exists for this Issue"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class IssueLinkLiteSerializer(BaseSerializer):
|
class IssueLinkLiteSerializer(BaseSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueLink
|
model = IssueLink
|
||||||
fields = [
|
fields = [
|
||||||
@ -476,7 +501,6 @@ class IssueAttachmentSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueAttachment
|
model = IssueAttachment
|
||||||
fields = [
|
fields = [
|
||||||
@ -505,13 +529,12 @@ class IssueReactionSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class IssueReactionLiteSerializer(DynamicBaseSerializer):
|
class IssueReactionLiteSerializer(DynamicBaseSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueReaction
|
model = IssueReaction
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"actor_id",
|
"actor",
|
||||||
"issue_id",
|
"issue",
|
||||||
"reaction",
|
"reaction",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -601,15 +624,18 @@ class IssueSerializer(DynamicBaseSerializer):
|
|||||||
# ids
|
# ids
|
||||||
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||||
module_ids = serializers.ListField(
|
module_ids = serializers.ListField(
|
||||||
child=serializers.UUIDField(), required=False,
|
child=serializers.UUIDField(),
|
||||||
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Many to many
|
# Many to many
|
||||||
label_ids = serializers.ListField(
|
label_ids = serializers.ListField(
|
||||||
child=serializers.UUIDField(), required=False,
|
child=serializers.UUIDField(),
|
||||||
|
required=False,
|
||||||
)
|
)
|
||||||
assignee_ids = serializers.ListField(
|
assignee_ids = serializers.ListField(
|
||||||
child=serializers.UUIDField(), required=False,
|
child=serializers.UUIDField(),
|
||||||
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Count items
|
# Count items
|
||||||
@ -649,19 +675,7 @@ class IssueSerializer(DynamicBaseSerializer):
|
|||||||
read_only_fields = fields
|
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",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Issue
|
model = Issue
|
||||||
fields = [
|
fields = [
|
||||||
|
@ -3,7 +3,6 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer, DynamicBaseSerializer
|
from .base import BaseSerializer, DynamicBaseSerializer
|
||||||
from .user import UserLiteSerializer
|
|
||||||
from .project import ProjectLiteSerializer
|
from .project import ProjectLiteSerializer
|
||||||
|
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
@ -40,6 +39,7 @@ class ModuleWriteSerializer(BaseSerializer):
|
|||||||
"updated_by",
|
"updated_by",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
|
"archived_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
@ -142,7 +142,6 @@ class ModuleIssueSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ModuleLinkSerializer(BaseSerializer):
|
class ModuleLinkSerializer(BaseSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ModuleLink
|
model = ModuleLink
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
@ -215,13 +214,12 @@ class ModuleSerializer(DynamicBaseSerializer):
|
|||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ModuleDetailSerializer(ModuleSerializer):
|
class ModuleDetailSerializer(ModuleSerializer):
|
||||||
|
|
||||||
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
||||||
|
sub_issues = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta(ModuleSerializer.Meta):
|
class Meta(ModuleSerializer.Meta):
|
||||||
fields = ModuleSerializer.Meta.fields + ['link_module']
|
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
|
||||||
|
|
||||||
|
|
||||||
class ModuleFavoriteSerializer(BaseSerializer):
|
class ModuleFavoriteSerializer(BaseSerializer):
|
||||||
|
@ -15,7 +15,6 @@ class NotificationSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class UserNotificationPreferenceSerializer(BaseSerializer):
|
class UserNotificationPreferenceSerializer(BaseSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserNotificationPreference
|
model = UserNotificationPreference
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
@ -3,7 +3,7 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from .issue import IssueFlatSerializer, LabelLiteSerializer
|
from .issue import LabelLiteSerializer
|
||||||
from .workspace import WorkspaceLiteSerializer
|
from .workspace import WorkspaceLiteSerializer
|
||||||
from .project import ProjectLiteSerializer
|
from .project import ProjectLiteSerializer
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
@ -12,8 +12,6 @@ from plane.db.models import (
|
|||||||
PageFavorite,
|
PageFavorite,
|
||||||
PageLabel,
|
PageLabel,
|
||||||
Label,
|
Label,
|
||||||
Issue,
|
|
||||||
Module,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -95,14 +95,19 @@ class ProjectLiteSerializer(BaseSerializer):
|
|||||||
"identifier",
|
"identifier",
|
||||||
"name",
|
"name",
|
||||||
"cover_image",
|
"cover_image",
|
||||||
"icon_prop",
|
"logo_props",
|
||||||
"emoji",
|
|
||||||
"description",
|
"description",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
class ProjectListSerializer(DynamicBaseSerializer):
|
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)
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
total_members = serializers.IntegerField(read_only=True)
|
total_members = serializers.IntegerField(read_only=True)
|
||||||
total_cycles = serializers.IntegerField(read_only=True)
|
total_cycles = serializers.IntegerField(read_only=True)
|
||||||
|
@ -4,7 +4,6 @@ from rest_framework import serializers
|
|||||||
# Module import
|
# Module import
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from plane.db.models import User, Workspace, WorkspaceMemberInvite
|
from plane.db.models import User, Workspace, WorkspaceMemberInvite
|
||||||
from plane.license.models import InstanceAdmin, Instance
|
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(BaseSerializer):
|
class UserSerializer(BaseSerializer):
|
||||||
@ -99,13 +98,13 @@ class UserMeSettingsSerializer(BaseSerializer):
|
|||||||
).first()
|
).first()
|
||||||
return {
|
return {
|
||||||
"last_workspace_id": obj.last_workspace_id,
|
"last_workspace_id": obj.last_workspace_id,
|
||||||
"last_workspace_slug": workspace.slug
|
"last_workspace_slug": (
|
||||||
if workspace is not None
|
workspace.slug if workspace is not None else ""
|
||||||
else "",
|
),
|
||||||
"fallback_workspace_id": obj.last_workspace_id,
|
"fallback_workspace_id": obj.last_workspace_id,
|
||||||
"fallback_workspace_slug": workspace.slug
|
"fallback_workspace_slug": (
|
||||||
if workspace is not None
|
workspace.slug if workspace is not None else ""
|
||||||
else "",
|
),
|
||||||
"invites": workspace_invites,
|
"invites": workspace_invites,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
@ -120,12 +119,16 @@ class UserMeSettingsSerializer(BaseSerializer):
|
|||||||
return {
|
return {
|
||||||
"last_workspace_id": None,
|
"last_workspace_id": None,
|
||||||
"last_workspace_slug": None,
|
"last_workspace_slug": None,
|
||||||
"fallback_workspace_id": fallback_workspace.id
|
"fallback_workspace_id": (
|
||||||
if fallback_workspace is not None
|
fallback_workspace.id
|
||||||
else None,
|
if fallback_workspace is not None
|
||||||
"fallback_workspace_slug": fallback_workspace.slug
|
else None
|
||||||
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,
|
"invites": workspace_invites,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import urllib
|
|
||||||
import socket
|
import socket
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
@ -6,9 +6,7 @@ from .cycle import urlpatterns as cycle_urls
|
|||||||
from .dashboard import urlpatterns as dashboard_urls
|
from .dashboard import urlpatterns as dashboard_urls
|
||||||
from .estimate import urlpatterns as estimate_urls
|
from .estimate import urlpatterns as estimate_urls
|
||||||
from .external import urlpatterns as external_urls
|
from .external import urlpatterns as external_urls
|
||||||
from .importer import urlpatterns as importer_urls
|
|
||||||
from .inbox import urlpatterns as inbox_urls
|
from .inbox import urlpatterns as inbox_urls
|
||||||
from .integration import urlpatterns as integration_urls
|
|
||||||
from .issue import urlpatterns as issue_urls
|
from .issue import urlpatterns as issue_urls
|
||||||
from .module import urlpatterns as module_urls
|
from .module import urlpatterns as module_urls
|
||||||
from .notification import urlpatterns as notification_urls
|
from .notification import urlpatterns as notification_urls
|
||||||
@ -32,9 +30,7 @@ urlpatterns = [
|
|||||||
*dashboard_urls,
|
*dashboard_urls,
|
||||||
*estimate_urls,
|
*estimate_urls,
|
||||||
*external_urls,
|
*external_urls,
|
||||||
*importer_urls,
|
|
||||||
*inbox_urls,
|
*inbox_urls,
|
||||||
*integration_urls,
|
|
||||||
*issue_urls,
|
*issue_urls,
|
||||||
*module_urls,
|
*module_urls,
|
||||||
*notification_urls,
|
*notification_urls,
|
||||||
|
@ -8,6 +8,7 @@ from plane.app.views import (
|
|||||||
CycleFavoriteViewSet,
|
CycleFavoriteViewSet,
|
||||||
TransferCycleIssueEndpoint,
|
TransferCycleIssueEndpoint,
|
||||||
CycleUserPropertiesEndpoint,
|
CycleUserPropertiesEndpoint,
|
||||||
|
CycleArchiveUnarchiveEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -90,4 +91,14 @@ urlpatterns = [
|
|||||||
CycleUserPropertiesEndpoint.as_view(),
|
CycleUserPropertiesEndpoint.as_view(),
|
||||||
name="cycle-user-filters",
|
name="cycle-user-filters",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/archive/",
|
||||||
|
CycleArchiveUnarchiveEndpoint.as_view(),
|
||||||
|
name="cycle-archive-unarchive",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/",
|
||||||
|
CycleArchiveUnarchiveEndpoint.as_view(),
|
||||||
|
name="cycle-archive-unarchive",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -2,7 +2,6 @@ from django.urls import path
|
|||||||
|
|
||||||
|
|
||||||
from plane.app.views import UnsplashEndpoint
|
from plane.app.views import UnsplashEndpoint
|
||||||
from plane.app.views import ReleaseNotesEndpoint
|
|
||||||
from plane.app.views import GPTIntegrationEndpoint
|
from plane.app.views import GPTIntegrationEndpoint
|
||||||
|
|
||||||
|
|
||||||
@ -12,11 +11,6 @@ urlpatterns = [
|
|||||||
UnsplashEndpoint.as_view(),
|
UnsplashEndpoint.as_view(),
|
||||||
name="unsplash",
|
name="unsplash",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"release-notes/",
|
|
||||||
ReleaseNotesEndpoint.as_view(),
|
|
||||||
name="release-notes",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
|
||||||
GPTIntegrationEndpoint.as_view(),
|
GPTIntegrationEndpoint.as_view(),
|
||||||
|
@ -1,37 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
|
|
||||||
|
|
||||||
from plane.app.views import (
|
|
||||||
ServiceIssueImportSummaryEndpoint,
|
|
||||||
ImportServiceEndpoint,
|
|
||||||
UpdateServiceImportStatusEndpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/importers/<str:service>/",
|
|
||||||
ServiceIssueImportSummaryEndpoint.as_view(),
|
|
||||||
name="importer-summary",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/importers/<str:service>/",
|
|
||||||
ImportServiceEndpoint.as_view(),
|
|
||||||
name="importer",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/importers/",
|
|
||||||
ImportServiceEndpoint.as_view(),
|
|
||||||
name="importer",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/importers/<str:service>/<uuid:pk>/",
|
|
||||||
ImportServiceEndpoint.as_view(),
|
|
||||||
name="importer",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/service/<str:service>/importers/<uuid:importer_id>/",
|
|
||||||
UpdateServiceImportStatusEndpoint.as_view(),
|
|
||||||
name="importer-status",
|
|
||||||
),
|
|
||||||
]
|
|
@ -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/<uuid:pk>/",
|
|
||||||
IntegrationViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "retrieve",
|
|
||||||
"patch": "partial_update",
|
|
||||||
"delete": "destroy",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="integrations",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/workspace-integrations/",
|
|
||||||
WorkspaceIntegrationViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "list",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="workspace-integrations",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/workspace-integrations/<str:provider>/",
|
|
||||||
WorkspaceIntegrationViewSet.as_view(
|
|
||||||
{
|
|
||||||
"post": "create",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="workspace-integrations",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/workspace-integrations/<uuid:pk>/provider/",
|
|
||||||
WorkspaceIntegrationViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "retrieve",
|
|
||||||
"delete": "destroy",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="workspace-integrations",
|
|
||||||
),
|
|
||||||
# Github Integrations
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/workspace-integrations/<uuid:workspace_integration_id>/github-repositories/",
|
|
||||||
GithubRepositoriesEndpoint.as_view(),
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/",
|
|
||||||
GithubRepositorySyncViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "list",
|
|
||||||
"post": "create",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/<uuid:pk>/",
|
|
||||||
GithubRepositorySyncViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "retrieve",
|
|
||||||
"delete": "destroy",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/",
|
|
||||||
GithubIssueSyncViewSet.as_view(
|
|
||||||
{
|
|
||||||
"post": "create",
|
|
||||||
"get": "list",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/bulk-create-github-issue-sync/",
|
|
||||||
BulkCreateGithubIssueSyncEndpoint.as_view(),
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
|
|
||||||
GithubIssueSyncViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "retrieve",
|
|
||||||
"delete": "destroy",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/",
|
|
||||||
GithubCommentSyncViewSet.as_view(
|
|
||||||
{
|
|
||||||
"post": "create",
|
|
||||||
"get": "list",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/<uuid:pk>/",
|
|
||||||
GithubCommentSyncViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "retrieve",
|
|
||||||
"delete": "destroy",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
## End Github Integrations
|
|
||||||
# Slack Integration
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/",
|
|
||||||
SlackProjectSyncViewSet.as_view(
|
|
||||||
{
|
|
||||||
"post": "create",
|
|
||||||
"get": "list",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/<uuid:pk>/",
|
|
||||||
SlackProjectSyncViewSet.as_view(
|
|
||||||
{
|
|
||||||
"delete": "destroy",
|
|
||||||
"get": "retrieve",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
## End Slack Integration
|
|
||||||
]
|
|
@ -1,30 +1,26 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
|
|
||||||
from plane.app.views import (
|
from plane.app.views import (
|
||||||
IssueListEndpoint,
|
|
||||||
IssueViewSet,
|
|
||||||
LabelViewSet,
|
|
||||||
BulkCreateIssueLabelsEndpoint,
|
BulkCreateIssueLabelsEndpoint,
|
||||||
BulkDeleteIssuesEndpoint,
|
BulkDeleteIssuesEndpoint,
|
||||||
BulkImportIssuesEndpoint,
|
|
||||||
UserWorkSpaceIssues,
|
|
||||||
SubIssuesEndpoint,
|
SubIssuesEndpoint,
|
||||||
IssueLinkViewSet,
|
IssueLinkViewSet,
|
||||||
IssueAttachmentEndpoint,
|
IssueAttachmentEndpoint,
|
||||||
|
CommentReactionViewSet,
|
||||||
ExportIssuesEndpoint,
|
ExportIssuesEndpoint,
|
||||||
IssueActivityEndpoint,
|
IssueActivityEndpoint,
|
||||||
IssueCommentViewSet,
|
|
||||||
IssueSubscriberViewSet,
|
|
||||||
IssueReactionViewSet,
|
|
||||||
CommentReactionViewSet,
|
|
||||||
IssueUserDisplayPropertyEndpoint,
|
|
||||||
IssueArchiveViewSet,
|
IssueArchiveViewSet,
|
||||||
IssueRelationViewSet,
|
IssueCommentViewSet,
|
||||||
IssueDraftViewSet,
|
IssueDraftViewSet,
|
||||||
|
IssueListEndpoint,
|
||||||
|
IssueReactionViewSet,
|
||||||
|
IssueRelationViewSet,
|
||||||
|
IssueSubscriberViewSet,
|
||||||
|
IssueUserDisplayPropertyEndpoint,
|
||||||
|
IssueViewSet,
|
||||||
|
LabelViewSet,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/list/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/list/",
|
||||||
@ -85,18 +81,7 @@ urlpatterns = [
|
|||||||
BulkDeleteIssuesEndpoint.as_view(),
|
BulkDeleteIssuesEndpoint.as_view(),
|
||||||
name="project-issues-bulk",
|
name="project-issues-bulk",
|
||||||
),
|
),
|
||||||
path(
|
##
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-issues/<str:service>/",
|
|
||||||
BulkImportIssuesEndpoint.as_view(),
|
|
||||||
name="project-issues-bulk",
|
|
||||||
),
|
|
||||||
# deprecated endpoint TODO: remove once confirmed
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/my-issues/",
|
|
||||||
UserWorkSpaceIssues.as_view(),
|
|
||||||
name="workspace-issues",
|
|
||||||
),
|
|
||||||
##
|
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
|
||||||
SubIssuesEndpoint.as_view(),
|
SubIssuesEndpoint.as_view(),
|
||||||
|
@ -6,8 +6,8 @@ from plane.app.views import (
|
|||||||
ModuleIssueViewSet,
|
ModuleIssueViewSet,
|
||||||
ModuleLinkViewSet,
|
ModuleLinkViewSet,
|
||||||
ModuleFavoriteViewSet,
|
ModuleFavoriteViewSet,
|
||||||
BulkImportModulesEndpoint,
|
|
||||||
ModuleUserPropertiesEndpoint,
|
ModuleUserPropertiesEndpoint,
|
||||||
|
ModuleArchiveUnarchiveEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -106,14 +106,19 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="user-favorite-module",
|
name="user-favorite-module",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-modules/<str:service>/",
|
|
||||||
BulkImportModulesEndpoint.as_view(),
|
|
||||||
name="bulk-modules-create",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/user-properties/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/user-properties/",
|
||||||
ModuleUserPropertiesEndpoint.as_view(),
|
ModuleUserPropertiesEndpoint.as_view(),
|
||||||
name="cycle-user-filters",
|
name="cycle-user-filters",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/archive/",
|
||||||
|
ModuleArchiveUnarchiveEndpoint.as_view(),
|
||||||
|
name="module-archive-unarchive",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/",
|
||||||
|
ModuleArchiveUnarchiveEndpoint.as_view(),
|
||||||
|
name="module-archive-unarchive",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -14,6 +14,7 @@ from plane.app.views import (
|
|||||||
ProjectPublicCoverImagesEndpoint,
|
ProjectPublicCoverImagesEndpoint,
|
||||||
ProjectDeployBoardViewSet,
|
ProjectDeployBoardViewSet,
|
||||||
UserProjectRolesEndpoint,
|
UserProjectRolesEndpoint,
|
||||||
|
ProjectArchiveUnarchiveEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -175,4 +176,9 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="project-deploy-board",
|
name="project-deploy-board",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/archive/",
|
||||||
|
ProjectArchiveUnarchiveEndpoint.as_view(),
|
||||||
|
name="project-archive-unarchive",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -22,6 +22,7 @@ from plane.app.views import (
|
|||||||
WorkspaceUserPropertiesEndpoint,
|
WorkspaceUserPropertiesEndpoint,
|
||||||
WorkspaceStatesEndpoint,
|
WorkspaceStatesEndpoint,
|
||||||
WorkspaceEstimatesEndpoint,
|
WorkspaceEstimatesEndpoint,
|
||||||
|
ExportWorkspaceUserActivityEndpoint,
|
||||||
WorkspaceModulesEndpoint,
|
WorkspaceModulesEndpoint,
|
||||||
WorkspaceCyclesEndpoint,
|
WorkspaceCyclesEndpoint,
|
||||||
)
|
)
|
||||||
@ -191,6 +192,11 @@ urlpatterns = [
|
|||||||
WorkspaceUserActivityEndpoint.as_view(),
|
WorkspaceUserActivityEndpoint.as_view(),
|
||||||
name="workspace-user-activity",
|
name="workspace-user-activity",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/user-activity/<uuid:user_id>/export/",
|
||||||
|
ExportWorkspaceUserActivityEndpoint.as_view(),
|
||||||
|
name="export-workspace-user-activity",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/user-profile/<uuid:user_id>/",
|
"workspaces/<str:slug>/user-profile/<uuid:user_id>/",
|
||||||
WorkspaceUserProfileEndpoint.as_view(),
|
WorkspaceUserProfileEndpoint.as_view(),
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,19 +1,27 @@
|
|||||||
from .project import (
|
from .project.base import (
|
||||||
ProjectViewSet,
|
ProjectViewSet,
|
||||||
ProjectMemberViewSet,
|
|
||||||
UserProjectInvitationsViewset,
|
|
||||||
ProjectInvitationsViewset,
|
|
||||||
AddTeamToProjectEndpoint,
|
|
||||||
ProjectIdentifierEndpoint,
|
ProjectIdentifierEndpoint,
|
||||||
ProjectJoinEndpoint,
|
|
||||||
ProjectUserViewsEndpoint,
|
ProjectUserViewsEndpoint,
|
||||||
ProjectMemberUserEndpoint,
|
|
||||||
ProjectFavoritesViewSet,
|
ProjectFavoritesViewSet,
|
||||||
ProjectPublicCoverImagesEndpoint,
|
ProjectPublicCoverImagesEndpoint,
|
||||||
ProjectDeployBoardViewSet,
|
ProjectDeployBoardViewSet,
|
||||||
|
ProjectArchiveUnarchiveEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .project.invite import (
|
||||||
|
UserProjectInvitationsViewset,
|
||||||
|
ProjectInvitationsViewset,
|
||||||
|
ProjectJoinEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .project.member import (
|
||||||
|
ProjectMemberViewSet,
|
||||||
|
AddTeamToProjectEndpoint,
|
||||||
|
ProjectMemberUserEndpoint,
|
||||||
UserProjectRolesEndpoint,
|
UserProjectRolesEndpoint,
|
||||||
)
|
)
|
||||||
from .user import (
|
|
||||||
|
from .user.base import (
|
||||||
UserEndpoint,
|
UserEndpoint,
|
||||||
UpdateUserOnBoardedEndpoint,
|
UpdateUserOnBoardedEndpoint,
|
||||||
UpdateUserTourCompletedEndpoint,
|
UpdateUserTourCompletedEndpoint,
|
||||||
@ -24,70 +32,122 @@ from .oauth import OauthEndpoint
|
|||||||
|
|
||||||
from .base import BaseAPIView, BaseViewSet, WebhookMixin
|
from .base import BaseAPIView, BaseViewSet, WebhookMixin
|
||||||
|
|
||||||
from .workspace import (
|
from .workspace.base import (
|
||||||
WorkSpaceViewSet,
|
WorkSpaceViewSet,
|
||||||
UserWorkSpacesEndpoint,
|
UserWorkSpacesEndpoint,
|
||||||
WorkSpaceAvailabilityCheckEndpoint,
|
WorkSpaceAvailabilityCheckEndpoint,
|
||||||
WorkspaceJoinEndpoint,
|
|
||||||
WorkSpaceMemberViewSet,
|
|
||||||
TeamMemberViewSet,
|
|
||||||
WorkspaceInvitationsViewset,
|
|
||||||
UserWorkspaceInvitationsViewSet,
|
|
||||||
UserLastProjectWithWorkspaceEndpoint,
|
|
||||||
WorkspaceMemberUserEndpoint,
|
|
||||||
WorkspaceMemberUserViewsEndpoint,
|
|
||||||
UserActivityGraphEndpoint,
|
|
||||||
UserIssueCompletedGraphEndpoint,
|
|
||||||
UserWorkspaceDashboardEndpoint,
|
UserWorkspaceDashboardEndpoint,
|
||||||
WorkspaceThemeViewSet,
|
WorkspaceThemeViewSet,
|
||||||
WorkspaceUserProfileStatsEndpoint,
|
ExportWorkspaceUserActivityEndpoint
|
||||||
WorkspaceUserActivityEndpoint,
|
)
|
||||||
WorkspaceUserProfileEndpoint,
|
|
||||||
WorkspaceUserProfileIssuesEndpoint,
|
from .workspace.member import (
|
||||||
WorkspaceLabelsEndpoint,
|
WorkSpaceMemberViewSet,
|
||||||
|
TeamMemberViewSet,
|
||||||
|
WorkspaceMemberUserEndpoint,
|
||||||
WorkspaceProjectMemberEndpoint,
|
WorkspaceProjectMemberEndpoint,
|
||||||
WorkspaceUserPropertiesEndpoint,
|
WorkspaceMemberUserViewsEndpoint,
|
||||||
|
)
|
||||||
|
from .workspace.invite import (
|
||||||
|
WorkspaceInvitationsViewset,
|
||||||
|
WorkspaceJoinEndpoint,
|
||||||
|
UserWorkspaceInvitationsViewSet,
|
||||||
|
)
|
||||||
|
from .workspace.label import (
|
||||||
|
WorkspaceLabelsEndpoint,
|
||||||
|
)
|
||||||
|
from .workspace.state import (
|
||||||
WorkspaceStatesEndpoint,
|
WorkspaceStatesEndpoint,
|
||||||
|
)
|
||||||
|
from .workspace.user import (
|
||||||
|
UserLastProjectWithWorkspaceEndpoint,
|
||||||
|
WorkspaceUserProfileIssuesEndpoint,
|
||||||
|
WorkspaceUserPropertiesEndpoint,
|
||||||
|
WorkspaceUserProfileEndpoint,
|
||||||
|
WorkspaceUserActivityEndpoint,
|
||||||
|
WorkspaceUserProfileStatsEndpoint,
|
||||||
|
UserActivityGraphEndpoint,
|
||||||
|
UserIssueCompletedGraphEndpoint,
|
||||||
|
)
|
||||||
|
from .workspace.estimate import (
|
||||||
WorkspaceEstimatesEndpoint,
|
WorkspaceEstimatesEndpoint,
|
||||||
|
)
|
||||||
|
from .workspace.module import (
|
||||||
WorkspaceModulesEndpoint,
|
WorkspaceModulesEndpoint,
|
||||||
|
)
|
||||||
|
from .workspace.cycle import (
|
||||||
WorkspaceCyclesEndpoint,
|
WorkspaceCyclesEndpoint,
|
||||||
)
|
)
|
||||||
from .state import StateViewSet
|
|
||||||
from .view import (
|
from .state.base import StateViewSet
|
||||||
|
from .view.base import (
|
||||||
GlobalViewViewSet,
|
GlobalViewViewSet,
|
||||||
GlobalViewIssuesViewSet,
|
GlobalViewIssuesViewSet,
|
||||||
IssueViewViewSet,
|
IssueViewViewSet,
|
||||||
IssueViewFavoriteViewSet,
|
IssueViewFavoriteViewSet,
|
||||||
)
|
)
|
||||||
from .cycle import (
|
from .cycle.base import (
|
||||||
CycleViewSet,
|
CycleViewSet,
|
||||||
CycleIssueViewSet,
|
|
||||||
CycleDateCheckEndpoint,
|
CycleDateCheckEndpoint,
|
||||||
CycleFavoriteViewSet,
|
CycleFavoriteViewSet,
|
||||||
TransferCycleIssueEndpoint,
|
TransferCycleIssueEndpoint,
|
||||||
|
CycleArchiveUnarchiveEndpoint,
|
||||||
CycleUserPropertiesEndpoint,
|
CycleUserPropertiesEndpoint,
|
||||||
)
|
)
|
||||||
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
from .cycle.issue import (
|
||||||
from .issue import (
|
CycleIssueViewSet,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||||
|
from .issue.base import (
|
||||||
IssueListEndpoint,
|
IssueListEndpoint,
|
||||||
IssueViewSet,
|
IssueViewSet,
|
||||||
WorkSpaceIssuesEndpoint,
|
|
||||||
IssueActivityEndpoint,
|
|
||||||
IssueCommentViewSet,
|
|
||||||
IssueUserDisplayPropertyEndpoint,
|
IssueUserDisplayPropertyEndpoint,
|
||||||
LabelViewSet,
|
|
||||||
BulkDeleteIssuesEndpoint,
|
BulkDeleteIssuesEndpoint,
|
||||||
UserWorkSpaceIssues,
|
)
|
||||||
SubIssuesEndpoint,
|
|
||||||
IssueLinkViewSet,
|
from .issue.activity import (
|
||||||
BulkCreateIssueLabelsEndpoint,
|
IssueActivityEndpoint,
|
||||||
IssueAttachmentEndpoint,
|
)
|
||||||
|
|
||||||
|
from .issue.archive import (
|
||||||
IssueArchiveViewSet,
|
IssueArchiveViewSet,
|
||||||
IssueSubscriberViewSet,
|
)
|
||||||
|
|
||||||
|
from .issue.attachment import (
|
||||||
|
IssueAttachmentEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .issue.comment import (
|
||||||
|
IssueCommentViewSet,
|
||||||
CommentReactionViewSet,
|
CommentReactionViewSet,
|
||||||
IssueReactionViewSet,
|
)
|
||||||
|
|
||||||
|
from .issue.draft import IssueDraftViewSet
|
||||||
|
|
||||||
|
from .issue.label import (
|
||||||
|
LabelViewSet,
|
||||||
|
BulkCreateIssueLabelsEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .issue.link import (
|
||||||
|
IssueLinkViewSet,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .issue.relation import (
|
||||||
IssueRelationViewSet,
|
IssueRelationViewSet,
|
||||||
IssueDraftViewSet,
|
)
|
||||||
|
|
||||||
|
from .issue.reaction import (
|
||||||
|
IssueReactionViewSet,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .issue.sub_issue import (
|
||||||
|
SubIssuesEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .issue.subscriber import (
|
||||||
|
IssueSubscriberViewSet,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .auth_extended import (
|
from .auth_extended import (
|
||||||
@ -106,36 +166,22 @@ from .authentication import (
|
|||||||
MagicSignInEndpoint,
|
MagicSignInEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .module import (
|
from .module.base import (
|
||||||
ModuleViewSet,
|
ModuleViewSet,
|
||||||
ModuleIssueViewSet,
|
|
||||||
ModuleLinkViewSet,
|
ModuleLinkViewSet,
|
||||||
ModuleFavoriteViewSet,
|
ModuleFavoriteViewSet,
|
||||||
|
ModuleArchiveUnarchiveEndpoint,
|
||||||
ModuleUserPropertiesEndpoint,
|
ModuleUserPropertiesEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .module.issue import (
|
||||||
|
ModuleIssueViewSet,
|
||||||
|
)
|
||||||
|
|
||||||
from .api import ApiTokenEndpoint
|
from .api import ApiTokenEndpoint
|
||||||
|
|
||||||
from .integration import (
|
|
||||||
WorkspaceIntegrationViewSet,
|
|
||||||
IntegrationViewSet,
|
|
||||||
GithubIssueSyncViewSet,
|
|
||||||
GithubRepositorySyncViewSet,
|
|
||||||
GithubCommentSyncViewSet,
|
|
||||||
GithubRepositoriesEndpoint,
|
|
||||||
BulkCreateGithubIssueSyncEndpoint,
|
|
||||||
SlackProjectSyncViewSet,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .importer import (
|
from .page.base import (
|
||||||
ServiceIssueImportSummaryEndpoint,
|
|
||||||
ImportServiceEndpoint,
|
|
||||||
UpdateServiceImportStatusEndpoint,
|
|
||||||
BulkImportIssuesEndpoint,
|
|
||||||
BulkImportModulesEndpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .page import (
|
|
||||||
PageViewSet,
|
PageViewSet,
|
||||||
PageFavoriteViewSet,
|
PageFavoriteViewSet,
|
||||||
PageLogEndpoint,
|
PageLogEndpoint,
|
||||||
@ -145,20 +191,19 @@ from .page import (
|
|||||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||||
|
|
||||||
|
|
||||||
from .external import (
|
from .external.base import (
|
||||||
GPTIntegrationEndpoint,
|
GPTIntegrationEndpoint,
|
||||||
ReleaseNotesEndpoint,
|
|
||||||
UnsplashEndpoint,
|
UnsplashEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .estimate import (
|
from .estimate.base import (
|
||||||
ProjectEstimatePointEndpoint,
|
ProjectEstimatePointEndpoint,
|
||||||
BulkEstimatePointEndpoint,
|
BulkEstimatePointEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .inbox import InboxViewSet, InboxIssueViewSet
|
from .inbox.base import InboxViewSet, InboxIssueViewSet
|
||||||
|
|
||||||
from .analytic import (
|
from .analytic.base import (
|
||||||
AnalyticsEndpoint,
|
AnalyticsEndpoint,
|
||||||
AnalyticViewViewset,
|
AnalyticViewViewset,
|
||||||
SavedAnalyticEndpoint,
|
SavedAnalyticEndpoint,
|
||||||
@ -166,24 +211,23 @@ from .analytic import (
|
|||||||
DefaultAnalyticsEndpoint,
|
DefaultAnalyticsEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .notification import (
|
from .notification.base import (
|
||||||
NotificationViewSet,
|
NotificationViewSet,
|
||||||
UnreadNotificationEndpoint,
|
UnreadNotificationEndpoint,
|
||||||
MarkAllReadNotificationViewSet,
|
MarkAllReadNotificationViewSet,
|
||||||
UserNotificationPreferenceEndpoint,
|
UserNotificationPreferenceEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .exporter import ExportIssuesEndpoint
|
from .exporter.base import ExportIssuesEndpoint
|
||||||
|
|
||||||
from .config import ConfigurationEndpoint, MobileConfigurationEndpoint
|
from .config import ConfigurationEndpoint, MobileConfigurationEndpoint
|
||||||
|
|
||||||
from .webhook import (
|
from .webhook.base import (
|
||||||
WebhookEndpoint,
|
WebhookEndpoint,
|
||||||
WebhookLogsEndpoint,
|
WebhookLogsEndpoint,
|
||||||
WebhookSecretRegenerateEndpoint,
|
WebhookSecretRegenerateEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .dashboard import (
|
from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
|
||||||
DashboardEndpoint,
|
|
||||||
WidgetsEndpoint
|
from .error_404 import custom_404_view
|
||||||
)
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.db.models import Count, Sum, F, Q
|
from django.db.models import Count, Sum, F
|
||||||
from django.db.models.functions import ExtractMonth
|
from django.db.models.functions import ExtractMonth
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -10,7 +10,7 @@ from rest_framework.response import Response
|
|||||||
# Module imports
|
# Module imports
|
||||||
from plane.app.views import BaseAPIView, BaseViewSet
|
from plane.app.views import BaseAPIView, BaseViewSet
|
||||||
from plane.app.permissions import WorkSpaceAdminPermission
|
from plane.app.permissions import WorkSpaceAdminPermission
|
||||||
from plane.db.models import Issue, AnalyticView, Workspace, State, Label
|
from plane.db.models import Issue, AnalyticView, Workspace
|
||||||
from plane.app.serializers import AnalyticViewSerializer
|
from plane.app.serializers import AnalyticViewSerializer
|
||||||
from plane.utils.analytics_plot import build_graph_plot
|
from plane.utils.analytics_plot import build_graph_plot
|
||||||
from plane.bgtasks.analytic_plot_export import analytic_export_task
|
from plane.bgtasks.analytic_plot_export import analytic_export_task
|
||||||
@ -51,8 +51,8 @@ class AnalyticsEndpoint(BaseAPIView):
|
|||||||
if (
|
if (
|
||||||
not x_axis
|
not x_axis
|
||||||
or not y_axis
|
or not y_axis
|
||||||
or not x_axis in valid_xaxis_segment
|
or x_axis not in valid_xaxis_segment
|
||||||
or not y_axis in valid_yaxis
|
or y_axis not in valid_yaxis
|
||||||
):
|
):
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -266,8 +266,8 @@ class ExportAnalyticsEndpoint(BaseAPIView):
|
|||||||
if (
|
if (
|
||||||
not x_axis
|
not x_axis
|
||||||
or not y_axis
|
or not y_axis
|
||||||
or not x_axis in valid_xaxis_segment
|
or x_axis not in valid_xaxis_segment
|
||||||
or not y_axis in valid_yaxis
|
or y_axis not in valid_yaxis
|
||||||
):
|
):
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
@ -43,7 +43,7 @@ class ApiTokenEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get(self, request, slug, pk=None):
|
def get(self, request, slug, pk=None):
|
||||||
if pk == None:
|
if pk is None:
|
||||||
api_tokens = APIToken.objects.filter(
|
api_tokens = APIToken.objects.filter(
|
||||||
user=request.user, workspace__slug=slug
|
user=request.user, workspace__slug=slug
|
||||||
)
|
)
|
||||||
|
@ -4,7 +4,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseAPIView, BaseViewSet
|
from ..base import BaseAPIView, BaseViewSet
|
||||||
from plane.db.models import FileAsset, Workspace
|
from plane.db.models import FileAsset, Workspace
|
||||||
from plane.app.serializers import FileAssetSerializer
|
from plane.app.serializers import FileAssetSerializer
|
||||||
|
|
@ -16,7 +16,6 @@ from django.contrib.auth.hashers import make_password
|
|||||||
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
## Third Party Imports
|
## Third Party Imports
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -172,7 +171,7 @@ class ResetPasswordEndpoint(BaseAPIView):
|
|||||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
except DjangoUnicodeDecodeError as indentifier:
|
except DjangoUnicodeDecodeError:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "token is not valid, please check the new one"},
|
{"error": "token is not valid, please check the new one"},
|
||||||
status=status.HTTP_401_UNAUTHORIZED,
|
status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
@ -7,7 +7,6 @@ import json
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
@ -65,7 +64,7 @@ class SignUpEndpoint(BaseAPIView):
|
|||||||
email = email.strip().lower()
|
email = email.strip().lower()
|
||||||
try:
|
try:
|
||||||
validate_email(email)
|
validate_email(email)
|
||||||
except ValidationError as e:
|
except ValidationError:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Please provide a valid email address."},
|
{"error": "Please provide a valid email address."},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
@ -151,7 +150,7 @@ class SignInEndpoint(BaseAPIView):
|
|||||||
email = email.strip().lower()
|
email = email.strip().lower()
|
||||||
try:
|
try:
|
||||||
validate_email(email)
|
validate_email(email)
|
||||||
except ValidationError as e:
|
except ValidationError:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Please provide a valid email address."},
|
{"error": "Please provide a valid email address."},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
@ -238,9 +237,11 @@ class SignInEndpoint(BaseAPIView):
|
|||||||
[
|
[
|
||||||
WorkspaceMember(
|
WorkspaceMember(
|
||||||
workspace_id=project_member_invite.workspace_id,
|
workspace_id=project_member_invite.workspace_id,
|
||||||
role=project_member_invite.role
|
role=(
|
||||||
if project_member_invite.role in [5, 10, 15]
|
project_member_invite.role
|
||||||
else 15,
|
if project_member_invite.role in [5, 10, 15]
|
||||||
|
else 15
|
||||||
|
),
|
||||||
member=user,
|
member=user,
|
||||||
created_by_id=project_member_invite.created_by_id,
|
created_by_id=project_member_invite.created_by_id,
|
||||||
)
|
)
|
||||||
@ -254,9 +255,11 @@ class SignInEndpoint(BaseAPIView):
|
|||||||
[
|
[
|
||||||
ProjectMember(
|
ProjectMember(
|
||||||
workspace_id=project_member_invite.workspace_id,
|
workspace_id=project_member_invite.workspace_id,
|
||||||
role=project_member_invite.role
|
role=(
|
||||||
if project_member_invite.role in [5, 10, 15]
|
project_member_invite.role
|
||||||
else 15,
|
if project_member_invite.role in [5, 10, 15]
|
||||||
|
else 15
|
||||||
|
),
|
||||||
member=user,
|
member=user,
|
||||||
created_by_id=project_member_invite.created_by_id,
|
created_by_id=project_member_invite.created_by_id,
|
||||||
)
|
)
|
||||||
@ -392,9 +395,11 @@ class MagicSignInEndpoint(BaseAPIView):
|
|||||||
[
|
[
|
||||||
WorkspaceMember(
|
WorkspaceMember(
|
||||||
workspace_id=project_member_invite.workspace_id,
|
workspace_id=project_member_invite.workspace_id,
|
||||||
role=project_member_invite.role
|
role=(
|
||||||
if project_member_invite.role in [5, 10, 15]
|
project_member_invite.role
|
||||||
else 15,
|
if project_member_invite.role in [5, 10, 15]
|
||||||
|
else 15
|
||||||
|
),
|
||||||
member=user,
|
member=user,
|
||||||
created_by_id=project_member_invite.created_by_id,
|
created_by_id=project_member_invite.created_by_id,
|
||||||
)
|
)
|
||||||
@ -408,9 +413,11 @@ class MagicSignInEndpoint(BaseAPIView):
|
|||||||
[
|
[
|
||||||
ProjectMember(
|
ProjectMember(
|
||||||
workspace_id=project_member_invite.workspace_id,
|
workspace_id=project_member_invite.workspace_id,
|
||||||
role=project_member_invite.role
|
role=(
|
||||||
if project_member_invite.role in [5, 10, 15]
|
project_member_invite.role
|
||||||
else 15,
|
if project_member_invite.role in [5, 10, 15]
|
||||||
|
else 15
|
||||||
|
),
|
||||||
member=user,
|
member=user,
|
||||||
created_by_id=project_member_invite.created_by_id,
|
created_by_id=project_member_invite.created_by_id,
|
||||||
)
|
)
|
||||||
|
@ -1,30 +1,27 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import zoneinfo
|
import zoneinfo
|
||||||
import json
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
|
from django.db import IntegrityError
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.urls import resolve
|
from django.urls import resolve
|
||||||
from django.conf import settings
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db import IntegrityError
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
|
||||||
|
|
||||||
# Third part imports
|
# Third part imports
|
||||||
from rest_framework import status
|
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.exceptions import APIException
|
||||||
from rest_framework.views import APIView
|
|
||||||
from rest_framework.filters import SearchFilter
|
from rest_framework.filters import SearchFilter
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from sentry_sdk import capture_exception
|
from rest_framework.response import Response
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.utils.paginator import BasePaginator
|
|
||||||
from plane.bgtasks.webhook_task import send_webhook
|
from plane.bgtasks.webhook_task import send_webhook
|
||||||
|
from plane.utils.exception_logger import log_exception
|
||||||
|
from plane.utils.paginator import BasePaginator
|
||||||
|
|
||||||
|
|
||||||
class TimezoneMixin:
|
class TimezoneMixin:
|
||||||
@ -90,7 +87,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
|
|||||||
try:
|
try:
|
||||||
return self.model.objects.all()
|
return self.model.objects.all()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
log_exception(e)
|
||||||
raise APIException(
|
raise APIException(
|
||||||
"Please check the view", status.HTTP_400_BAD_REQUEST
|
"Please check the view", status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
@ -119,18 +116,18 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
|
|||||||
|
|
||||||
if isinstance(e, ObjectDoesNotExist):
|
if isinstance(e, ObjectDoesNotExist):
|
||||||
return Response(
|
return Response(
|
||||||
{"error": f"The required object does not exist."},
|
{"error": "The required object does not exist."},
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(e, KeyError):
|
if isinstance(e, KeyError):
|
||||||
capture_exception(e)
|
log_exception(e)
|
||||||
return Response(
|
return Response(
|
||||||
{"error": f"The required key does not exist."},
|
{"error": "The required key does not exist."},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
capture_exception(e)
|
log_exception(e)
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
@ -226,19 +223,17 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
|||||||
|
|
||||||
if isinstance(e, ObjectDoesNotExist):
|
if isinstance(e, ObjectDoesNotExist):
|
||||||
return Response(
|
return Response(
|
||||||
{"error": f"The required object does not exist."},
|
{"error": "The required object does not exist."},
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(e, KeyError):
|
if isinstance(e, KeyError):
|
||||||
return Response(
|
return Response(
|
||||||
{"error": f"The required key does not exist."},
|
{"error": "The required key does not exist."},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.DEBUG:
|
log_exception(e)
|
||||||
print(e)
|
|
||||||
capture_exception(e)
|
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
@ -12,13 +11,14 @@ from rest_framework.response import Response
|
|||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseAPIView
|
from .base import BaseAPIView
|
||||||
from plane.license.utils.instance_value import get_configuration_value
|
from plane.license.utils.instance_value import get_configuration_value
|
||||||
|
from plane.utils.cache import cache_response
|
||||||
|
|
||||||
class ConfigurationEndpoint(BaseAPIView):
|
class ConfigurationEndpoint(BaseAPIView):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
AllowAny,
|
AllowAny,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@cache_response(60 * 60 * 2, user=False)
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
# Get all the configuration
|
# Get all the configuration
|
||||||
(
|
(
|
||||||
@ -136,6 +136,7 @@ class MobileConfigurationEndpoint(BaseAPIView):
|
|||||||
AllowAny,
|
AllowAny,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@cache_response(60 * 60 * 2, user=False)
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
(
|
(
|
||||||
GOOGLE_CLIENT_ID,
|
GOOGLE_CLIENT_ID,
|
||||||
|
@ -1,63 +1,56 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import json
|
import json
|
||||||
|
|
||||||
# Django imports
|
|
||||||
from django.db.models import (
|
|
||||||
Func,
|
|
||||||
F,
|
|
||||||
Q,
|
|
||||||
Exists,
|
|
||||||
OuterRef,
|
|
||||||
Count,
|
|
||||||
Prefetch,
|
|
||||||
Sum,
|
|
||||||
Case,
|
|
||||||
When,
|
|
||||||
Value,
|
|
||||||
CharField,
|
|
||||||
)
|
|
||||||
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.aggregates import ArrayAgg
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.db.models import Value, UUIDField
|
|
||||||
|
# Django imports
|
||||||
|
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.db.models.functions import Coalesce
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
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,
|
|
||||||
IssueSerializer,
|
|
||||||
CycleWriteSerializer,
|
|
||||||
CycleUserPropertiesSerializer,
|
|
||||||
)
|
|
||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
ProjectLitePermission,
|
ProjectLitePermission,
|
||||||
)
|
)
|
||||||
from plane.db.models import (
|
from plane.app.serializers import (
|
||||||
User,
|
CycleFavoriteSerializer,
|
||||||
Cycle,
|
CycleSerializer,
|
||||||
CycleIssue,
|
CycleUserPropertiesSerializer,
|
||||||
Issue,
|
CycleWriteSerializer,
|
||||||
CycleFavorite,
|
|
||||||
IssueLink,
|
|
||||||
IssueAttachment,
|
|
||||||
Label,
|
|
||||||
CycleUserProperties,
|
|
||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.db.models import (
|
||||||
|
Cycle,
|
||||||
|
CycleFavorite,
|
||||||
|
CycleIssue,
|
||||||
|
CycleUserProperties,
|
||||||
|
Issue,
|
||||||
|
Label,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from plane.utils.analytics_plot import burndown_plot
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
||||||
|
|
||||||
|
|
||||||
class CycleViewSet(WebhookMixin, BaseViewSet):
|
class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||||
serializer_class = CycleSerializer
|
serializer_class = CycleSerializer
|
||||||
@ -89,6 +82,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
|
.filter(project__archived_at__isnull=True)
|
||||||
.select_related("project", "workspace", "owned_by")
|
.select_related("project", "workspace", "owned_by")
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
@ -109,7 +103,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.annotate(is_favorite=Exists(favorite_subquery))
|
.annotate(is_favorite=Exists(favorite_subquery))
|
||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"issue_cycle",
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
filter=Q(
|
filter=Q(
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
issue_cycle__issue__is_draft=False,
|
issue_cycle__issue__is_draft=False,
|
||||||
@ -118,7 +113,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"issue_cycle__issue__state__group",
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
filter=Q(
|
filter=Q(
|
||||||
issue_cycle__issue__state__group="completed",
|
issue_cycle__issue__state__group="completed",
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
@ -128,7 +124,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
cancelled_issues=Count(
|
cancelled_issues=Count(
|
||||||
"issue_cycle__issue__state__group",
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
filter=Q(
|
filter=Q(
|
||||||
issue_cycle__issue__state__group="cancelled",
|
issue_cycle__issue__state__group="cancelled",
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
@ -138,7 +135,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
started_issues=Count(
|
started_issues=Count(
|
||||||
"issue_cycle__issue__state__group",
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
filter=Q(
|
filter=Q(
|
||||||
issue_cycle__issue__state__group="started",
|
issue_cycle__issue__state__group="started",
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
@ -148,7 +146,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
unstarted_issues=Count(
|
unstarted_issues=Count(
|
||||||
"issue_cycle__issue__state__group",
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
filter=Q(
|
filter=Q(
|
||||||
issue_cycle__issue__state__group="unstarted",
|
issue_cycle__issue__state__group="unstarted",
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
@ -158,7 +157,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
backlog_issues=Count(
|
backlog_issues=Count(
|
||||||
"issue_cycle__issue__state__group",
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
filter=Q(
|
filter=Q(
|
||||||
issue_cycle__issue__state__group="backlog",
|
issue_cycle__issue__state__group="backlog",
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
@ -202,7 +202,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def list(self, request, slug, project_id):
|
def list(self, request, slug, project_id):
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||||
cycle_view = request.GET.get("cycle_view", "all")
|
cycle_view = request.GET.get("cycle_view", "all")
|
||||||
|
|
||||||
# Update the order by
|
# Update the order by
|
||||||
@ -403,8 +403,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"progress_snapshot",
|
"progress_snapshot",
|
||||||
# meta fields
|
# meta fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
|
||||||
"cancelled_issues",
|
"cancelled_issues",
|
||||||
|
"total_issues",
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
"started_issues",
|
"started_issues",
|
||||||
"unstarted_issues",
|
"unstarted_issues",
|
||||||
@ -427,11 +427,15 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def partial_update(self, request, slug, project_id, pk):
|
def partial_update(self, request, slug, project_id, pk):
|
||||||
queryset = (
|
queryset = self.get_queryset().filter(
|
||||||
self.get_queryset()
|
workspace__slug=slug, project_id=project_id, pk=pk
|
||||||
.filter(workspace__slug=slug, project_id=project_id, pk=pk)
|
|
||||||
)
|
)
|
||||||
cycle = queryset.first()
|
cycle = queryset.first()
|
||||||
|
if cycle.archived_at:
|
||||||
|
return Response(
|
||||||
|
{"error": "Archived cycle cannot be updated"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
request_data = request.data
|
request_data = request.data
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -489,10 +493,22 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk):
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
queryset = self.get_queryset().filter(pk=pk)
|
queryset = (
|
||||||
|
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
|
||||||
|
)
|
||||||
data = (
|
data = (
|
||||||
self.get_queryset()
|
self.get_queryset()
|
||||||
.filter(pk=pk)
|
.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(
|
.values(
|
||||||
# necessary fields
|
# necessary fields
|
||||||
"id",
|
"id",
|
||||||
@ -509,6 +525,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"external_source",
|
"external_source",
|
||||||
"external_id",
|
"external_id",
|
||||||
"progress_snapshot",
|
"progress_snapshot",
|
||||||
|
"sub_issues",
|
||||||
# meta fields
|
# meta fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
"total_issues",
|
||||||
@ -662,273 +679,194 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||||
serializer_class = CycleIssueSerializer
|
|
||||||
model = CycleIssue
|
|
||||||
|
|
||||||
webhook_event = "cycle_issue"
|
|
||||||
bulk = True
|
|
||||||
|
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
]
|
]
|
||||||
|
|
||||||
filterset_fields = [
|
|
||||||
"issue__labels__id",
|
|
||||||
"issue__assignees__id",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.filter_queryset(
|
favorite_subquery = CycleFavorite.objects.filter(
|
||||||
super()
|
user=self.request.user,
|
||||||
.get_queryset()
|
cycle_id=OuterRef("pk"),
|
||||||
.annotate(
|
project_id=self.kwargs.get("project_id"),
|
||||||
sub_issues_count=Issue.issue_objects.filter(
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
parent=OuterRef("issue_id")
|
)
|
||||||
)
|
return (
|
||||||
.order_by()
|
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.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_id=self.kwargs.get("project_id"))
|
||||||
|
.filter(archived_at__isnull=False)
|
||||||
.filter(
|
.filter(
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
.filter(cycle_id=self.kwargs.get("cycle_id"))
|
.filter(project__archived_at__isnull=True)
|
||||||
.select_related("project")
|
.select_related("project", "workspace", "owned_by")
|
||||||
.select_related("workspace")
|
.prefetch_related(
|
||||||
.select_related("cycle")
|
Prefetch(
|
||||||
.select_related("issue", "issue__state", "issue__project")
|
"issue_cycle__issue__assignees",
|
||||||
.prefetch_related("issue__assignees", "issue__labels")
|
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(
|
||||||
|
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(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
& Q(
|
||||||
|
issue_cycle__issue__assignees__member_project__is_active=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "name")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
@method_decorator(gzip_page)
|
def get(self, request, slug, project_id):
|
||||||
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 = (
|
queryset = (
|
||||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
|
self.get_queryset()
|
||||||
.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(
|
.annotate(
|
||||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
total_issues=Count(
|
||||||
.order_by()
|
"issue_cycle",
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
filter=Q(
|
||||||
.values("count")
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
)
|
issue_cycle__issue__is_draft=False,
|
||||||
.annotate(
|
),
|
||||||
attachment_count=IssueAttachment.objects.filter(
|
|
||||||
issue=OuterRef("id")
|
|
||||||
)
|
)
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
)
|
||||||
.annotate(
|
.values(
|
||||||
sub_issues_count=Issue.issue_objects.filter(
|
# necessary fields
|
||||||
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),
|
|
||||||
),
|
|
||||||
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",
|
"id",
|
||||||
"name",
|
"workspace_id",
|
||||||
"state_id",
|
|
||||||
"sort_order",
|
|
||||||
"completed_at",
|
|
||||||
"estimate_point",
|
|
||||||
"priority",
|
|
||||||
"start_date",
|
|
||||||
"target_date",
|
|
||||||
"sequence_id",
|
|
||||||
"project_id",
|
"project_id",
|
||||||
"parent_id",
|
# model fields
|
||||||
"cycle_id",
|
"name",
|
||||||
"module_ids",
|
"description",
|
||||||
"label_ids",
|
"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",
|
"assignee_ids",
|
||||||
"sub_issues_count",
|
"status",
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"created_by",
|
|
||||||
"updated_by",
|
|
||||||
"attachment_count",
|
|
||||||
"link_count",
|
|
||||||
"is_draft",
|
|
||||||
"archived_at",
|
"archived_at",
|
||||||
)
|
)
|
||||||
return Response(issues, status=status.HTTP_200_OK)
|
).order_by("-is_favorite", "-created_at")
|
||||||
|
return Response(queryset, 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, cycle_id):
|
||||||
cycle = Cycle.objects.get(
|
cycle = Cycle.objects.get(
|
||||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
cycle.archived_at = timezone.now()
|
||||||
|
cycle.save()
|
||||||
|
return Response(
|
||||||
|
{"archived_at": str(cycle.archived_at)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
def delete(self, request, slug, project_id, cycle_id):
|
||||||
cycle.end_date is not None
|
cycle = Cycle.objects.get(
|
||||||
and cycle.end_date < timezone.now().date()
|
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||||
):
|
|
||||||
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 = [
|
cycle.archived_at = None
|
||||||
str(cycle_issue.issue_id) for cycle_issue in cycle_issues
|
cycle.save()
|
||||||
]
|
|
||||||
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:
|
|
||||||
# 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(cycle_issue.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)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
314
apiserver/plane/app/views/cycle/issue.py
Normal file
314
apiserver/plane/app/views/cycle/issue.py
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
# 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, WebhookMixin
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
# 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(cycle_issue.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)
|
@ -9,15 +9,15 @@ from django.db.models import (
|
|||||||
F,
|
F,
|
||||||
Exists,
|
Exists,
|
||||||
OuterRef,
|
OuterRef,
|
||||||
Max,
|
|
||||||
Subquery,
|
Subquery,
|
||||||
JSONField,
|
JSONField,
|
||||||
Func,
|
Func,
|
||||||
Prefetch,
|
Prefetch,
|
||||||
|
IntegerField,
|
||||||
)
|
)
|
||||||
from django.contrib.postgres.aggregates import ArrayAgg
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.db.models import Value, UUIDField
|
from django.db.models import UUIDField
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from . import BaseAPIView
|
from .. import BaseAPIView
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Issue,
|
Issue,
|
||||||
IssueActivity,
|
IssueActivity,
|
||||||
@ -38,6 +38,7 @@ from plane.db.models import (
|
|||||||
IssueLink,
|
IssueLink,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
IssueRelation,
|
IssueRelation,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
IssueActivitySerializer,
|
IssueActivitySerializer,
|
||||||
@ -148,7 +149,8 @@ def dashboard_assigned_issues(self, request, slug):
|
|||||||
ArrayAgg(
|
ArrayAgg(
|
||||||
"assignees__id",
|
"assignees__id",
|
||||||
distinct=True,
|
distinct=True,
|
||||||
filter=~Q(assignees__id__isnull=True),
|
filter=~Q(assignees__id__isnull=True)
|
||||||
|
& Q(assignees__member_project__is_active=True),
|
||||||
),
|
),
|
||||||
Value([], output_field=ArrayField(UUIDField())),
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
),
|
),
|
||||||
@ -212,11 +214,11 @@ def dashboard_assigned_issues(self, request, slug):
|
|||||||
if issue_type == "overdue":
|
if issue_type == "overdue":
|
||||||
overdue_issues_count = assigned_issues.filter(
|
overdue_issues_count = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
overdue_issues = assigned_issues.filter(
|
overdue_issues = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -231,11 +233,11 @@ def dashboard_assigned_issues(self, request, slug):
|
|||||||
if issue_type == "upcoming":
|
if issue_type == "upcoming":
|
||||||
upcoming_issues_count = assigned_issues.filter(
|
upcoming_issues_count = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
upcoming_issues = assigned_issues.filter(
|
upcoming_issues = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -302,7 +304,8 @@ def dashboard_created_issues(self, request, slug):
|
|||||||
ArrayAgg(
|
ArrayAgg(
|
||||||
"assignees__id",
|
"assignees__id",
|
||||||
distinct=True,
|
distinct=True,
|
||||||
filter=~Q(assignees__id__isnull=True),
|
filter=~Q(assignees__id__isnull=True)
|
||||||
|
& Q(assignees__member_project__is_active=True),
|
||||||
),
|
),
|
||||||
Value([], output_field=ArrayField(UUIDField())),
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
),
|
),
|
||||||
@ -365,11 +368,11 @@ def dashboard_created_issues(self, request, slug):
|
|||||||
if issue_type == "overdue":
|
if issue_type == "overdue":
|
||||||
overdue_issues_count = created_issues.filter(
|
overdue_issues_count = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
overdue_issues = created_issues.filter(
|
overdue_issues = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -382,11 +385,11 @@ def dashboard_created_issues(self, request, slug):
|
|||||||
if issue_type == "upcoming":
|
if issue_type == "upcoming":
|
||||||
upcoming_issues_count = created_issues.filter(
|
upcoming_issues_count = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
upcoming_issues = created_issues.filter(
|
upcoming_issues = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -470,6 +473,7 @@ def dashboard_recent_activity(self, request, slug):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True,
|
||||||
actor=request.user,
|
actor=request.user,
|
||||||
).select_related("actor", "workspace", "issue", "project")[:8]
|
).select_related("actor", "workspace", "issue", "project")[:8]
|
||||||
|
|
||||||
@ -485,6 +489,7 @@ def dashboard_recent_projects(self, request, slug):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True,
|
||||||
actor=request.user,
|
actor=request.user,
|
||||||
)
|
)
|
||||||
.values_list("project_id", flat=True)
|
.values_list("project_id", flat=True)
|
||||||
@ -499,11 +504,14 @@ def dashboard_recent_projects(self, request, slug):
|
|||||||
additional_projects = Project.objects.filter(
|
additional_projects = Project.objects.filter(
|
||||||
project_projectmember__member=request.user,
|
project_projectmember__member=request.user,
|
||||||
project_projectmember__is_active=True,
|
project_projectmember__is_active=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
).exclude(id__in=unique_project_ids)
|
).exclude(id__in=unique_project_ids)
|
||||||
|
|
||||||
# Append additional project IDs to the existing list
|
# Append additional project IDs to the existing list
|
||||||
unique_project_ids.update(additional_projects.values_list("id", flat=True))
|
unique_project_ids.update(
|
||||||
|
additional_projects.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
list(unique_project_ids)[:4],
|
list(unique_project_ids)[:4],
|
||||||
@ -512,90 +520,99 @@ def dashboard_recent_projects(self, request, slug):
|
|||||||
|
|
||||||
|
|
||||||
def dashboard_recent_collaborators(self, request, slug):
|
def dashboard_recent_collaborators(self, request, slug):
|
||||||
# Fetch all project IDs where the user belongs to
|
# Subquery to count activities for each project member
|
||||||
user_projects = Project.objects.filter(
|
activity_count_subquery = (
|
||||||
project_projectmember__member=request.user,
|
|
||||||
project_projectmember__is_active=True,
|
|
||||||
workspace__slug=slug,
|
|
||||||
).values_list("id", flat=True)
|
|
||||||
|
|
||||||
# Fetch all users who have performed an activity in the projects where the user exists
|
|
||||||
users_with_activities = (
|
|
||||||
IssueActivity.objects.filter(
|
IssueActivity.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id__in=user_projects,
|
actor=OuterRef("member"),
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True,
|
||||||
)
|
)
|
||||||
.values("actor")
|
.values("actor")
|
||||||
.exclude(actor=request.user)
|
.annotate(num_activities=Count("pk"))
|
||||||
.annotate(num_activities=Count("actor"))
|
.values("num_activities")
|
||||||
.order_by("-num_activities")
|
|
||||||
)[:7]
|
|
||||||
|
|
||||||
# Get the count of active issues for each user in users_with_activities
|
|
||||||
users_with_active_issues = []
|
|
||||||
for user_activity in users_with_activities:
|
|
||||||
user_id = user_activity["actor"]
|
|
||||||
active_issue_count = Issue.objects.filter(
|
|
||||||
assignees__in=[user_id],
|
|
||||||
state__group__in=["unstarted", "started"],
|
|
||||||
).count()
|
|
||||||
users_with_active_issues.append(
|
|
||||||
{"user_id": user_id, "active_issue_count": active_issue_count}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert the logged-in user's ID and their active issue count at the beginning
|
|
||||||
active_issue_count = Issue.objects.filter(
|
|
||||||
assignees__in=[request.user],
|
|
||||||
state__group__in=["unstarted", "started"],
|
|
||||||
).count()
|
|
||||||
|
|
||||||
if users_with_activities.count() < 7:
|
|
||||||
# Calculate the additional collaborators needed
|
|
||||||
additional_collaborators_needed = 7 - users_with_activities.count()
|
|
||||||
|
|
||||||
# Fetch additional collaborators from the project_member table
|
|
||||||
additional_collaborators = list(
|
|
||||||
set(
|
|
||||||
ProjectMember.objects.filter(
|
|
||||||
~Q(member=request.user),
|
|
||||||
project_id__in=user_projects,
|
|
||||||
workspace__slug=slug,
|
|
||||||
)
|
|
||||||
.exclude(
|
|
||||||
member__in=[
|
|
||||||
user["actor"] for user in users_with_activities
|
|
||||||
]
|
|
||||||
)
|
|
||||||
.values_list("member", flat=True)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
additional_collaborators = additional_collaborators[
|
|
||||||
:additional_collaborators_needed
|
|
||||||
]
|
|
||||||
|
|
||||||
# Append additional collaborators to the list
|
|
||||||
for collaborator_id in additional_collaborators:
|
|
||||||
active_issue_count = Issue.objects.filter(
|
|
||||||
assignees__in=[collaborator_id],
|
|
||||||
state__group__in=["unstarted", "started"],
|
|
||||||
).count()
|
|
||||||
users_with_active_issues.append(
|
|
||||||
{
|
|
||||||
"user_id": str(collaborator_id),
|
|
||||||
"active_issue_count": active_issue_count,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
users_with_active_issues.insert(
|
|
||||||
0,
|
|
||||||
{"user_id": request.user.id, "active_issue_count": active_issue_count},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(users_with_active_issues, status=status.HTTP_200_OK)
|
# 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=self.get_results_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DashboardEndpoint(BaseAPIView):
|
class DashboardEndpoint(BaseAPIView):
|
||||||
|
def get_results_controller(self, project_members_with_activities):
|
||||||
|
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",
|
||||||
|
],
|
||||||
|
then=1,
|
||||||
|
),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.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):
|
def create(self, request, slug):
|
||||||
serializer = DashboardSerializer(data=request.data)
|
serializer = DashboardSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
@ -622,7 +639,9 @@ class DashboardEndpoint(BaseAPIView):
|
|||||||
dashboard_type = request.GET.get("dashboard_type", None)
|
dashboard_type = request.GET.get("dashboard_type", None)
|
||||||
if dashboard_type == "home":
|
if dashboard_type == "home":
|
||||||
dashboard, created = Dashboard.objects.get_or_create(
|
dashboard, created = Dashboard.objects.get_or_create(
|
||||||
type_identifier=dashboard_type, owned_by=request.user, is_default=True
|
type_identifier=dashboard_type,
|
||||||
|
owned_by=request.user,
|
||||||
|
is_default=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@ -639,7 +658,9 @@ class DashboardEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
updated_dashboard_widgets = []
|
updated_dashboard_widgets = []
|
||||||
for widget_key in widgets_to_fetch:
|
for widget_key in widgets_to_fetch:
|
||||||
widget = Widget.objects.filter(key=widget_key).values_list("id", flat=True)
|
widget = Widget.objects.filter(
|
||||||
|
key=widget_key
|
||||||
|
).values_list("id", flat=True)
|
||||||
if widget:
|
if widget:
|
||||||
updated_dashboard_widgets.append(
|
updated_dashboard_widgets.append(
|
||||||
DashboardWidget(
|
DashboardWidget(
|
5
apiserver/plane/app/views/error_404.py
Normal file
5
apiserver/plane/app/views/error_404.py
Normal file
@ -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)
|
@ -3,7 +3,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseViewSet, BaseAPIView
|
from ..base import BaseViewSet, BaseAPIView
|
||||||
from plane.app.permissions import ProjectEntityPermission
|
from plane.app.permissions import ProjectEntityPermission
|
||||||
from plane.db.models import Project, Estimate, EstimatePoint
|
from plane.db.models import Project, Estimate, EstimatePoint
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
@ -11,7 +11,7 @@ from plane.app.serializers import (
|
|||||||
EstimatePointSerializer,
|
EstimatePointSerializer,
|
||||||
EstimateReadSerializer,
|
EstimateReadSerializer,
|
||||||
)
|
)
|
||||||
|
from plane.utils.cache import invalidate_cache
|
||||||
|
|
||||||
class ProjectEstimatePointEndpoint(BaseAPIView):
|
class ProjectEstimatePointEndpoint(BaseAPIView):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
@ -49,6 +49,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
|||||||
serializer = EstimateReadSerializer(estimates, many=True)
|
serializer = EstimateReadSerializer(estimates, many=True)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
|
||||||
def create(self, request, slug, project_id):
|
def create(self, request, slug, project_id):
|
||||||
if not request.data.get("estimate", False):
|
if not request.data.get("estimate", False):
|
||||||
return Response(
|
return Response(
|
||||||
@ -114,6 +115,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
|||||||
status=status.HTTP_200_OK,
|
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):
|
def partial_update(self, request, slug, project_id, estimate_id):
|
||||||
if not request.data.get("estimate", False):
|
if not request.data.get("estimate", False):
|
||||||
return Response(
|
return Response(
|
||||||
@ -182,6 +184,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
|||||||
status=status.HTTP_200_OK,
|
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):
|
def destroy(self, request, slug, project_id, estimate_id):
|
||||||
estimate = Estimate.objects.get(
|
estimate = Estimate.objects.get(
|
||||||
pk=estimate_id, workspace__slug=slug, project_id=project_id
|
pk=estimate_id, workspace__slug=slug, project_id=project_id
|
@ -3,7 +3,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from . import BaseAPIView
|
from .. import BaseAPIView
|
||||||
from plane.app.permissions import WorkSpaceAdminPermission
|
from plane.app.permissions import WorkSpaceAdminPermission
|
||||||
from plane.bgtasks.export_task import issue_export_task
|
from plane.bgtasks.export_task import issue_export_task
|
||||||
from plane.db.models import Project, ExporterHistory, Workspace
|
from plane.db.models import Project, ExporterHistory, Workspace
|
||||||
@ -29,7 +29,10 @@ class ExportIssuesEndpoint(BaseAPIView):
|
|||||||
if provider in ["csv", "xlsx", "json"]:
|
if provider in ["csv", "xlsx", "json"]:
|
||||||
if not project_ids:
|
if not project_ids:
|
||||||
project_ids = Project.objects.filter(
|
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)
|
).values_list("id", flat=True)
|
||||||
project_ids = [str(project_id) for project_id in project_ids]
|
project_ids = [str(project_id) for project_id in project_ids]
|
||||||
|
|
||||||
@ -50,7 +53,7 @@ class ExportIssuesEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
return Response(
|
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,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
@ -8,17 +8,15 @@ from rest_framework.response import Response
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseAPIView
|
from ..base import BaseAPIView
|
||||||
from plane.app.permissions import ProjectEntityPermission
|
from plane.app.permissions import ProjectEntityPermission
|
||||||
from plane.db.models import Workspace, Project
|
from plane.db.models import Workspace, Project
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
ProjectLiteSerializer,
|
ProjectLiteSerializer,
|
||||||
WorkspaceLiteSerializer,
|
WorkspaceLiteSerializer,
|
||||||
)
|
)
|
||||||
from plane.utils.integrations.github import get_release_notes
|
|
||||||
from plane.license.utils.instance_value import get_configuration_value
|
from plane.license.utils.instance_value import get_configuration_value
|
||||||
|
|
||||||
|
|
||||||
@ -85,12 +83,6 @@ 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):
|
class UnsplashEndpoint(BaseAPIView):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
(UNSPLASH_ACCESS_KEY,) = get_configuration_value(
|
(UNSPLASH_ACCESS_KEY,) = get_configuration_value(
|
@ -1,558 +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,
|
|
||||||
is_allowed_hostname,
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
|
|
||||||
cloud_hostname = metadata.get("cloud_hostname", False)
|
|
||||||
|
|
||||||
if not cloud_hostname:
|
|
||||||
return Response(
|
|
||||||
{"error": "Cloud hostname is required"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_allowed_hostname(cloud_hostname):
|
|
||||||
return Response(
|
|
||||||
{"error": "Hostname is not a valid hostname."},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
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", "<p></p>"
|
|
||||||
),
|
|
||||||
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", "<p></p>"),
|
|
||||||
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,
|
|
||||||
)
|
|
@ -15,7 +15,7 @@ from rest_framework import status
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseViewSet
|
from ..base import BaseViewSet
|
||||||
from plane.app.permissions import ProjectBasePermission, ProjectLitePermission
|
from plane.app.permissions import ProjectBasePermission, ProjectLitePermission
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Inbox,
|
Inbox,
|
||||||
@ -146,7 +146,8 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
ArrayAgg(
|
ArrayAgg(
|
||||||
"assignees__id",
|
"assignees__id",
|
||||||
distinct=True,
|
distinct=True,
|
||||||
filter=~Q(assignees__id__isnull=True),
|
filter=~Q(assignees__id__isnull=True)
|
||||||
|
& Q(assignees__member_project__is_active=True),
|
||||||
),
|
),
|
||||||
Value([], output_field=ArrayField(UUIDField())),
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
),
|
),
|
||||||
@ -213,7 +214,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check for valid priority
|
# 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",
|
"low",
|
||||||
"medium",
|
"medium",
|
||||||
"high",
|
"high",
|
||||||
@ -428,8 +429,11 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
if issue is None:
|
if issue is None:
|
||||||
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
|
return Response(
|
||||||
|
{"error": "Requested object was not found"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
serializer = IssueDetailSerializer(issue)
|
serializer = IssueDetailSerializer(issue)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
@ -1,9 +0,0 @@
|
|||||||
from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
|
|
||||||
from .github import (
|
|
||||||
GithubRepositorySyncViewSet,
|
|
||||||
GithubIssueSyncViewSet,
|
|
||||||
BulkCreateGithubIssueSyncEndpoint,
|
|
||||||
GithubCommentSyncViewSet,
|
|
||||||
GithubRepositoriesEndpoint,
|
|
||||||
)
|
|
||||||
from .slack import SlackProjectSyncViewSet
|
|
@ -1,181 +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)
|
|
@ -1,202 +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"),
|
|
||||||
)
|
|
@ -1,96 +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,
|
|
||||||
project__project_projectmember__is_active=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
File diff suppressed because it is too large
Load Diff
87
apiserver/plane/app/views/issue/activity.py
Normal file
87
apiserver/plane/app/views/issue/activity.py
Normal file
@ -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)
|
348
apiserver/plane/app/views/issue/archive.py
Normal file
348
apiserver/plane/app/views/issue/archive.py
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
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)
|
73
apiserver/plane/app/views/issue/attachment.py
Normal file
73
apiserver/plane/app/views/issue/attachment.py
Normal file
@ -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)
|
661
apiserver/plane/app/views/issue/base.py
Normal file
661
apiserver/plane/app/views/issue/base.py
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
# 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,
|
||||||
|
)
|
||||||
|
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 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, WebhookMixin
|
||||||
|
from plane.app.serializers import (
|
||||||
|
IssuePropertySerializer,
|
||||||
|
IssueSerializer,
|
||||||
|
IssueCreateSerializer,
|
||||||
|
IssueDetailSerializer,
|
||||||
|
)
|
||||||
|
from plane.app.permissions import (
|
||||||
|
ProjectEntityPermission,
|
||||||
|
ProjectLitePermission,
|
||||||
|
)
|
||||||
|
from plane.db.models import (
|
||||||
|
Project,
|
||||||
|
Issue,
|
||||||
|
IssueProperty,
|
||||||
|
IssueLink,
|
||||||
|
IssueAttachment,
|
||||||
|
IssueSubscriber,
|
||||||
|
IssueReaction,
|
||||||
|
)
|
||||||
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
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.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",
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
221
apiserver/plane/app/views/issue/comment.py
Normal file
221
apiserver/plane/app/views/issue/comment.py
Normal file
@ -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, WebhookMixin
|
||||||
|
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(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,
|
||||||
|
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)
|
365
apiserver/plane/app/views/issue/draft.py
Normal file
365
apiserver/plane/app/views/issue/draft.py
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
# 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
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
)
|
||||||
|
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)
|
105
apiserver/plane/app/views/issue/label.py
Normal file
105
apiserver/plane/app/views/issue/label.py
Normal file
@ -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,
|
||||||
|
)
|
121
apiserver/plane/app/views/issue/link.py
Normal file
121
apiserver/plane/app/views/issue/link.py
Normal file
@ -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)
|
90
apiserver/plane/app/views/issue/reaction.py
Normal file
90
apiserver/plane/app/views/issue/reaction.py
Normal file
@ -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)
|
205
apiserver/plane/app/views/issue/relation.py
Normal file
205
apiserver/plane/app/views/issue/relation.py
Normal file
@ -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)
|
196
apiserver/plane/app/views/issue/sub_issue.py
Normal file
196
apiserver/plane/app/views/issue/sub_issue.py
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
# 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 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",
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
125
apiserver/plane/app/views/issue/subscriber.py
Normal file
125
apiserver/plane/app/views/issue/subscriber.py
Normal file
@ -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
|
||||||
|
)
|
@ -1,51 +1,57 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import json
|
import json
|
||||||
|
|
||||||
# Django Imports
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, 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.aggregates import ArrayAgg
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.db.models import Value, UUIDField
|
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.db.models.functions import Coalesce
|
||||||
|
|
||||||
|
# Django Imports
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
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,
|
|
||||||
IssueSerializer,
|
|
||||||
ModuleUserPropertiesSerializer,
|
|
||||||
ModuleDetailSerializer,
|
|
||||||
)
|
|
||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
ProjectLitePermission,
|
ProjectLitePermission,
|
||||||
)
|
)
|
||||||
from plane.db.models import (
|
from plane.app.serializers import (
|
||||||
Module,
|
ModuleDetailSerializer,
|
||||||
ModuleIssue,
|
ModuleFavoriteSerializer,
|
||||||
Project,
|
ModuleLinkSerializer,
|
||||||
Issue,
|
ModuleSerializer,
|
||||||
ModuleLink,
|
ModuleUserPropertiesSerializer,
|
||||||
ModuleFavorite,
|
ModuleWriteSerializer,
|
||||||
IssueLink,
|
|
||||||
IssueAttachment,
|
|
||||||
ModuleUserProperties,
|
|
||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.db.models import (
|
||||||
|
Issue,
|
||||||
|
Module,
|
||||||
|
ModuleFavorite,
|
||||||
|
ModuleIssue,
|
||||||
|
ModuleLink,
|
||||||
|
ModuleUserProperties,
|
||||||
|
Project,
|
||||||
|
)
|
||||||
from plane.utils.analytics_plot import burndown_plot
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
||||||
|
|
||||||
|
|
||||||
class ModuleViewSet(WebhookMixin, BaseViewSet):
|
class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||||
model = Module
|
model = Module
|
||||||
@ -68,6 +74,59 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project_id=self.kwargs.get("project_id"),
|
project_id=self.kwargs.get("project_id"),
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
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 (
|
return (
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
@ -87,62 +146,39 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
completed_issues=Coalesce(
|
||||||
"issue_module",
|
Subquery(completed_issues[:1]),
|
||||||
filter=Q(
|
Value(0, output_field=IntegerField()),
|
||||||
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(
|
.annotate(
|
||||||
cancelled_issues=Count(
|
cancelled_issues=Coalesce(
|
||||||
"issue_module__issue__state__group",
|
Subquery(cancelled_issues[:1]),
|
||||||
filter=Q(
|
Value(0, output_field=IntegerField()),
|
||||||
issue_module__issue__state__group="cancelled",
|
|
||||||
issue_module__issue__archived_at__isnull=True,
|
|
||||||
issue_module__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
started_issues=Count(
|
started_issues=Coalesce(
|
||||||
"issue_module__issue__state__group",
|
Subquery(started_issues[:1]),
|
||||||
filter=Q(
|
Value(0, output_field=IntegerField()),
|
||||||
issue_module__issue__state__group="started",
|
|
||||||
issue_module__issue__archived_at__isnull=True,
|
|
||||||
issue_module__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
unstarted_issues=Count(
|
unstarted_issues=Coalesce(
|
||||||
"issue_module__issue__state__group",
|
Subquery(unstarted_issues[:1]),
|
||||||
filter=Q(
|
Value(0, output_field=IntegerField()),
|
||||||
issue_module__issue__state__group="unstarted",
|
|
||||||
issue_module__issue__archived_at__isnull=True,
|
|
||||||
issue_module__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
backlog_issues=Count(
|
backlog_issues=Coalesce(
|
||||||
"issue_module__issue__state__group",
|
Subquery(backlog_issues[:1]),
|
||||||
filter=Q(
|
Value(0, output_field=IntegerField()),
|
||||||
issue_module__issue__state__group="backlog",
|
)
|
||||||
issue_module__issue__archived_at__isnull=True,
|
)
|
||||||
issue_module__issue__is_draft=False,
|
.annotate(
|
||||||
),
|
total_issues=Coalesce(
|
||||||
|
Subquery(total_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
@ -190,9 +226,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"external_id",
|
"external_id",
|
||||||
# computed fields
|
# computed fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
|
||||||
"cancelled_issues",
|
"cancelled_issues",
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
|
"total_issues",
|
||||||
"started_issues",
|
"started_issues",
|
||||||
"unstarted_issues",
|
"unstarted_issues",
|
||||||
"backlog_issues",
|
"backlog_issues",
|
||||||
@ -204,7 +240,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def list(self, request, slug, project_id):
|
def list(self, request, slug, project_id):
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||||
if self.fields:
|
if self.fields:
|
||||||
modules = ModuleSerializer(
|
modules = ModuleSerializer(
|
||||||
queryset,
|
queryset,
|
||||||
@ -231,8 +267,8 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"external_source",
|
"external_source",
|
||||||
"external_id",
|
"external_id",
|
||||||
# computed fields
|
# computed fields
|
||||||
"is_favorite",
|
|
||||||
"total_issues",
|
"total_issues",
|
||||||
|
"is_favorite",
|
||||||
"cancelled_issues",
|
"cancelled_issues",
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
"started_issues",
|
"started_issues",
|
||||||
@ -244,7 +280,21 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
return Response(modules, status=status.HTTP_200_OK)
|
return Response(modules, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk):
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
queryset = self.get_queryset().filter(pk=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 = (
|
assignee_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
@ -345,9 +395,11 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"completion_chart": {},
|
"completion_chart": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
if queryset.first().start_date and queryset.first().target_date:
|
# Fetch the modules
|
||||||
|
modules = queryset.first()
|
||||||
|
if modules and modules.start_date and modules.target_date:
|
||||||
data["distribution"]["completion_chart"] = burndown_plot(
|
data["distribution"]["completion_chart"] = burndown_plot(
|
||||||
queryset=queryset.first(),
|
queryset=modules,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
module_id=pk,
|
module_id=pk,
|
||||||
@ -359,14 +411,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def partial_update(self, request, slug, project_id, pk):
|
def partial_update(self, request, slug, project_id, pk):
|
||||||
queryset = self.get_queryset().filter(pk=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,
|
||||||
|
)
|
||||||
serializer = ModuleWriteSerializer(
|
serializer = ModuleWriteSerializer(
|
||||||
queryset.first(), data=request.data, partial=True
|
module.first(), data=request.data, partial=True
|
||||||
)
|
)
|
||||||
|
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
module = queryset.values(
|
module = module.values(
|
||||||
# Required fields
|
# Required fields
|
||||||
"id",
|
"id",
|
||||||
"workspace_id",
|
"workspace_id",
|
||||||
@ -387,10 +445,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"external_id",
|
"external_id",
|
||||||
# computed fields
|
# computed fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
|
||||||
"cancelled_issues",
|
"cancelled_issues",
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
"started_issues",
|
"started_issues",
|
||||||
|
"total_issues",
|
||||||
"unstarted_issues",
|
"unstarted_issues",
|
||||||
"backlog_issues",
|
"backlog_issues",
|
||||||
"created_at",
|
"created_at",
|
||||||
@ -426,232 +484,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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 (
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
|
|
||||||
# create multiple module inside an issue
|
|
||||||
def create_issue_modules(self, request, slug, project_id, issue_id):
|
|
||||||
modules = request.data.get("modules", [])
|
|
||||||
if not modules:
|
|
||||||
return Response(
|
|
||||||
{"error": "Modules are required"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
project = Project.objects.get(pk=project_id)
|
|
||||||
_ = 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
|
|
||||||
]
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class ModuleLinkViewSet(BaseViewSet):
|
class ModuleLinkViewSet(BaseViewSet):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
@ -676,12 +508,174 @@ class ModuleLinkViewSet(BaseViewSet):
|
|||||||
.filter(
|
.filter(
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True,
|
||||||
)
|
)
|
||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||||
|
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
favorite_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 (
|
||||||
|
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(archived_at__isnull=False)
|
||||||
|
.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(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.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):
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
return Response(modules, 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
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
class ModuleFavoriteViewSet(BaseViewSet):
|
class ModuleFavoriteViewSet(BaseViewSet):
|
||||||
serializer_class = ModuleFavoriteSerializer
|
serializer_class = ModuleFavoriteSerializer
|
||||||
model = ModuleFavorite
|
model = ModuleFavorite
|
260
apiserver/plane/app/views/module/issue.py
Normal file
260
apiserver/plane/app/views/module/issue.py
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
# 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, WebhookMixin
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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 (
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# create multiple module inside an issue
|
||||||
|
def create_issue_modules(self, request, slug, project_id, issue_id):
|
||||||
|
modules = request.data.get("modules", [])
|
||||||
|
if not modules:
|
||||||
|
return Response(
|
||||||
|
{"error": "Modules are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
project = Project.objects.get(pk=project_id)
|
||||||
|
_ = 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
|
||||||
|
]
|
||||||
|
|
||||||
|
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)
|
@ -8,7 +8,7 @@ from rest_framework.response import Response
|
|||||||
from plane.utils.paginator import BasePaginator
|
from plane.utils.paginator import BasePaginator
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseViewSet, BaseAPIView
|
from ..base import BaseViewSet, BaseAPIView
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Notification,
|
Notification,
|
||||||
IssueAssignee,
|
IssueAssignee,
|
||||||
@ -17,7 +17,10 @@ from plane.db.models import (
|
|||||||
WorkspaceMember,
|
WorkspaceMember,
|
||||||
UserNotificationPreference,
|
UserNotificationPreference,
|
||||||
)
|
)
|
||||||
from plane.app.serializers import NotificationSerializer, UserNotificationPreferenceSerializer
|
from plane.app.serializers import (
|
||||||
|
NotificationSerializer,
|
||||||
|
UserNotificationPreferenceSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NotificationViewSet(BaseViewSet, BasePaginator):
|
class NotificationViewSet(BaseViewSet, BasePaginator):
|
@ -5,7 +5,6 @@ import os
|
|||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
# Third Party modules
|
# Third Party modules
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -250,9 +249,11 @@ class OauthEndpoint(BaseAPIView):
|
|||||||
[
|
[
|
||||||
WorkspaceMember(
|
WorkspaceMember(
|
||||||
workspace_id=project_member_invite.workspace_id,
|
workspace_id=project_member_invite.workspace_id,
|
||||||
role=project_member_invite.role
|
role=(
|
||||||
if project_member_invite.role in [5, 10, 15]
|
project_member_invite.role
|
||||||
else 15,
|
if project_member_invite.role in [5, 10, 15]
|
||||||
|
else 15
|
||||||
|
),
|
||||||
member=user,
|
member=user,
|
||||||
created_by_id=project_member_invite.created_by_id,
|
created_by_id=project_member_invite.created_by_id,
|
||||||
)
|
)
|
||||||
@ -266,9 +267,11 @@ class OauthEndpoint(BaseAPIView):
|
|||||||
[
|
[
|
||||||
ProjectMember(
|
ProjectMember(
|
||||||
workspace_id=project_member_invite.workspace_id,
|
workspace_id=project_member_invite.workspace_id,
|
||||||
role=project_member_invite.role
|
role=(
|
||||||
if project_member_invite.role in [5, 10, 15]
|
project_member_invite.role
|
||||||
else 15,
|
if project_member_invite.role in [5, 10, 15]
|
||||||
|
else 15
|
||||||
|
),
|
||||||
member=user,
|
member=user,
|
||||||
created_by_id=project_member_invite.created_by_id,
|
created_by_id=project_member_invite.created_by_id,
|
||||||
)
|
)
|
||||||
@ -391,9 +394,11 @@ class OauthEndpoint(BaseAPIView):
|
|||||||
[
|
[
|
||||||
WorkspaceMember(
|
WorkspaceMember(
|
||||||
workspace_id=project_member_invite.workspace_id,
|
workspace_id=project_member_invite.workspace_id,
|
||||||
role=project_member_invite.role
|
role=(
|
||||||
if project_member_invite.role in [5, 10, 15]
|
project_member_invite.role
|
||||||
else 15,
|
if project_member_invite.role in [5, 10, 15]
|
||||||
|
else 15
|
||||||
|
),
|
||||||
member=user,
|
member=user,
|
||||||
created_by_id=project_member_invite.created_by_id,
|
created_by_id=project_member_invite.created_by_id,
|
||||||
)
|
)
|
||||||
@ -407,9 +412,11 @@ class OauthEndpoint(BaseAPIView):
|
|||||||
[
|
[
|
||||||
ProjectMember(
|
ProjectMember(
|
||||||
workspace_id=project_member_invite.workspace_id,
|
workspace_id=project_member_invite.workspace_id,
|
||||||
role=project_member_invite.role
|
role=(
|
||||||
if project_member_invite.role in [5, 10, 15]
|
project_member_invite.role
|
||||||
else 15,
|
if project_member_invite.role in [5, 10, 15]
|
||||||
|
else 15
|
||||||
|
),
|
||||||
member=user,
|
member=user,
|
||||||
created_by_id=project_member_invite.created_by_id,
|
created_by_id=project_member_invite.created_by_id,
|
||||||
)
|
)
|
||||||
|
@ -1,25 +1,32 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import datetime
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from plane.app.permissions import ProjectEntityPermission
|
from plane.app.permissions import ProjectEntityPermission
|
||||||
from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer,
|
from plane.app.serializers import (
|
||||||
PageLogSerializer, PageSerializer,
|
PageFavoriteSerializer,
|
||||||
SubPageSerializer)
|
PageLogSerializer,
|
||||||
from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page,
|
PageSerializer,
|
||||||
PageFavorite, PageLog, ProjectMember)
|
SubPageSerializer,
|
||||||
|
)
|
||||||
|
from plane.db.models import (
|
||||||
|
Page,
|
||||||
|
PageFavorite,
|
||||||
|
PageLog,
|
||||||
|
ProjectMember,
|
||||||
|
)
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseAPIView, BaseViewSet
|
from ..base import BaseAPIView, BaseViewSet
|
||||||
|
|
||||||
|
|
||||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||||
@ -63,6 +70,7 @@ class PageViewSet(BaseViewSet):
|
|||||||
.filter(
|
.filter(
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True,
|
||||||
)
|
)
|
||||||
.filter(parent__isnull=True)
|
.filter(parent__isnull=True)
|
||||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
File diff suppressed because it is too large
Load Diff
652
apiserver/plane/app/views/project/base.py
Normal file
652
apiserver/plane/app/views/project/base.py
Normal file
@ -0,0 +1,652 @@
|
|||||||
|
# Python imports
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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, WebhookMixin
|
||||||
|
from plane.app.serializers import (
|
||||||
|
ProjectSerializer,
|
||||||
|
ProjectListSerializer,
|
||||||
|
ProjectFavoriteSerializer,
|
||||||
|
ProjectDeployBoardSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
from plane.app.permissions import (
|
||||||
|
ProjectBasePermission,
|
||||||
|
ProjectMemberPermission,
|
||||||
|
)
|
||||||
|
|
||||||
|
from plane.db.models import (
|
||||||
|
Project,
|
||||||
|
ProjectMember,
|
||||||
|
Workspace,
|
||||||
|
State,
|
||||||
|
ProjectFavorite,
|
||||||
|
ProjectIdentifier,
|
||||||
|
Module,
|
||||||
|
Cycle,
|
||||||
|
Inbox,
|
||||||
|
ProjectDeployBoard,
|
||||||
|
IssueProperty,
|
||||||
|
Issue,
|
||||||
|
)
|
||||||
|
from plane.utils.cache import cache_response
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectViewSet(WebhookMixin, 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(
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.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"),
|
||||||
|
parent__isnull=True,
|
||||||
|
)
|
||||||
|
.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,
|
||||||
|
parent__isnull=True,
|
||||||
|
)
|
||||||
|
.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,
|
||||||
|
parent__isnull=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()
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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="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:
|
||||||
|
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):
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Cache the below api for 24 hours
|
||||||
|
@cache_response(60 * 60 * 24, user=False)
|
||||||
|
def get(self, request):
|
||||||
|
files = []
|
||||||
|
s3 = boto3.client(
|
||||||
|
"s3",
|
||||||
|
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||||
|
)
|
||||||
|
params = {
|
||||||
|
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||||
|
"Prefix": "static/project-cover/",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = s3.list_objects_v2(**params)
|
||||||
|
# Extracting file keys from the response
|
||||||
|
if "Contents" in response:
|
||||||
|
for content in response["Contents"]:
|
||||||
|
if not content["Key"].endswith(
|
||||||
|
"/"
|
||||||
|
): # This line ensures we're only getting files, not "sub-folders"
|
||||||
|
files.append(
|
||||||
|
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(files, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectDeployBoardViewSet(BaseViewSet):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectMemberPermission,
|
||||||
|
]
|
||||||
|
serializer_class = ProjectDeployBoardSerializer
|
||||||
|
model = ProjectDeployBoard
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return (
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.filter(
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(self, request, slug, project_id):
|
||||||
|
comments = request.data.get("comments", False)
|
||||||
|
reactions = request.data.get("reactions", False)
|
||||||
|
inbox = request.data.get("inbox", None)
|
||||||
|
votes = request.data.get("votes", False)
|
||||||
|
views = request.data.get(
|
||||||
|
"views",
|
||||||
|
{
|
||||||
|
"list": True,
|
||||||
|
"kanban": True,
|
||||||
|
"calendar": True,
|
||||||
|
"gantt": True,
|
||||||
|
"spreadsheet": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create(
|
||||||
|
anchor=f"{slug}/{project_id}",
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
project_deploy_board.comments = comments
|
||||||
|
project_deploy_board.reactions = reactions
|
||||||
|
project_deploy_board.inbox = inbox
|
||||||
|
project_deploy_board.votes = votes
|
||||||
|
project_deploy_board.views = views
|
||||||
|
|
||||||
|
project_deploy_board.save()
|
||||||
|
|
||||||
|
serializer = ProjectDeployBoardSerializer(project_deploy_board)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
286
apiserver/plane/app/views/project/invite.py
Normal file
286
apiserver/plane/app/views/project/invite.py
Normal file
@ -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)
|
349
apiserver/plane/app/views/project/member.py
Normal file
349
apiserver/plane/app/views/project/member.py
Normal file
@ -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)
|
@ -50,6 +50,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
q,
|
q,
|
||||||
project_projectmember__member=self.request.user,
|
project_projectmember__member=self.request.user,
|
||||||
project_projectmember__is_active=True,
|
project_projectmember__is_active=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
.distinct()
|
.distinct()
|
||||||
@ -72,6 +73,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -97,6 +99,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -121,6 +124,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -145,6 +149,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -169,6 +174,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -235,6 +241,7 @@ class IssueSearchEndpoint(BaseAPIView):
|
|||||||
cycle = request.query_params.get("cycle", "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")
|
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)
|
issue_id = request.query_params.get("issue_id", False)
|
||||||
|
|
||||||
@ -242,6 +249,7 @@ class IssueSearchEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True
|
||||||
)
|
)
|
||||||
|
|
||||||
if workspace_search == "false":
|
if workspace_search == "false":
|
||||||
@ -253,7 +261,8 @@ class IssueSearchEndpoint(BaseAPIView):
|
|||||||
if parent == "true" and issue_id:
|
if parent == "true" and issue_id:
|
||||||
issue = Issue.issue_objects.get(pk=issue_id)
|
issue = Issue.issue_objects.get(pk=issue_id)
|
||||||
issues = issues.filter(
|
issues = issues.filter(
|
||||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id))
|
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
|
||||||
|
)
|
||||||
if issue_relation == "true" and issue_id:
|
if issue_relation == "true" and issue_id:
|
||||||
issue = Issue.issue_objects.get(pk=issue_id)
|
issue = Issue.issue_objects.get(pk=issue_id)
|
||||||
issues = issues.filter(
|
issues = issues.filter(
|
||||||
@ -273,6 +282,9 @@ class IssueSearchEndpoint(BaseAPIView):
|
|||||||
if module:
|
if module:
|
||||||
issues = issues.exclude(issue_module__module=module)
|
issues = issues.exclude(issue_module__module=module)
|
||||||
|
|
||||||
|
if target_date == "none":
|
||||||
|
issues = issues.filter(target_date__isnull=True)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
issues.values(
|
issues.values(
|
||||||
"name",
|
"name",
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user