mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of github.com:makeplane/plane into dev/litellm
This commit is contained in:
commit
66edd3c962
@ -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"
|
10
.github/workflows/build-branch.yml
vendored
10
.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]
|
||||||
|
|
||||||
@ -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.
|
||||||
|
15
Dockerfile
15
Dockerfile
@ -27,7 +27,7 @@ RUN yarn install
|
|||||||
COPY --from=builder /app/out/full/ .
|
COPY --from=builder /app/out/full/ .
|
||||||
COPY turbo.json turbo.json
|
COPY turbo.json turbo.json
|
||||||
COPY replace-env-vars.sh /usr/local/bin/
|
COPY replace-env-vars.sh /usr/local/bin/
|
||||||
USER root
|
|
||||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||||
|
|
||||||
RUN yarn turbo run build
|
RUN yarn turbo run build
|
||||||
@ -89,21 +89,17 @@ RUN chmod -R 777 /code
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Don't run production as root
|
|
||||||
RUN addgroup --system --gid 1001 plane
|
|
||||||
RUN adduser --system --uid 1001 captain
|
|
||||||
|
|
||||||
COPY --from=installer /app/apps/app/next.config.js .
|
COPY --from=installer /app/apps/app/next.config.js .
|
||||||
COPY --from=installer /app/apps/app/package.json .
|
COPY --from=installer /app/apps/app/package.json .
|
||||||
COPY --from=installer /app/apps/space/next.config.js .
|
COPY --from=installer /app/apps/space/next.config.js .
|
||||||
COPY --from=installer /app/apps/space/package.json .
|
COPY --from=installer /app/apps/space/package.json .
|
||||||
|
|
||||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
|
COPY --from=installer /app/apps/app/.next/standalone ./
|
||||||
|
|
||||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
|
COPY --from=installer /app/apps/app/.next/static ./apps/app/.next/static
|
||||||
|
|
||||||
COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./
|
COPY --from=installer /app/apps/space/.next/standalone ./
|
||||||
COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next
|
COPY --from=installer /app/apps/space/.next ./apps/space/.next
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
@ -118,7 +114,6 @@ ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
|||||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||||
|
|
||||||
USER root
|
|
||||||
COPY replace-env-vars.sh /usr/local/bin/
|
COPY replace-env-vars.sh /usr/local/bin/
|
||||||
COPY start.sh /usr/local/bin/
|
COPY start.sh /usr/local/bin/
|
||||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
155
README.md
155
README.md
@ -7,7 +7,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 align="center"><b>Plane</b></h3>
|
<h3 align="center"><b>Plane</b></h3>
|
||||||
<p align="center"><b>Flexible, extensible open-source project management</b></p>
|
<p align="center"><b>Open-source project management that unlocks customer value.</b></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.com/invite/A92xrEGCge">
|
<a href="https://discord.com/invite/A92xrEGCge">
|
||||||
@ -16,6 +16,13 @@
|
|||||||
<img alt="Commit activity per month" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
<img alt="Commit activity per month" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://dub.sh/plane-website-readme"><b>Website</b></a> •
|
||||||
|
<a href="https://git.new/releases"><b>Releases</b></a> •
|
||||||
|
<a href="https://dub.sh/planepowershq"><b>Twitter</b></a> •
|
||||||
|
<a href="https://dub.sh/planedocs"><b>Documentation</b></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
|
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
|
||||||
<img
|
<img
|
||||||
@ -33,56 +40,90 @@
|
|||||||
</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.
|
||||||
|
|
||||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
|
## ⚡ Installation
|
||||||
|
|
||||||
## ⚡️ Contributors Quick Start
|
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users.
|
||||||
|
|
||||||
### Prerequisite
|
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).
|
||||||
|
|
||||||
Development system must have docker engine installed and running.
|
| 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/self-hosting/methods/docker-compose) |
|
||||||
|
| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://docs.plane.so/kubernetes) |
|
||||||
|
|
||||||
### Steps
|
`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature.
|
||||||
|
|
||||||
Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute
|
|
||||||
|
|
||||||
1. Clone the code locally using `git clone https://github.com/makeplane/plane.git`
|
|
||||||
1. Switch to the code folder `cd plane`
|
|
||||||
1. Create your feature or fix branch you plan to work on using `git checkout -b <feature-branch-name>`
|
|
||||||
1. Open terminal and run `./setup.sh`
|
|
||||||
1. Open the code on VSCode or similar equivalent IDE
|
|
||||||
1. Review the `.env` files available in various folders. Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system
|
|
||||||
1. Run the docker command to initiate various services `docker compose -f docker-compose-local.yml up -d`
|
|
||||||
|
|
||||||
You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload)
|
|
||||||
|
|
||||||
Thats it!
|
|
||||||
|
|
||||||
## 🍙 Self Hosting
|
|
||||||
|
|
||||||
For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/docker-compose) documentation page
|
|
||||||
|
|
||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
- **Issue Planning and Tracking**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to issues for better organization and tracking.
|
- **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.
|
||||||
- **Issue Attachments**: Collaborate effectively by attaching files to issues, making it easy for your team to find and share important project-related documents.
|
|
||||||
- **Layouts**: Customize your project view with your preferred layout - choose from List, Kanban, or Calendar to visualize your project in a way that makes sense to you.
|
- **Cycles**:
|
||||||
- **Cycles**: Plan sprints with Cycles to keep your team on track and productive. Gain insights into your project's progress with burn-down charts and other useful features.
|
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 easily track and plan your project's progress.
|
|
||||||
|
- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily.
|
||||||
|
|
||||||
- **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.
|
- **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.
|
||||||
- **Pages**: Plane pages function as an AI-powered notepad, allowing you to easily document issues, cycle plans, and module details, and then synchronize them with your issues.
|
|
||||||
- **Command K**: Enjoy a better user experience with the new Command + K menu. Easily manage and navigate through your projects from one convenient location.
|
- **Pages**: Plane pages, equipped with AI and a rich text editor, let you jot down your thoughts on the fly. Format your text, upload images, hyperlink, or sync your existing ideas into an actionable item or issue.
|
||||||
- **GitHub Sync**: Streamline your planning process by syncing your GitHub issues with Plane. Keep all your issues in one place for better tracking and collaboration.
|
|
||||||
|
- **Analytics**: Get insights into all your Plane data in real-time. Visualize issue data to spot trends, remove blockers, and progress your work.
|
||||||
|
|
||||||
|
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
|
||||||
|
|
||||||
|
## 🛠️ Quick start for contributors
|
||||||
|
|
||||||
|
> Development system must have docker engine installed and running.
|
||||||
|
|
||||||
|
Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute -
|
||||||
|
|
||||||
|
1. Clone the code locally using:
|
||||||
|
```
|
||||||
|
git clone https://github.com/makeplane/plane.git
|
||||||
|
```
|
||||||
|
2. Switch to the code folder:
|
||||||
|
```
|
||||||
|
cd plane
|
||||||
|
```
|
||||||
|
3. Create your feature or fix branch you plan to work on using:
|
||||||
|
```
|
||||||
|
git checkout -b <feature-branch-name>
|
||||||
|
```
|
||||||
|
4. Open terminal and run:
|
||||||
|
```
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
5. Open the code on VSCode or similar equivalent IDE.
|
||||||
|
6. Review the `.env` files available in various folders.
|
||||||
|
Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system.
|
||||||
|
7. Run the docker command to initiate services:
|
||||||
|
```
|
||||||
|
docker compose -f docker-compose-local.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
You are ready to make changes to the code. Do not forget to refresh the browser (in case it does not auto-reload).
|
||||||
|
|
||||||
|
Thats it!
|
||||||
|
|
||||||
|
## ❤️ Community
|
||||||
|
|
||||||
|
The Plane community can be found on [GitHub Discussions](https://github.com/orgs/makeplane/discussions), and our [Discord server](https://discord.com/invite/A92xrEGCge). Our [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community chanels.
|
||||||
|
|
||||||
|
Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects.
|
||||||
|
|
||||||
|
### Repo Activity
|
||||||
|
|
||||||
|
![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image")
|
||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_views_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Issues_rNZjrGgFl.png?updatedAt=1709298765880"
|
||||||
alt="Plane Views"
|
alt="Plane Views"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@ -91,8 +132,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_issue_detail_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Cycles_jCDhqmTl9.png?updatedAt=1709298780697"
|
||||||
alt="Plane Issue Details"
|
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
@ -100,7 +140,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_cycles_modules_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Modules_PSCVsbSfI.png?updatedAt=1709298796783"
|
||||||
alt="Plane Cycles and Modules"
|
alt="Plane Cycles and Modules"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@ -109,7 +149,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_analytics_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Views_uxXsRatS4.png?updatedAt=1709298834522"
|
||||||
alt="Plane Analytics"
|
alt="Plane Analytics"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@ -118,7 +158,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_pages_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Analytics_0o22gLRtp.png?updatedAt=1709298834389"
|
||||||
alt="Plane Pages"
|
alt="Plane Pages"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@ -128,7 +168,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_commad_k_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Drive_LlfeY4xn3.png?updatedAt=1709298837917"
|
||||||
alt="Plane Command Menu"
|
alt="Plane Command Menu"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@ -136,20 +176,23 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
</p>
|
</p>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 📚Documentation
|
|
||||||
|
|
||||||
For full documentation, visit [docs.plane.so](https://docs.plane.so/)
|
|
||||||
|
|
||||||
To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md).
|
|
||||||
|
|
||||||
## ❤️ Community
|
|
||||||
|
|
||||||
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
|
|
||||||
|
|
||||||
To chat with other community members you can join the [Plane Discord](https://discord.com/invite/A92xrEGCge).
|
|
||||||
|
|
||||||
Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels.
|
|
||||||
|
|
||||||
## ⛓️ Security
|
## ⛓️ Security
|
||||||
|
|
||||||
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email engineering@plane.so to disclose any security vulnerabilities.
|
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports.
|
||||||
|
|
||||||
|
Email squawk@plane.so to disclose any security vulnerabilities.
|
||||||
|
|
||||||
|
## ❤️ Contribute
|
||||||
|
|
||||||
|
There are many ways to contribute to Plane, including:
|
||||||
|
|
||||||
|
- Submitting [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) and [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+) for various components.
|
||||||
|
- Reviewing [the documentation](https://docs.plane.so/) and submitting [pull requests](https://github.com/makeplane/plane), from fixing typos to adding new features.
|
||||||
|
- Speaking or writing about Plane or any other ecosystem integration and [letting us know](https://discord.com/invite/A92xrEGCge)!
|
||||||
|
- Upvoting [popular feature requests](https://github.com/makeplane/plane/issues) to show your support.
|
||||||
|
|
||||||
|
### We couldn't have done this without you.
|
||||||
|
|
||||||
|
<a href="https://github.com/makeplane/plane/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=makeplane/plane" />
|
||||||
|
</a>
|
||||||
|
44
SECURITY.md
Normal file
44
SECURITY.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
This document outlines security procedures and vulnerabilities reporting for the Plane project.
|
||||||
|
|
||||||
|
At Plane, we safeguarding the security of our systems with top priority. Despite our efforts, vulnerabilities may still exist. We greatly appreciate your assistance in identifying and reporting any such vulnerabilities to help us maintain the integrity of our systems and protect our clients.
|
||||||
|
|
||||||
|
To report a security vulnerability, please email us directly at security@plane.so with a detailed description of the vulnerability and steps to reproduce it. Please refrain from disclosing the vulnerability publicly until we have had an opportunity to review and address it.
|
||||||
|
|
||||||
|
## Out of Scope Vulnerabilities
|
||||||
|
|
||||||
|
We appreciate your help in identifying vulnerabilities. However, please note that the following types of vulnerabilities are considered out of scope:
|
||||||
|
|
||||||
|
- Attacks requiring MITM or physical access to a user's device.
|
||||||
|
- Content spoofing and text injection issues without demonstrating an attack vector or ability to modify HTML/CSS.
|
||||||
|
- Email spoofing.
|
||||||
|
- Missing DNSSEC, CAA, CSP headers.
|
||||||
|
- Lack of Secure or HTTP only flag on non-sensitive cookies.
|
||||||
|
|
||||||
|
## Reporting Process
|
||||||
|
|
||||||
|
If you discover a vulnerability, please adhere to the following reporting process:
|
||||||
|
|
||||||
|
1. Email your findings to security@plane.so.
|
||||||
|
2. Refrain from running automated scanners on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary.
|
||||||
|
3. Do not exploit the vulnerability for malicious purposes, such as downloading excessive data or altering user data.
|
||||||
|
4. Maintain confidentiality and refrain from disclosing the vulnerability until it has been resolved.
|
||||||
|
5. Avoid using physical security attacks, social engineering, distributed denial of service, spam, or third-party applications.
|
||||||
|
|
||||||
|
When reporting a vulnerability, please provide sufficient information to allow us to reproduce and address the issue promptly. Include the IP address or URL of the affected system, along with a detailed description of the vulnerability.
|
||||||
|
|
||||||
|
## Our Commitment
|
||||||
|
|
||||||
|
We are committed to promptly addressing reported vulnerabilities and maintaining open communication throughout the resolution process. Here's what you can expect from us:
|
||||||
|
|
||||||
|
- **Response Time:** We will acknowledge receipt of your report within three business days and provide an expected resolution date.
|
||||||
|
- **Legal Protection:** We will not pursue legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines.
|
||||||
|
- **Confidentiality:** Your report will be treated with strict confidentiality. We will not disclose your personal information to third parties without your consent.
|
||||||
|
- **Progress Updates:** We will keep you informed of our progress in resolving the reported vulnerability.
|
||||||
|
- **Recognition:** With your permission, we will publicly acknowledge you as the discoverer of the vulnerability.
|
||||||
|
- **Timely Resolution:** We strive to resolve all reported vulnerabilities promptly and will actively participate in the publication process once the issue is resolved.
|
||||||
|
|
||||||
|
We appreciate your cooperation in helping us maintain the security of our systems and protecting our clients. Thank you for your contributions to our security efforts.
|
||||||
|
|
||||||
|
reference: https://supabase.com/.well-known/security.txt
|
@ -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
|
||||||
|
|
||||||
|
@ -32,27 +32,19 @@ RUN apk add --no-cache --virtual .build-deps \
|
|||||||
apk del .build-deps
|
apk del .build-deps
|
||||||
|
|
||||||
|
|
||||||
RUN addgroup -S plane && \
|
|
||||||
adduser -S captain -G plane
|
|
||||||
|
|
||||||
RUN chown captain.plane /code
|
|
||||||
|
|
||||||
USER captain
|
|
||||||
|
|
||||||
# Add in Django deps and generate Django's static files
|
# Add in Django deps and generate Django's static files
|
||||||
COPY manage.py manage.py
|
COPY manage.py manage.py
|
||||||
COPY plane plane/
|
COPY plane plane/
|
||||||
COPY templates templates/
|
COPY templates templates/
|
||||||
COPY package.json package.json
|
COPY package.json package.json
|
||||||
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
|
||||||
|
|
||||||
USER captain
|
|
||||||
|
|
||||||
# Expose container port and run entry point script
|
# Expose container port and run entry point script
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
@ -30,16 +30,13 @@ ADD requirements ./requirements
|
|||||||
# Install the local development settings
|
# Install the local development settings
|
||||||
RUN pip install -r requirements/local.txt --compile --no-cache-dir
|
RUN pip install -r requirements/local.txt --compile --no-cache-dir
|
||||||
|
|
||||||
RUN addgroup -S plane && \
|
|
||||||
adduser -S captain -G plane
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN chown -R captain.plane /code
|
RUN mkdir -p /code/plane/logs
|
||||||
RUN chmod -R +x /code/bin
|
RUN chmod -R +x /code/bin
|
||||||
RUN chmod -R 777 /code
|
RUN chmod -R 777 /code
|
||||||
|
|
||||||
USER captain
|
|
||||||
|
|
||||||
# Expose container port and run entry point script
|
# Expose container port and run entry point script
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -66,11 +66,11 @@ class BaseSerializer(serializers.ModelSerializer):
|
|||||||
if expand in self.fields:
|
if expand in self.fields:
|
||||||
# Import all the expandable serializers
|
# Import all the expandable serializers
|
||||||
from . import (
|
from . import (
|
||||||
WorkspaceLiteSerializer,
|
|
||||||
ProjectLiteSerializer,
|
|
||||||
UserLiteSerializer,
|
|
||||||
StateLiteSerializer,
|
|
||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
|
ProjectLiteSerializer,
|
||||||
|
StateLiteSerializer,
|
||||||
|
UserLiteSerializer,
|
||||||
|
WorkspaceLiteSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Expansion mapper
|
# Expansion mapper
|
||||||
|
@ -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):
|
||||||
@ -77,8 +79,8 @@ class IssueSerializer(BaseSerializer):
|
|||||||
parsed_str = html.tostring(parsed, encoding="unicode")
|
parsed_str = html.tostring(parsed, encoding="unicode")
|
||||||
data["description_html"] = parsed_str
|
data["description_html"] = parsed_str
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
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:
|
||||||
@ -339,8 +366,8 @@ class IssueCommentSerializer(BaseSerializer):
|
|||||||
parsed_str = html.tostring(parsed, encoding="unicode")
|
parsed_str = html.tostring(parsed, encoding="unicode")
|
||||||
data["comment_html"] = parsed_str
|
data["comment_html"] = parsed_str
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
|
raise serializers.ValidationError("Invalid HTML passed")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,9 +6,8 @@ from plane.db.models import (
|
|||||||
Project,
|
Project,
|
||||||
ProjectIdentifier,
|
ProjectIdentifier,
|
||||||
WorkspaceMember,
|
WorkspaceMember,
|
||||||
State,
|
|
||||||
Estimate,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
# Module imports
|
# Module imports
|
||||||
from plane.db.models import User
|
from plane.db.models import User
|
||||||
|
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
|
|
||||||
|
|
||||||
@ -10,7 +11,9 @@ class UserLiteSerializer(BaseSerializer):
|
|||||||
"id",
|
"id",
|
||||||
"first_name",
|
"first_name",
|
||||||
"last_name",
|
"last_name",
|
||||||
|
"email",
|
||||||
"avatar",
|
"avatar",
|
||||||
"display_name",
|
"display_name",
|
||||||
|
"email",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
@ -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:cycle_id>/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(
|
||||||
@ -9,8 +12,13 @@ urlpatterns = [
|
|||||||
name="project",
|
name="project",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/",
|
"workspaces/<str:slug>/projects/<uuid:pk>/",
|
||||||
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,27 @@
|
|||||||
# 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.urls import resolve
|
||||||
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 +107,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,
|
||||||
@ -170,7 +166,12 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def project_id(self):
|
def project_id(self):
|
||||||
return self.kwargs.get("project_id", None)
|
project_id = self.kwargs.get("project_id", None)
|
||||||
|
if project_id:
|
||||||
|
return project_id
|
||||||
|
|
||||||
|
if resolve(self.request.path_info).url_name == "project":
|
||||||
|
return self.kwargs.get("pk", None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def fields(self):
|
def fields(self):
|
||||||
|
@ -2,29 +2,31 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db.models import Q, Count, Sum, Prefetch, F, OuterRef, Func
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
|
from django.db.models import Count, F, Func, OuterRef, Q, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseAPIView, WebhookMixin
|
from plane.api.serializers import (
|
||||||
from plane.db.models import (
|
CycleIssueSerializer,
|
||||||
Cycle,
|
CycleSerializer,
|
||||||
Issue,
|
|
||||||
CycleIssue,
|
|
||||||
IssueLink,
|
|
||||||
IssueAttachment,
|
|
||||||
)
|
)
|
||||||
from plane.app.permissions import ProjectEntityPermission
|
from plane.app.permissions import ProjectEntityPermission
|
||||||
from plane.api.serializers import (
|
|
||||||
CycleSerializer,
|
|
||||||
CycleIssueSerializer,
|
|
||||||
)
|
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
|
from plane.db.models import (
|
||||||
|
Cycle,
|
||||||
|
CycleIssue,
|
||||||
|
Issue,
|
||||||
|
IssueAttachment,
|
||||||
|
IssueLink,
|
||||||
|
)
|
||||||
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
|
||||||
|
from .base import BaseAPIView, WebhookMixin
|
||||||
|
|
||||||
|
|
||||||
class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||||
@ -140,7 +142,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 +154,7 @@ 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,144 @@ 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, cycle_id):
|
||||||
|
cycle = Cycle.objects.get(
|
||||||
|
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
if cycle.end_date >= timezone.now().date():
|
||||||
|
return Response(
|
||||||
|
{"error": "Only completed cycles can be archived"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
cycle.archived_at = timezone.now()
|
||||||
|
cycle.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
def delete(self, request, slug, project_id, cycle_id):
|
||||||
|
cycle = Cycle.objects.get(
|
||||||
|
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
cycle.archived_at = None
|
||||||
|
cycle.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||||
"""
|
"""
|
||||||
This viewset automatically provides `list`, `create`,
|
This viewset automatically provides `list`, `create`,
|
||||||
@ -407,7 +556,21 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
def get(self, request, slug, project_id, cycle_id):
|
def get(self, request, slug, project_id, cycle_id, issue_id=None):
|
||||||
|
# Get
|
||||||
|
if issue_id:
|
||||||
|
cycle_issue = CycleIssue.objects.get(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
cycle_id=cycle_id,
|
||||||
|
issue_id=issue_id,
|
||||||
|
)
|
||||||
|
serializer = CycleIssueSerializer(
|
||||||
|
cycle_issue, fields=self.fields, expand=self.expand
|
||||||
|
)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# List
|
||||||
order_by = request.GET.get("order_by", "created_at")
|
order_by = request.GET.get("order_by", "created_at")
|
||||||
issues = (
|
issues = (
|
||||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
|
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
|
||||||
@ -604,6 +767,209 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
old_cycle = (
|
||||||
|
Cycle.objects.filter(
|
||||||
|
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"issue_cycle",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="completed",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="cancelled",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="started",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="unstarted",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="backlog",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pass the new_cycle queryset to burndown_plot
|
||||||
|
completion_chart = burndown_plot(
|
||||||
|
queryset=old_cycle.first(),
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
cycle_id=cycle_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the assignee distribution
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=cycle_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.values("display_name", "assignee_id", "avatar")
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("display_name")
|
||||||
|
)
|
||||||
|
# assignee distribution serialized
|
||||||
|
assignee_distribution_data = [
|
||||||
|
{
|
||||||
|
"display_name": item["display_name"],
|
||||||
|
"assignee_id": (
|
||||||
|
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||||
|
),
|
||||||
|
"avatar": item["avatar"],
|
||||||
|
"total_issues": item["total_issues"],
|
||||||
|
"completed_issues": item["completed_issues"],
|
||||||
|
"pending_issues": item["pending_issues"],
|
||||||
|
}
|
||||||
|
for item in assignee_distribution
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get the label distribution
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=cycle_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Label distribution serilization
|
||||||
|
label_distribution_data = [
|
||||||
|
{
|
||||||
|
"label_name": item["label_name"],
|
||||||
|
"color": item["color"],
|
||||||
|
"label_id": (
|
||||||
|
str(item["label_id"]) if item["label_id"] else None
|
||||||
|
),
|
||||||
|
"total_issues": item["total_issues"],
|
||||||
|
"completed_issues": item["completed_issues"],
|
||||||
|
"pending_issues": item["pending_issues"],
|
||||||
|
}
|
||||||
|
for item in label_distribution
|
||||||
|
]
|
||||||
|
|
||||||
|
current_cycle = Cycle.objects.filter(
|
||||||
|
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if current_cycle:
|
||||||
|
current_cycle.progress_snapshot = {
|
||||||
|
"total_issues": old_cycle.first().total_issues,
|
||||||
|
"completed_issues": old_cycle.first().completed_issues,
|
||||||
|
"cancelled_issues": old_cycle.first().cancelled_issues,
|
||||||
|
"started_issues": old_cycle.first().started_issues,
|
||||||
|
"unstarted_issues": old_cycle.first().unstarted_issues,
|
||||||
|
"backlog_issues": old_cycle.first().backlog_issues,
|
||||||
|
"distribution": {
|
||||||
|
"labels": label_distribution_data,
|
||||||
|
"assignees": assignee_distribution_data,
|
||||||
|
"completion_chart": completion_chart,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# Save the snapshot of the current cycle
|
||||||
|
current_cycle.save(update_fields=["progress_snapshot"])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
new_cycle.end_date is not None
|
new_cycle.end_date is not None
|
||||||
and new_cycle.end_date < timezone.now().date()
|
and new_cycle.end_date < timezone.now().date()
|
||||||
|
@ -2,27 +2,28 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
# Django improts
|
# Django improts
|
||||||
from django.utils import timezone
|
|
||||||
from django.db.models import Q
|
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseAPIView
|
|
||||||
from plane.app.permissions import ProjectLitePermission
|
|
||||||
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
|
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
|
||||||
|
from plane.app.permissions import ProjectLitePermission
|
||||||
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
|
Inbox,
|
||||||
InboxIssue,
|
InboxIssue,
|
||||||
Issue,
|
Issue,
|
||||||
State,
|
|
||||||
ProjectMember,
|
|
||||||
Project,
|
Project,
|
||||||
Inbox,
|
ProjectMember,
|
||||||
|
State,
|
||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
|
||||||
|
from .base import BaseAPIView
|
||||||
|
|
||||||
|
|
||||||
class InboxIssueAPIEndpoint(BaseAPIView):
|
class InboxIssueAPIEndpoint(BaseAPIView):
|
||||||
@ -119,7 +120,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",
|
||||||
@ -134,10 +135,11 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
|||||||
# Create or get state
|
# Create or get state
|
||||||
state, _ = State.objects.get_or_create(
|
state, _ = State.objects.get_or_create(
|
||||||
name="Triage",
|
name="Triage",
|
||||||
group="backlog",
|
group="triage",
|
||||||
description="Default state for managing all Inbox Issues",
|
description="Default state for managing all Inbox Issues",
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
color="#ff7700",
|
color="#ff7700",
|
||||||
|
is_triage=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# create an issue
|
# create an issue
|
||||||
@ -298,7 +300,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update the issue state only if it is in triage state
|
# Update the issue state only if it is in triage state
|
||||||
if issue.state.name == "Triage":
|
if issue.state.is_triage:
|
||||||
# Move to default state
|
# Move to default state
|
||||||
state = State.objects.filter(
|
state = State.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
|
@ -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):
|
||||||
@ -307,8 +308,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
actor_id=str(request.user.id),
|
actor_id=str(request.user.id),
|
||||||
issue_id=str(pk),
|
issue_id=str(pk),
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
external_id__isnull=False,
|
|
||||||
external_source__isnull=False,
|
|
||||||
current_instance=current_instance,
|
current_instance=current_instance,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
)
|
)
|
||||||
@ -356,6 +355,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 +488,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 +618,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 +655,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 +680,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(
|
||||||
@ -716,9 +716,12 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
|
|
||||||
# Validation check if the issue already exists
|
# Validation check if the issue already exists
|
||||||
if (
|
if (
|
||||||
str(request.data.get("external_id"))
|
request.data.get("external_id")
|
||||||
and (issue_comment.external_id != str(request.data.get("external_id")))
|
and (
|
||||||
and Issue.objects.filter(
|
issue_comment.external_id
|
||||||
|
!= str(request.data.get("external_id"))
|
||||||
|
)
|
||||||
|
and IssueComment.objects.filter(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
external_source=request.data.get(
|
external_source=request.data.get(
|
||||||
@ -735,7 +738,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 +794,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"))
|
||||||
|
|
||||||
|
@ -2,32 +2,33 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db.models import Count, Prefetch, Q, F, Func, OuterRef
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
|
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseAPIView, WebhookMixin
|
from plane.api.serializers import (
|
||||||
|
IssueSerializer,
|
||||||
|
ModuleIssueSerializer,
|
||||||
|
ModuleSerializer,
|
||||||
|
)
|
||||||
from plane.app.permissions import ProjectEntityPermission
|
from plane.app.permissions import ProjectEntityPermission
|
||||||
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Project,
|
|
||||||
Module,
|
|
||||||
ModuleLink,
|
|
||||||
Issue,
|
Issue,
|
||||||
ModuleIssue,
|
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
|
Module,
|
||||||
|
ModuleIssue,
|
||||||
|
ModuleLink,
|
||||||
|
Project,
|
||||||
)
|
)
|
||||||
from plane.api.serializers import (
|
|
||||||
ModuleSerializer,
|
from .base import BaseAPIView, WebhookMixin
|
||||||
ModuleIssueSerializer,
|
|
||||||
IssueSerializer,
|
|
||||||
)
|
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
|
||||||
|
|
||||||
|
|
||||||
class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||||
@ -67,6 +68,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 +79,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 +90,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 +101,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 +112,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 +123,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 +172,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 +190,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 +209,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 +223,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 +293,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 +461,130 @@ 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, pk):
|
||||||
|
return self.paginate(
|
||||||
|
request=request,
|
||||||
|
queryset=(self.get_queryset()),
|
||||||
|
on_results=lambda modules: ModuleSerializer(
|
||||||
|
modules,
|
||||||
|
many=True,
|
||||||
|
fields=self.fields,
|
||||||
|
expand=self.expand,
|
||||||
|
).data,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, pk):
|
||||||
|
module = Module.objects.get(
|
||||||
|
pk=pk, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
if module.status not in ["completed", "cancelled"]:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "Only completed or cancelled modules can be archived"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
module.archived_at = timezone.now()
|
||||||
|
module.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
def delete(self, request, slug, project_id, pk):
|
||||||
|
module = Module.objects.get(
|
||||||
|
pk=pk, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
module.archived_at = None
|
||||||
|
module.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
@ -1,27 +1,29 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
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, F, Func, OuterRef, Prefetch, Q, Subquery
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
# 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 rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
|
from plane.api.serializers import ProjectSerializer
|
||||||
|
from plane.app.permissions import ProjectBasePermission
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Workspace,
|
|
||||||
Project,
|
|
||||||
ProjectFavorite,
|
|
||||||
ProjectMember,
|
|
||||||
ProjectDeployBoard,
|
|
||||||
State,
|
|
||||||
Cycle,
|
Cycle,
|
||||||
Module,
|
|
||||||
IssueProperty,
|
|
||||||
Inbox,
|
Inbox,
|
||||||
|
IssueProperty,
|
||||||
|
Module,
|
||||||
|
Project,
|
||||||
|
ProjectDeployBoard,
|
||||||
|
ProjectMember,
|
||||||
|
State,
|
||||||
|
Workspace,
|
||||||
)
|
)
|
||||||
from plane.app.permissions import ProjectBasePermission
|
|
||||||
from plane.api.serializers import ProjectSerializer
|
|
||||||
from .base import BaseAPIView, WebhookMixin
|
from .base import BaseAPIView, WebhookMixin
|
||||||
|
|
||||||
|
|
||||||
@ -40,7 +42,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(
|
||||||
@ -100,8 +105,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
def get(self, request, slug, project_id=None):
|
def get(self, request, slug, pk=None):
|
||||||
if project_id is None:
|
if pk is None:
|
||||||
sort_order_query = ProjectMember.objects.filter(
|
sort_order_query = ProjectMember.objects.filter(
|
||||||
member=request.user,
|
member=request.user,
|
||||||
project_id=OuterRef("pk"),
|
project_id=OuterRef("pk"),
|
||||||
@ -132,7 +137,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
expand=self.expand,
|
expand=self.expand,
|
||||||
).data,
|
).data,
|
||||||
)
|
)
|
||||||
project = self.get_queryset().get(workspace__slug=slug, pk=project_id)
|
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
|
||||||
serializer = ProjectSerializer(
|
serializer = ProjectSerializer(
|
||||||
project,
|
project,
|
||||||
fields=self.fields,
|
fields=self.fields,
|
||||||
@ -150,7 +155,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,21 +250,27 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
def patch(self, request, slug, project_id=None):
|
def patch(self, request, slug, pk):
|
||||||
try:
|
try:
|
||||||
workspace = Workspace.objects.get(slug=slug)
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
project = Project.objects.get(pk=project_id)
|
project = Project.objects.get(pk=pk)
|
||||||
|
|
||||||
|
if project.archived_at:
|
||||||
|
return Response(
|
||||||
|
{"error": "Archived project cannot be updated"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
serializer = ProjectSerializer(
|
serializer = ProjectSerializer(
|
||||||
project,
|
project,
|
||||||
@ -280,10 +291,11 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
# Create the triage state in Backlog group
|
# Create the triage state in Backlog group
|
||||||
State.objects.get_or_create(
|
State.objects.get_or_create(
|
||||||
name="Triage",
|
name="Triage",
|
||||||
group="backlog",
|
group="triage",
|
||||||
description="Default state for managing all Inbox Issues",
|
description="Default state for managing all Inbox Issues",
|
||||||
project_id=project_id,
|
project_id=pk,
|
||||||
color="#ff7700",
|
color="#ff7700",
|
||||||
|
is_triage=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
project = (
|
project = (
|
||||||
@ -307,13 +319,32 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete(self, request, slug, project_id):
|
def delete(self, request, slug, pk):
|
||||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
project = Project.objects.get(pk=pk, 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)
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from plane.api.serializers import StateSerializer
|
||||||
|
from plane.app.permissions import ProjectEntityPermission
|
||||||
|
from plane.db.models import Issue, State
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseAPIView
|
from .base import BaseAPIView
|
||||||
from plane.api.serializers import StateSerializer
|
|
||||||
from plane.app.permissions import ProjectEntityPermission
|
|
||||||
from plane.db.models import State, Issue
|
|
||||||
|
|
||||||
|
|
||||||
class StateAPIEndpoint(BaseAPIView):
|
class StateAPIEndpoint(BaseAPIView):
|
||||||
@ -28,7 +28,8 @@ 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(~Q(name="Triage"))
|
.filter(is_triage=False)
|
||||||
|
.filter(project__archived_at__isnull=True)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.distinct()
|
.distinct()
|
||||||
@ -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,
|
||||||
@ -83,7 +86,11 @@ class StateAPIEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
def get(self, request, slug, project_id, state_id=None):
|
def get(self, request, slug, project_id, state_id=None):
|
||||||
if state_id:
|
if state_id:
|
||||||
serializer = StateSerializer(self.get_queryset().get(pk=state_id))
|
serializer = StateSerializer(
|
||||||
|
self.get_queryset().get(pk=state_id),
|
||||||
|
fields=self.fields,
|
||||||
|
expand=self.expand,
|
||||||
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return self.paginate(
|
return self.paginate(
|
||||||
request=request,
|
request=request,
|
||||||
@ -98,7 +105,7 @@ class StateAPIEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
def delete(self, request, slug, project_id, state_id):
|
def delete(self, request, slug, project_id, state_id):
|
||||||
state = State.objects.get(
|
state = State.objects.get(
|
||||||
~Q(name="Triage"),
|
is_triage=False,
|
||||||
pk=state_id,
|
pk=state_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
@ -136,7 +143,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()
|
||||||
):
|
):
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
# Third Party imports
|
# Third Party imports
|
||||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||||
|
|
||||||
# Module import
|
# Module import
|
||||||
from plane.db.models import WorkspaceMember, ProjectMember
|
from plane.db.models import ProjectMember, WorkspaceMember
|
||||||
|
|
||||||
# Permission Mappings
|
# Permission Mappings
|
||||||
Admin = 20
|
Admin = 20
|
||||||
|
@ -59,6 +59,7 @@ from .issue import (
|
|||||||
IssueFlatSerializer,
|
IssueFlatSerializer,
|
||||||
IssueStateSerializer,
|
IssueStateSerializer,
|
||||||
IssueLinkSerializer,
|
IssueLinkSerializer,
|
||||||
|
IssueInboxSerializer,
|
||||||
IssueLiteSerializer,
|
IssueLiteSerializer,
|
||||||
IssueAttachmentSerializer,
|
IssueAttachmentSerializer,
|
||||||
IssueSubscriberSerializer,
|
IssueSubscriberSerializer,
|
||||||
@ -86,22 +87,13 @@ 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 (
|
||||||
PageSerializer,
|
PageSerializer,
|
||||||
PageLogSerializer,
|
PageLogSerializer,
|
||||||
SubPageSerializer,
|
SubPageSerializer,
|
||||||
|
PageDetailSerializer,
|
||||||
PageFavoriteSerializer,
|
PageFavoriteSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -117,11 +109,15 @@ from .inbox import (
|
|||||||
InboxIssueSerializer,
|
InboxIssueSerializer,
|
||||||
IssueStateInboxSerializer,
|
IssueStateInboxSerializer,
|
||||||
InboxIssueLiteSerializer,
|
InboxIssueLiteSerializer,
|
||||||
|
InboxIssueDetailSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,7 +3,11 @@ 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 (
|
||||||
|
IssueInboxSerializer,
|
||||||
|
LabelLiteSerializer,
|
||||||
|
IssueDetailSerializer,
|
||||||
|
)
|
||||||
from .project import ProjectLiteSerializer
|
from .project import ProjectLiteSerializer
|
||||||
from .state import StateLiteSerializer
|
from .state import StateLiteSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
@ -24,17 +28,62 @@ class InboxSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class InboxIssueSerializer(BaseSerializer):
|
class InboxIssueSerializer(BaseSerializer):
|
||||||
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
|
issue = IssueInboxSerializer(read_only=True)
|
||||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InboxIssue
|
model = InboxIssue
|
||||||
fields = "__all__"
|
fields = [
|
||||||
|
"id",
|
||||||
|
"status",
|
||||||
|
"duplicate_to",
|
||||||
|
"snoozed_till",
|
||||||
|
"source",
|
||||||
|
"issue",
|
||||||
|
"created_by",
|
||||||
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"project",
|
"project",
|
||||||
"workspace",
|
"workspace",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
# Pass the annotated fields to the Issue instance if they exist
|
||||||
|
if hasattr(instance, "label_ids"):
|
||||||
|
instance.issue.label_ids = instance.label_ids
|
||||||
|
return super().to_representation(instance)
|
||||||
|
|
||||||
|
|
||||||
|
class InboxIssueDetailSerializer(BaseSerializer):
|
||||||
|
issue = IssueDetailSerializer(read_only=True)
|
||||||
|
duplicate_issue_detail = IssueInboxSerializer(
|
||||||
|
read_only=True, source="duplicate_to"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = InboxIssue
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"status",
|
||||||
|
"duplicate_to",
|
||||||
|
"snoozed_till",
|
||||||
|
"duplicate_issue_detail",
|
||||||
|
"source",
|
||||||
|
"issue",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"project",
|
||||||
|
"workspace",
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
# Pass the annotated fields to the Issue instance if they exist
|
||||||
|
if hasattr(instance, "assignee_ids"):
|
||||||
|
instance.issue.assignee_ids = instance.assignee_ids
|
||||||
|
if hasattr(instance, "label_ids"):
|
||||||
|
instance.issue.label_ids = instance.label_ids
|
||||||
|
|
||||||
|
return super().to_representation(instance)
|
||||||
|
|
||||||
|
|
||||||
class InboxIssueLiteSerializer(BaseSerializer):
|
class InboxIssueLiteSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -597,19 +620,42 @@ class IssueStateSerializer(DynamicBaseSerializer):
|
|||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class IssueInboxSerializer(DynamicBaseSerializer):
|
||||||
|
label_ids = serializers.ListField(
|
||||||
|
child=serializers.UUIDField(),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Issue
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"priority",
|
||||||
|
"sequence_id",
|
||||||
|
"project_id",
|
||||||
|
"created_at",
|
||||||
|
"label_ids",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
class IssueSerializer(DynamicBaseSerializer):
|
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 +695,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 = [
|
||||||
@ -674,7 +708,7 @@ class IssueLiteSerializer(DynamicBaseSerializer):
|
|||||||
|
|
||||||
class IssueDetailSerializer(IssueSerializer):
|
class IssueDetailSerializer(IssueSerializer):
|
||||||
description_html = serializers.CharField()
|
description_html = serializers.CharField()
|
||||||
is_subscribed = serializers.BooleanField()
|
is_subscribed = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
class Meta(IssueSerializer.Meta):
|
class Meta(IssueSerializer.Meta):
|
||||||
fields = IssueSerializer.Meta.fields + [
|
fields = IssueSerializer.Meta.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,38 +3,44 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from .issue import IssueFlatSerializer, LabelLiteSerializer
|
|
||||||
from .workspace import WorkspaceLiteSerializer
|
|
||||||
from .project import ProjectLiteSerializer
|
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Page,
|
Page,
|
||||||
PageLog,
|
PageLog,
|
||||||
PageFavorite,
|
PageFavorite,
|
||||||
PageLabel,
|
PageLabel,
|
||||||
Label,
|
Label,
|
||||||
Issue,
|
|
||||||
Module,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PageSerializer(BaseSerializer):
|
class PageSerializer(BaseSerializer):
|
||||||
is_favorite = serializers.BooleanField(read_only=True)
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
label_details = LabelLiteSerializer(
|
|
||||||
read_only=True, source="labels", many=True
|
|
||||||
)
|
|
||||||
labels = serializers.ListField(
|
labels = serializers.ListField(
|
||||||
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
|
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
|
||||||
write_only=True,
|
write_only=True,
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
|
||||||
workspace_detail = WorkspaceLiteSerializer(
|
|
||||||
source="workspace", read_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Page
|
model = Page
|
||||||
fields = "__all__"
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"owned_by",
|
||||||
|
"access",
|
||||||
|
"color",
|
||||||
|
"labels",
|
||||||
|
"parent",
|
||||||
|
"is_favorite",
|
||||||
|
"is_locked",
|
||||||
|
"archived_at",
|
||||||
|
"workspace",
|
||||||
|
"project",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"view_props",
|
||||||
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"workspace",
|
"workspace",
|
||||||
"project",
|
"project",
|
||||||
@ -50,8 +56,12 @@ class PageSerializer(BaseSerializer):
|
|||||||
labels = validated_data.pop("labels", None)
|
labels = validated_data.pop("labels", None)
|
||||||
project_id = self.context["project_id"]
|
project_id = self.context["project_id"]
|
||||||
owned_by_id = self.context["owned_by_id"]
|
owned_by_id = self.context["owned_by_id"]
|
||||||
|
description_html = self.context["description_html"]
|
||||||
page = Page.objects.create(
|
page = Page.objects.create(
|
||||||
**validated_data, project_id=project_id, owned_by_id=owned_by_id
|
**validated_data,
|
||||||
|
description_html=description_html,
|
||||||
|
project_id=project_id,
|
||||||
|
owned_by_id=owned_by_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if labels is not None:
|
if labels is not None:
|
||||||
@ -93,6 +103,13 @@ class PageSerializer(BaseSerializer):
|
|||||||
return super().update(instance, validated_data)
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class PageDetailSerializer(PageSerializer):
|
||||||
|
description_html = serializers.CharField()
|
||||||
|
|
||||||
|
class Meta(PageSerializer.Meta):
|
||||||
|
fields = PageSerializer.Meta.fields + ["description_html"]
|
||||||
|
|
||||||
|
|
||||||
class SubPageSerializer(BaseSerializer):
|
class SubPageSerializer(BaseSerializer):
|
||||||
entity_details = serializers.SerializerMethodField()
|
entity_details = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@ -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,19 @@ 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",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/<uuid:pk>/",
|
||||||
|
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",
|
|
||||||
),
|
|
||||||
]
|
|
@ -30,7 +30,7 @@ urlpatterns = [
|
|||||||
name="inbox",
|
name="inbox",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/",
|
||||||
InboxIssueViewSet.as_view(
|
InboxIssueViewSet.as_view(
|
||||||
{
|
{
|
||||||
"get": "list",
|
"get": "list",
|
||||||
@ -40,7 +40,7 @@ urlpatterns = [
|
|||||||
name="inbox-issue",
|
name="inbox-issue",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:issue_id>/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/",
|
||||||
InboxIssueViewSet.as_view(
|
InboxIssueViewSet.as_view(
|
||||||
{
|
{
|
||||||
"get": "retrieve",
|
"get": "retrieve",
|
||||||
|
@ -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,17 +81,6 @@ 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/",
|
||||||
|
@ -6,8 +6,8 @@ from plane.app.views import (
|
|||||||
ModuleIssueViewSet,
|
ModuleIssueViewSet,
|
||||||
ModuleLinkViewSet,
|
ModuleLinkViewSet,
|
||||||
ModuleFavoriteViewSet,
|
ModuleFavoriteViewSet,
|
||||||
BulkImportModulesEndpoint,
|
|
||||||
ModuleUserPropertiesEndpoint,
|
ModuleUserPropertiesEndpoint,
|
||||||
|
ModuleArchiveUnarchiveEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -106,14 +106,24 @@ 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",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/<uuid:pk>/",
|
||||||
|
ModuleArchiveUnarchiveEndpoint.as_view(),
|
||||||
|
name="module-archive-unarchive",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -31,102 +31,51 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="project-pages",
|
name="project-pages",
|
||||||
),
|
),
|
||||||
|
# favorite pages
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:pk>/",
|
||||||
PageFavoriteViewSet.as_view(
|
PageFavoriteViewSet.as_view(
|
||||||
{
|
{
|
||||||
"get": "list",
|
|
||||||
"post": "create",
|
"post": "create",
|
||||||
}
|
|
||||||
),
|
|
||||||
name="user-favorite-pages",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/<uuid:page_id>/",
|
|
||||||
PageFavoriteViewSet.as_view(
|
|
||||||
{
|
|
||||||
"delete": "destroy",
|
"delete": "destroy",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
name="user-favorite-pages",
|
name="user-favorite-pages",
|
||||||
),
|
),
|
||||||
|
# archived pages
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/archive/",
|
||||||
PageViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "list",
|
|
||||||
"post": "create",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="project-pages",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/",
|
|
||||||
PageViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "retrieve",
|
|
||||||
"patch": "partial_update",
|
|
||||||
"delete": "destroy",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="project-pages",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/archive/",
|
|
||||||
PageViewSet.as_view(
|
PageViewSet.as_view(
|
||||||
{
|
{
|
||||||
"post": "archive",
|
"post": "archive",
|
||||||
|
"delete": "unarchive",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
name="project-page-archive",
|
name="project-page-archive-unarchive",
|
||||||
),
|
),
|
||||||
|
# lock and unlock
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/unarchive/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/lock/",
|
||||||
PageViewSet.as_view(
|
|
||||||
{
|
|
||||||
"post": "unarchive",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="project-page-unarchive",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-pages/",
|
|
||||||
PageViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "archive_list",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="project-pages",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/lock/",
|
|
||||||
PageViewSet.as_view(
|
PageViewSet.as_view(
|
||||||
{
|
{
|
||||||
"post": "lock",
|
"post": "lock",
|
||||||
|
"delete": "unlock",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
name="project-pages",
|
name="project-pages-lock-unlock",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/unlock/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
|
||||||
PageViewSet.as_view(
|
|
||||||
{
|
|
||||||
"post": "unlock",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/transactions/",
|
|
||||||
PageLogEndpoint.as_view(),
|
PageLogEndpoint.as_view(),
|
||||||
name="page-transactions",
|
name="page-transactions",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/transactions/<uuid:transaction>/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/<uuid:transaction>/",
|
||||||
PageLogEndpoint.as_view(),
|
PageLogEndpoint.as_view(),
|
||||||
name="page-transactions",
|
name="page-transactions",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/sub-pages/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/sub-pages/",
|
||||||
SubPagesEndpoint.as_view(),
|
SubPagesEndpoint.as_view(),
|
||||||
name="sub-page",
|
name="sub-page",
|
||||||
),
|
),
|
||||||
|
@ -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
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
# 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",
|
||||||
@ -523,6 +540,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
queryset = queryset.first()
|
queryset = queryset.first()
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return Response(
|
||||||
|
{"error": "Cycle does not exist"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
# Assignee Distribution
|
# Assignee Distribution
|
||||||
assignee_distribution = (
|
assignee_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
@ -662,273 +686,377 @@ 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(
|
||||||
|
total_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="completed",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="cancelled",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="started",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="unstarted",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="backlog",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
status=Case(
|
||||||
|
When(
|
||||||
|
Q(start_date__lte=timezone.now())
|
||||||
|
& Q(end_date__gte=timezone.now()),
|
||||||
|
then=Value("CURRENT"),
|
||||||
|
),
|
||||||
|
When(
|
||||||
|
start_date__gt=timezone.now(), then=Value("UPCOMING")
|
||||||
|
),
|
||||||
|
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||||
|
When(
|
||||||
|
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||||
|
then=Value("DRAFT"),
|
||||||
|
),
|
||||||
|
default=Value("DRAFT"),
|
||||||
|
output_field=CharField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue_cycle__issue__assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(
|
||||||
|
issue_cycle__issue__assignees__id__isnull=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "name")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
@method_decorator(gzip_page)
|
def get(self, request, slug, project_id, pk=None):
|
||||||
def list(self, request, slug, project_id, cycle_id):
|
if pk is None:
|
||||||
fields = [
|
queryset = (
|
||||||
field
|
self.get_queryset()
|
||||||
for field in request.GET.get("fields", "").split(",")
|
.annotate(
|
||||||
if field
|
total_issues=Count(
|
||||||
]
|
"issue_cycle",
|
||||||
order_by = request.GET.get("order_by", "created_at")
|
filter=Q(
|
||||||
filters = issue_filters(request.query_params, "GET")
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
queryset = (
|
issue_cycle__issue__is_draft=False,
|
||||||
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()
|
.values(
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
# necessary fields
|
||||||
.values("count")
|
"id",
|
||||||
)
|
"workspace_id",
|
||||||
.annotate(
|
"project_id",
|
||||||
sub_issues_count=Issue.issue_objects.filter(
|
# model fields
|
||||||
parent=OuterRef("id")
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
# meta fields
|
||||||
|
"total_issues",
|
||||||
|
"is_favorite",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"assignee_ids",
|
||||||
|
"status",
|
||||||
|
"archived_at",
|
||||||
)
|
)
|
||||||
.order_by()
|
).order_by("-is_favorite", "-created_at")
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
return Response(queryset, status=status.HTTP_200_OK)
|
||||||
.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:
|
else:
|
||||||
issues = queryset.values(
|
queryset = (
|
||||||
"id",
|
self.get_queryset()
|
||||||
"name",
|
.filter(archived_at__isnull=False)
|
||||||
"state_id",
|
.filter(pk=pk)
|
||||||
"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)
|
data = (
|
||||||
|
self.get_queryset()
|
||||||
def create(self, request, slug, project_id, cycle_id):
|
.filter(pk=pk)
|
||||||
issues = request.data.get("issues", [])
|
.annotate(
|
||||||
|
sub_issues=Issue.issue_objects.filter(
|
||||||
if not issues:
|
project_id=self.kwargs.get("project_id"),
|
||||||
return Response(
|
parent__isnull=False,
|
||||||
{"error": "Issues are required"},
|
issue_cycle__cycle_id=pk,
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
)
|
||||||
)
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
cycle = Cycle.objects.get(
|
.values("count")
|
||||||
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
|
.values(
|
||||||
],
|
# necessary fields
|
||||||
batch_size=10,
|
"id",
|
||||||
)
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
"sub_issues",
|
||||||
|
# meta fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"assignee_ids",
|
||||||
|
"status",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
queryset = queryset.first()
|
||||||
|
|
||||||
# Updated Issues
|
if data is None:
|
||||||
updated_records = []
|
return Response(
|
||||||
update_cycle_issue_activity = []
|
{"error": "Cycle does not exist"},
|
||||||
# Iterate over each cycle_issue in cycle_issues
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
for cycle_issue in cycle_issues:
|
)
|
||||||
# Update the cycle_issue's cycle_id
|
|
||||||
cycle_issue.cycle_id = cycle_id
|
# Assignee Distribution
|
||||||
# Add the modified cycle_issue to the records_to_update list
|
assignee_distribution = (
|
||||||
updated_records.append(cycle_issue)
|
Issue.objects.filter(
|
||||||
# Record the update activity
|
issue_cycle__cycle_id=pk,
|
||||||
update_cycle_issue_activity.append(
|
workspace__slug=slug,
|
||||||
{
|
project_id=project_id,
|
||||||
"old_cycle_id": str(cycle_issue.cycle_id),
|
)
|
||||||
"new_cycle_id": str(cycle_id),
|
.annotate(first_name=F("assignees__first_name"))
|
||||||
"issue_id": str(cycle_issue.issue_id),
|
.annotate(last_name=F("assignees__last_name"))
|
||||||
}
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.values(
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"assignee_id",
|
||||||
|
"avatar",
|
||||||
|
"display_name",
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("first_name", "last_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update the cycle issues
|
# Label Distribution
|
||||||
CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100)
|
label_distribution = (
|
||||||
# Capture Issue Activity
|
Issue.objects.filter(
|
||||||
issue_activity.delay(
|
issue_cycle__cycle_id=pk,
|
||||||
type="cycle.activity.created",
|
workspace__slug=slug,
|
||||||
requested_data=json.dumps({"cycles_list": issues}),
|
project_id=project_id,
|
||||||
actor_id=str(self.request.user.id),
|
)
|
||||||
issue_id=None,
|
.annotate(label_name=F("labels__name"))
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
.annotate(color=F("labels__color"))
|
||||||
current_instance=json.dumps(
|
.annotate(label_id=F("labels__id"))
|
||||||
{
|
.values("label_name", "color", "label_id")
|
||||||
"updated_cycle_issues": update_cycle_issue_activity,
|
.annotate(
|
||||||
"created_cycle_issues": serializers.serialize(
|
total_issues=Count(
|
||||||
"json", created_records
|
"id",
|
||||||
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
),
|
),
|
||||||
}
|
)
|
||||||
),
|
.annotate(
|
||||||
epoch=int(timezone.now().timestamp()),
|
completed_issues=Count(
|
||||||
notification=True,
|
"id",
|
||||||
origin=request.META.get("HTTP_ORIGIN"),
|
filter=Q(
|
||||||
)
|
completed_at__isnull=False,
|
||||||
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, cycle_id, issue_id):
|
data["distribution"] = {
|
||||||
cycle_issue = CycleIssue.objects.get(
|
"assignees": assignee_distribution,
|
||||||
issue_id=issue_id,
|
"labels": label_distribution,
|
||||||
workspace__slug=slug,
|
"completion_chart": {},
|
||||||
project_id=project_id,
|
}
|
||||||
cycle_id=cycle_id,
|
|
||||||
|
if queryset.start_date and queryset.end_date:
|
||||||
|
data["distribution"]["completion_chart"] = burndown_plot(
|
||||||
|
queryset=queryset,
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
cycle_id=pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
data,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, cycle_id):
|
||||||
|
cycle = Cycle.objects.get(
|
||||||
|
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||||
)
|
)
|
||||||
issue_activity.delay(
|
|
||||||
type="cycle.activity.deleted",
|
if cycle.end_date >= timezone.now().date():
|
||||||
requested_data=json.dumps(
|
return Response(
|
||||||
{
|
{"error": "Only completed cycles can be archived"},
|
||||||
"cycle_id": str(self.kwargs.get("cycle_id")),
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
"issues": [str(issue_id)],
|
)
|
||||||
}
|
|
||||||
),
|
cycle.archived_at = timezone.now()
|
||||||
actor_id=str(self.request.user.id),
|
cycle.save()
|
||||||
issue_id=str(issue_id),
|
return Response(
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
{"archived_at": str(cycle.archived_at)},
|
||||||
current_instance=None,
|
status=status.HTTP_200_OK,
|
||||||
epoch=int(timezone.now().timestamp()),
|
|
||||||
notification=True,
|
|
||||||
origin=request.META.get("HTTP_ORIGIN"),
|
|
||||||
)
|
)
|
||||||
cycle_issue.delete()
|
|
||||||
|
def delete(self, request, slug, project_id, cycle_id):
|
||||||
|
cycle = Cycle.objects.get(
|
||||||
|
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
cycle.archived_at = None
|
||||||
|
cycle.save()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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,
|
||||||
@ -58,6 +59,7 @@ def dashboard_overview_stats(self, request, slug):
|
|||||||
|
|
||||||
pending_issues_count = Issue.issue_objects.filter(
|
pending_issues_count = Issue.issue_objects.filter(
|
||||||
~Q(state__group__in=["completed", "cancelled"]),
|
~Q(state__group__in=["completed", "cancelled"]),
|
||||||
|
target_date__lt=timezone.now().date(),
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
@ -147,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())),
|
||||||
),
|
),
|
||||||
@ -211,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(
|
||||||
{
|
{
|
||||||
@ -230,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(
|
||||||
{
|
{
|
||||||
@ -301,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())),
|
||||||
),
|
),
|
||||||
@ -364,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(
|
||||||
{
|
{
|
||||||
@ -381,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(
|
||||||
{
|
{
|
||||||
@ -469,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]
|
||||||
|
|
||||||
@ -484,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)
|
||||||
@ -498,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],
|
||||||
@ -511,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():
|
||||||
@ -621,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:
|
||||||
@ -638,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,
|
||||||
)
|
)
|
@ -1,24 +1,24 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import requests
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import litellm
|
import litellm
|
||||||
|
import requests
|
||||||
from litellm import completion
|
from litellm import completion
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
# Module imports
|
|
||||||
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.app.serializers import (
|
from plane.app.serializers import (
|
||||||
ProjectLiteSerializer,
|
ProjectLiteSerializer,
|
||||||
WorkspaceLiteSerializer,
|
WorkspaceLiteSerializer,
|
||||||
)
|
)
|
||||||
from plane.utils.integrations.github import get_release_notes
|
from plane.db.models import Project, Workspace
|
||||||
from plane.license.utils.instance_value import get_configuration_value
|
from plane.license.utils.instance_value import get_configuration_value
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from ..base import BaseAPIView
|
||||||
|
|
||||||
|
|
||||||
class GPTIntegrationEndpoint(BaseAPIView):
|
class GPTIntegrationEndpoint(BaseAPIView):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
@ -81,12 +81,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,
|
|
||||||
)
|
|
@ -3,7 +3,7 @@ import json
|
|||||||
|
|
||||||
# Django import
|
# Django import
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists
|
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
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
|
||||||
@ -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,
|
||||||
@ -24,16 +24,15 @@ from plane.db.models import (
|
|||||||
State,
|
State,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
|
Project,
|
||||||
ProjectMember,
|
ProjectMember,
|
||||||
IssueReaction,
|
|
||||||
IssueSubscriber,
|
|
||||||
)
|
)
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
IssueCreateSerializer,
|
IssueCreateSerializer,
|
||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
InboxSerializer,
|
InboxSerializer,
|
||||||
InboxIssueSerializer,
|
InboxIssueSerializer,
|
||||||
IssueDetailSerializer,
|
InboxIssueDetailSerializer,
|
||||||
)
|
)
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
@ -64,13 +63,20 @@ class InboxViewSet(BaseViewSet):
|
|||||||
.select_related("workspace", "project")
|
.select_related("workspace", "project")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list(self, request, slug, project_id):
|
||||||
|
inbox = self.get_queryset().first()
|
||||||
|
return Response(
|
||||||
|
InboxSerializer(inbox).data,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, pk):
|
def destroy(self, request, slug, project_id, pk):
|
||||||
inbox = Inbox.objects.get(
|
inbox = Inbox.objects.filter(
|
||||||
workspace__slug=slug, project_id=project_id, pk=pk
|
workspace__slug=slug, project_id=project_id, pk=pk
|
||||||
)
|
).first()
|
||||||
# Handle default inbox delete
|
# Handle default inbox delete
|
||||||
if inbox.is_default:
|
if inbox.is_default:
|
||||||
return Response(
|
return Response(
|
||||||
@ -98,7 +104,6 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
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"),
|
||||||
issue_inbox__inbox_id=self.kwargs.get("inbox_id"),
|
|
||||||
)
|
)
|
||||||
.select_related("workspace", "project", "state", "parent")
|
.select_related("workspace", "project", "state", "parent")
|
||||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||||
@ -146,7 +151,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())),
|
||||||
),
|
),
|
||||||
@ -161,51 +167,49 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
def list(self, request, slug, project_id, inbox_id):
|
def list(self, request, slug, project_id):
|
||||||
filters = issue_filters(request.query_params, "GET")
|
inbox_id = Inbox.objects.filter(
|
||||||
issue_queryset = (
|
workspace__slug=slug, project_id=project_id
|
||||||
self.get_queryset()
|
).first()
|
||||||
.filter(**filters)
|
filters = issue_filters(request.GET, "GET", "issue__")
|
||||||
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
|
inbox_issue = (
|
||||||
)
|
InboxIssue.objects.filter(
|
||||||
if self.expand:
|
inbox_id=inbox_id.id, project_id=project_id, **filters
|
||||||
issues = IssueSerializer(
|
|
||||||
issue_queryset, expand=self.expand, many=True
|
|
||||||
).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(
|
.select_related("issue")
|
||||||
issues,
|
.prefetch_related(
|
||||||
status=status.HTTP_200_OK,
|
"issue__labels",
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
label_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue__labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue__labels__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(request.GET.get("order_by", "-issue__created_at"))
|
||||||
|
# inbox status filter
|
||||||
|
inbox_status = [
|
||||||
|
item
|
||||||
|
for item in request.GET.get("status", "-2").split(",")
|
||||||
|
if item != "null"
|
||||||
|
]
|
||||||
|
if inbox_status:
|
||||||
|
inbox_issue = inbox_issue.filter(status__in=inbox_status)
|
||||||
|
|
||||||
|
return self.paginate(
|
||||||
|
request=request,
|
||||||
|
queryset=(inbox_issue),
|
||||||
|
on_results=lambda inbox_issues: InboxIssueSerializer(
|
||||||
|
inbox_issues,
|
||||||
|
many=True,
|
||||||
|
).data,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create(self, request, slug, project_id, inbox_id):
|
def create(self, request, slug, project_id):
|
||||||
if not request.data.get("issue", {}).get("name", False):
|
if not request.data.get("issue", {}).get("name", False):
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Name is required"},
|
{"error": "Name is required"},
|
||||||
@ -213,7 +217,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",
|
||||||
@ -228,49 +232,88 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
# Create or get state
|
# Create or get state
|
||||||
state, _ = State.objects.get_or_create(
|
state, _ = State.objects.get_or_create(
|
||||||
name="Triage",
|
name="Triage",
|
||||||
group="backlog",
|
group="triage",
|
||||||
description="Default state for managing all Inbox Issues",
|
description="Default state for managing all Inbox Issues",
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
color="#ff7700",
|
color="#ff7700",
|
||||||
|
is_triage=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# create an issue
|
# create an issue
|
||||||
issue = Issue.objects.create(
|
project = Project.objects.get(pk=project_id)
|
||||||
name=request.data.get("issue", {}).get("name"),
|
serializer = IssueCreateSerializer(
|
||||||
description=request.data.get("issue", {}).get("description", {}),
|
data=request.data.get("issue"),
|
||||||
description_html=request.data.get("issue", {}).get(
|
context={
|
||||||
"description_html", "<p></p>"
|
"project_id": project_id,
|
||||||
),
|
"workspace_id": project.workspace_id,
|
||||||
priority=request.data.get("issue", {}).get("priority", "low"),
|
"default_assignee_id": project.default_assignee_id,
|
||||||
project_id=project_id,
|
},
|
||||||
state=state,
|
|
||||||
)
|
)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
# Create an Issue Activity
|
||||||
|
issue_activity.delay(
|
||||||
|
type="issue.activity.created",
|
||||||
|
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||||
|
actor_id=str(request.user.id),
|
||||||
|
issue_id=str(serializer.data["id"]),
|
||||||
|
project_id=str(project_id),
|
||||||
|
current_instance=None,
|
||||||
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
|
)
|
||||||
|
inbox_id = Inbox.objects.filter(
|
||||||
|
workspace__slug=slug, project_id=project_id
|
||||||
|
).first()
|
||||||
|
# create an inbox issue
|
||||||
|
inbox_issue = InboxIssue.objects.create(
|
||||||
|
inbox_id=inbox_id.id,
|
||||||
|
project_id=project_id,
|
||||||
|
issue_id=serializer.data["id"],
|
||||||
|
source=request.data.get("source", "in-app"),
|
||||||
|
)
|
||||||
|
inbox_issue = (
|
||||||
|
InboxIssue.objects.select_related("issue")
|
||||||
|
.prefetch_related(
|
||||||
|
"issue__labels",
|
||||||
|
"issue__assignees",
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
label_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue__labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue__labels__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue__assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue__assignees__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
inbox_id=inbox_id.id,
|
||||||
|
issue_id=serializer.data["id"],
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
serializer = InboxIssueDetailSerializer(inbox_issue)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
# Create an Issue Activity
|
def partial_update(self, request, slug, project_id, issue_id):
|
||||||
issue_activity.delay(
|
inbox_id = Inbox.objects.filter(
|
||||||
type="issue.activity.created",
|
workspace__slug=slug, project_id=project_id
|
||||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
).first()
|
||||||
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"),
|
|
||||||
)
|
|
||||||
# create an inbox issue
|
|
||||||
InboxIssue.objects.create(
|
|
||||||
inbox_id=inbox_id,
|
|
||||||
project_id=project_id,
|
|
||||||
issue=issue,
|
|
||||||
source=request.data.get("source", "in-app"),
|
|
||||||
)
|
|
||||||
|
|
||||||
issue = self.get_queryset().filter(pk=issue.id).first()
|
|
||||||
serializer = IssueSerializer(issue, expand=self.expand)
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
|
|
||||||
inbox_issue = InboxIssue.objects.get(
|
inbox_issue = InboxIssue.objects.get(
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
@ -295,9 +338,12 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
|
|
||||||
# Get issue data
|
# Get issue data
|
||||||
issue_data = request.data.pop("issue", False)
|
issue_data = request.data.pop("issue", False)
|
||||||
|
|
||||||
if bool(issue_data):
|
if bool(issue_data):
|
||||||
issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first()
|
issue = Issue.objects.get(
|
||||||
|
pk=inbox_issue.issue_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
# Only allow guests and viewers to edit name and description
|
# Only allow guests and viewers to edit name and description
|
||||||
if project_member.role <= 10:
|
if project_member.role <= 10:
|
||||||
# viewers and guests since only viewers and guests
|
# viewers and guests since only viewers and guests
|
||||||
@ -373,7 +419,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update the issue state only if it is in triage state
|
# Update the issue state only if it is in triage state
|
||||||
if issue.state.name == "Triage":
|
if issue.state.is_triage:
|
||||||
# Move to default state
|
# Move to default state
|
||||||
state = State.objects.filter(
|
state = State.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
@ -383,57 +429,93 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
if state is not None:
|
if state is not None:
|
||||||
issue.state = state
|
issue.state = state
|
||||||
issue.save()
|
issue.save()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
inbox_issue = (
|
||||||
|
InboxIssue.objects.filter(
|
||||||
|
inbox_id=inbox_id.id,
|
||||||
|
issue_id=serializer.data["id"],
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.select_related("issue")
|
||||||
|
.prefetch_related(
|
||||||
|
"issue__labels",
|
||||||
|
"issue__assignees",
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
label_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue__labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue__labels__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value(
|
||||||
|
[],
|
||||||
|
output_field=ArrayField(UUIDField()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue__assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue__assignees__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value(
|
||||||
|
[],
|
||||||
|
output_field=ArrayField(UUIDField()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).first()
|
||||||
|
)
|
||||||
|
serializer = InboxIssueDetailSerializer(inbox_issue).data
|
||||||
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
return Response(
|
return Response(
|
||||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
serializer = InboxIssueDetailSerializer(inbox_issue).data
|
||||||
serializer = IssueSerializer(issue, expand=self.expand)
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
|
def retrieve(self, request, slug, project_id, issue_id):
|
||||||
issue = (
|
inbox_id = Inbox.objects.filter(
|
||||||
self.get_queryset()
|
workspace__slug=slug, project_id=project_id
|
||||||
.filter(pk=issue_id)
|
).first()
|
||||||
|
inbox_issue = (
|
||||||
|
InboxIssue.objects.select_related("issue")
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
"issue__labels",
|
||||||
"issue_reactions",
|
"issue__assignees",
|
||||||
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(
|
.annotate(
|
||||||
is_subscribed=Exists(
|
label_ids=Coalesce(
|
||||||
IssueSubscriber.objects.filter(
|
ArrayAgg(
|
||||||
workspace__slug=slug,
|
"issue__labels__id",
|
||||||
project_id=project_id,
|
distinct=True,
|
||||||
issue_id=OuterRef("pk"),
|
filter=~Q(issue__labels__id__isnull=True),
|
||||||
subscriber=request.user,
|
),
|
||||||
)
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
)
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue__assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue__assignees__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
.get(
|
||||||
|
inbox_id=inbox_id.id, issue_id=issue_id, project_id=project_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
issue = InboxIssueDetailSerializer(inbox_issue).data
|
||||||
|
return Response(
|
||||||
|
issue,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def destroy(self, request, slug, project_id, issue_id):
|
||||||
|
inbox_id = Inbox.objects.filter(
|
||||||
|
workspace__slug=slug, project_id=project_id
|
||||||
).first()
|
).first()
|
||||||
if issue is None:
|
|
||||||
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
serializer = IssueDetailSerializer(issue)
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
|
||||||
inbox_issue = InboxIssue.objects.get(
|
inbox_issue = InboxIssue.objects.get(
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
@ -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)
|
663
apiserver/plane/app/views/issue/base.py
Normal file
663
apiserver/plane/app/views/issue/base.py
Normal file
@ -0,0 +1,663 @@
|
|||||||
|
# Python imports
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
from django.db.models import (
|
||||||
|
Case,
|
||||||
|
CharField,
|
||||||
|
Exists,
|
||||||
|
F,
|
||||||
|
Func,
|
||||||
|
Max,
|
||||||
|
OuterRef,
|
||||||
|
Prefetch,
|
||||||
|
Q,
|
||||||
|
UUIDField,
|
||||||
|
Value,
|
||||||
|
When,
|
||||||
|
)
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.gzip import gzip_page
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
# Third Party imports
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from plane.app.permissions import (
|
||||||
|
ProjectEntityPermission,
|
||||||
|
ProjectLitePermission,
|
||||||
|
)
|
||||||
|
from plane.app.serializers import (
|
||||||
|
IssueCreateSerializer,
|
||||||
|
IssueDetailSerializer,
|
||||||
|
IssuePropertySerializer,
|
||||||
|
IssueSerializer,
|
||||||
|
)
|
||||||
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
|
from plane.db.models import (
|
||||||
|
Issue,
|
||||||
|
IssueAttachment,
|
||||||
|
IssueLink,
|
||||||
|
IssueProperty,
|
||||||
|
IssueReaction,
|
||||||
|
IssueSubscriber,
|
||||||
|
Project,
|
||||||
|
)
|
||||||
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
364
apiserver/plane/app/views/issue/draft.py
Normal file
364
apiserver/plane/app/views/issue/draft.py
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
# 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,332 @@ 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"),
|
||||||
|
)
|
||||||
|
cancelled_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="cancelled",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
completed_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="completed",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
started_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="started",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
unstarted_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="unstarted",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
backlog_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="backlog",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
total_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(archived_at__isnull=False)
|
||||||
|
.annotate(is_favorite=Exists(favorite_subquery))
|
||||||
|
.select_related("workspace", "project", "lead")
|
||||||
|
.prefetch_related("members")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"link_module",
|
||||||
|
queryset=ModuleLink.objects.select_related(
|
||||||
|
"module", "created_by"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Coalesce(
|
||||||
|
Subquery(completed_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Coalesce(
|
||||||
|
Subquery(cancelled_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Coalesce(
|
||||||
|
Subquery(started_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Coalesce(
|
||||||
|
Subquery(unstarted_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Coalesce(
|
||||||
|
Subquery(backlog_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Coalesce(
|
||||||
|
Subquery(total_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
member_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"members__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(members__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "-created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id, pk=None):
|
||||||
|
if pk is None:
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
modules = queryset.values( # Required fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# Model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"description_text",
|
||||||
|
"description_html",
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"status",
|
||||||
|
"lead_id",
|
||||||
|
"member_ids",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
# computed fields
|
||||||
|
"total_issues",
|
||||||
|
"is_favorite",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"archived_at",
|
||||||
|
)
|
||||||
|
return Response(modules, status=status.HTTP_200_OK)
|
||||||
|
else:
|
||||||
|
queryset = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(pk=pk)
|
||||||
|
.annotate(
|
||||||
|
sub_issues=Issue.issue_objects.filter(
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
parent__isnull=False,
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(first_name=F("assignees__first_name"))
|
||||||
|
.annotate(last_name=F("assignees__last_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.values(
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"assignee_id",
|
||||||
|
"avatar",
|
||||||
|
"display_name",
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("first_name", "last_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
data = ModuleDetailSerializer(queryset.first()).data
|
||||||
|
data["distribution"] = {
|
||||||
|
"assignees": assignee_distribution,
|
||||||
|
"labels": label_distribution,
|
||||||
|
"completion_chart": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch the modules
|
||||||
|
modules = queryset.first()
|
||||||
|
if modules and modules.start_date and modules.target_date:
|
||||||
|
data["distribution"]["completion_chart"] = burndown_plot(
|
||||||
|
queryset=modules,
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
module_id=pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
data,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, module_id):
|
||||||
|
module = Module.objects.get(
|
||||||
|
pk=module_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
if module.status not in ["completed", "cancelled"]:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "Only completed or cancelled modules can be archived"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
module.archived_at = timezone.now()
|
||||||
|
module.save()
|
||||||
|
return Response(
|
||||||
|
{"archived_at": str(module.archived_at)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, request, slug, project_id, module_id):
|
||||||
|
module = Module.objects.get(
|
||||||
|
pk=module_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
module.archived_at = None
|
||||||
|
module.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
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)
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user