Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e433266fe5 | ||
|
|
72556d407a | ||
|
|
51be035a30 | ||
|
|
59d5452248 | ||
|
|
37b30fbce9 | ||
|
|
e9121b6798 | ||
|
|
9e3f7d0873 | ||
|
|
aa2d61cdf7 | ||
|
|
192482dc30 | ||
|
|
2fee027f2c | ||
|
|
5a745314b1 | ||
|
|
a2773e284f | ||
|
|
7fdfe39bf0 | ||
|
|
6bc133e3b1 | ||
|
|
80761d3507 | ||
|
|
7a5c6e0e96 | ||
|
|
9c13dbd957 | ||
|
|
8d9adf4d87 | ||
|
|
4ccb505f36 | ||
|
|
884856b021 | ||
|
|
552c66457a | ||
|
|
1363ef0b2d | ||
|
|
898cf98be3 | ||
|
|
cb632408f9 | ||
|
|
b930d98665 | ||
|
|
69e110f4a8 | ||
|
|
c3c6ef8830 | ||
|
|
8aca74c68d | ||
|
|
c97b994311 | ||
|
|
443b93f897 | ||
|
|
730e556bea | ||
|
|
f77f4b8221 | ||
|
|
5c4c3f5c04 | ||
|
|
73c91654eb | ||
|
|
578bd29f6f | ||
|
|
2e5e14556d | ||
|
|
ecac45e885 | ||
|
|
6ec9c64f7c | ||
|
|
f493a03f56 | ||
|
|
48c9b78397 | ||
|
|
9c29ad1a28 | ||
|
|
e3ac075ee2 | ||
|
|
171b664fe7 | ||
|
|
8c9d328c24 | ||
|
|
b2146098e2 | ||
|
|
4a06572f73 | ||
|
|
7f67fe12af | ||
|
|
d34df5c805 | ||
|
|
01702e9f66 | ||
|
|
b57c389c75 | ||
|
|
27acd1bec1 | ||
|
|
539afb0792 | ||
|
|
6c7ba3bc79 | ||
|
|
c8ab650008 | ||
|
|
89d019df57 | ||
|
|
535731141f | ||
|
|
4b30339a59 | ||
|
|
899771a678 | ||
|
|
8997ee2e3e | ||
|
|
31a2bbc4de | ||
|
|
6be72149d9 | ||
|
|
775d8fbd90 | ||
|
|
58d82313cc | ||
|
|
f32645458c | ||
|
|
e05bc3965c | ||
|
|
b07fec533c | ||
|
|
cc069b61aa | ||
|
|
2074bb97db | ||
|
|
f5151ae717 | ||
|
|
deb5ac0578 | ||
|
|
cead56cc52 | ||
|
|
cb78ccad1f | ||
|
|
6c6b7156bb | ||
|
|
8cc372679c | ||
|
|
94327b8311 | ||
|
|
7b88a2a88c | ||
|
|
47a7f60611 | ||
|
|
7a8aef4599 | ||
|
|
bc02e56e3c | ||
|
|
b03f6a81e2 | ||
|
|
b535d8a23c | ||
|
|
bce69bcbe1 | ||
|
|
1fa47a6c04 | ||
|
|
c16a5b9b71 | ||
|
|
a852e3cc52 | ||
|
|
ed8782757d | ||
|
|
549f6d0943 | ||
|
|
cb5198c883 | ||
|
|
b4adb82d40 | ||
|
|
3f1ce9907d | ||
|
|
da735f318a | ||
|
|
a08f401452 | ||
|
|
4861be2773 | ||
|
|
6a6ab5544a | ||
|
|
466f69a0b9 | ||
|
|
f188c9fdc5 | ||
|
|
4fb9ab3998 | ||
|
|
8d4e4a7485 | ||
|
|
edb4280ec1 | ||
|
|
099cd5955c | ||
|
|
93cfa13955 | ||
|
|
f231ac0a79 | ||
|
|
00757a6704 |
@@ -8,13 +8,13 @@ on:
|
||||
|
||||
env:
|
||||
CURRENT_BRANCH: ${{ github.ref_name }}
|
||||
SOURCE_BRANCH: ${{ secrets.SYNC_TARGET_BRANCH_NAME }} # The sync branch such as "sync/ce"
|
||||
TARGET_BRANCH: ${{ secrets.TARGET_BRANCH }} # The target branch that you would like to merge changes like develop
|
||||
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.REVIEWER }}
|
||||
REVIEWER: ${{ secrets.SYNC_PR_REVIEWER }}
|
||||
|
||||
jobs:
|
||||
Check_Branch:
|
||||
Check_Branch:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
else
|
||||
echo "MATCH=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
Auto_Merge:
|
||||
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
|
||||
needs: [Check_Branch]
|
||||
@@ -41,6 +41,11 @@ jobs:
|
||||
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)
|
||||
@@ -50,20 +55,6 @@ jobs:
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- id: git-author
|
||||
name: Setup Git CLI from Github Token
|
||||
run: |
|
||||
VIEWER_JSON=$(gh api graphql -f query='query { viewer { name login databaseId }}' --jq '.data.viewer')
|
||||
VIEWER_NAME=$(jq --raw-output '.name | values' <<< "${VIEWER_JSON}")
|
||||
VIEWER_LOGIN=$(jq --raw-output '.login' <<< "${VIEWER_JSON}")
|
||||
VIEWER_DATABASE_ID=$(jq --raw-output '.databaseId' <<< "${VIEWER_JSON}")
|
||||
|
||||
USER_NAME="${VIEWER_NAME:-${VIEWER_LOGIN}}"
|
||||
USER_EMAIL="${VIEWER_DATABASE_ID}+${VIEWER_LOGIN}@users.noreply.github.com"
|
||||
|
||||
git config --global user.name ${USER_NAME}
|
||||
git config --global user.email ${USER_EMAIL}
|
||||
|
||||
- name: Check for merge conflicts
|
||||
id: conflicts
|
||||
run: |
|
||||
@@ -88,10 +79,6 @@ jobs:
|
||||
- name: Create PR to Target Branch
|
||||
if: env.HAS_CONFLICTS == 'true'
|
||||
run: |
|
||||
# Use GitHub CLI to create PR and specify author and committer
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH \
|
||||
--title "sync: merge conflicts need to be resolved" \
|
||||
--body "" \
|
||||
--reviewer $REVIEWER )
|
||||
# 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"
|
||||
|
||||
|
||||
@@ -2,6 +2,27 @@ name: Branch Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-web:
|
||||
required: false
|
||||
description: "Build Web"
|
||||
type: boolean
|
||||
default: false
|
||||
build-space:
|
||||
required: false
|
||||
description: "Build Space"
|
||||
type: boolean
|
||||
default: false
|
||||
build-api:
|
||||
required: false
|
||||
description: "Build API"
|
||||
type: boolean
|
||||
default: false
|
||||
build-proxy:
|
||||
required: false
|
||||
description: "Build Proxy"
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -18,7 +39,7 @@ jobs:
|
||||
name: Build-Push Web/Space/API/Proxy Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
|
||||
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
|
||||
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
|
||||
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
|
||||
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
||||
@@ -74,7 +95,7 @@ jobs:
|
||||
- nginx/**
|
||||
|
||||
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.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@@ -126,7 +147,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
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.inputs.build-space=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@@ -178,7 +199,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
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.inputs.build-api=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@@ -230,7 +251,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
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.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@@ -280,4 +301,3 @@ jobs:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -4,70 +4,196 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
web-build:
|
||||
required: true
|
||||
required: false
|
||||
description: 'Build Web'
|
||||
type: boolean
|
||||
default: true
|
||||
space-build:
|
||||
required: true
|
||||
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:
|
||||
KUBE_CONFIG_FILE: ${{ secrets.KUBE_CONFIG }}
|
||||
BUILD_WEB: ${{ (github.event.inputs.web-build == '' && true) || github.event.inputs.web-build }}
|
||||
BUILD_SPACE: ${{ (github.event.inputs.space-build == '' && false) || github.event.inputs.space-build }}
|
||||
|
||||
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/${{secrets.KUBE_VERSION}}/bin/linux/amd64/kubectl"
|
||||
curl -LO "https://dl.k8s.io/release/${{ vars.FEATURE_PREVIEW_KUBE_VERSION }}/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
|
||||
mkdir -p ~/.kube
|
||||
echo "$KUBE_CONFIG_FILE" > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: HELM Setup
|
||||
run: |
|
||||
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
|
||||
chmod 700 get_helm.sh
|
||||
./get_helm.sh
|
||||
|
||||
- name: App Deploy
|
||||
run: |
|
||||
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ secrets.FEATURE_PREVIEW_HELM_CHART_URL }}
|
||||
GIT_BRANCH=${{ github.ref_name }}
|
||||
APP_NAMESPACE=${{ secrets.FEATURE_PREVIEW_NAMESPACE }}
|
||||
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
|
||||
|
||||
METADATA=$(helm install feature-preview/${{ secrets.FEATURE_PREVIEW_HELM_CHART_NAME }} \
|
||||
--kube-insecure-skip-tls-verify \
|
||||
--generate-name \
|
||||
--namespace $APP_NAMESPACE \
|
||||
--set shared_config.git_repo=${{github.server_url}}/${{ github.repository }}.git \
|
||||
--set shared_config.git_branch="$GIT_BRANCH" \
|
||||
--set web.enabled=${{ env.BUILD_WEB }} \
|
||||
--set space.enabled=${{ env.BUILD_SPACE }} \
|
||||
--output json \
|
||||
--timeout 1000s)
|
||||
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
|
||||
|
||||
APP_NAME=$(echo $METADATA | jq -r '.name')
|
||||
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then
|
||||
|
||||
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')
|
||||
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
|
||||
|
||||
echo "****************************************"
|
||||
echo "APP NAME ::: $APP_NAME"
|
||||
echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME"
|
||||
echo "****************************************"
|
||||
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
|
||||
|
||||
@@ -51,6 +51,7 @@ staticfiles
|
||||
mediafiles
|
||||
.env
|
||||
.DS_Store
|
||||
logs/
|
||||
|
||||
node_modules/
|
||||
assets/dist/
|
||||
|
||||
@@ -14,10 +14,6 @@ POSTGRES_HOST="plane-db"
|
||||
POSTGRES_DB="plane"
|
||||
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_HOST="plane-redis"
|
||||
@@ -34,11 +30,6 @@ AWS_S3_BUCKET_NAME="uploads"
|
||||
# Maximum file upload limit
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# GPT settings
|
||||
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
|
||||
OPENAI_API_KEY="sk-" # deprecated
|
||||
GPT_ENGINE="gpt-3.5-turbo" # deprecated
|
||||
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1 # deprecated
|
||||
|
||||
@@ -48,19 +39,8 @@ USE_MINIO=1
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
|
||||
|
||||
# SignUps
|
||||
ENABLE_SIGNUP="1"
|
||||
|
||||
# Enable Email/Password Signup
|
||||
ENABLE_EMAIL_PASSWORD="1"
|
||||
|
||||
# Enable Magic link Login
|
||||
ENABLE_MAGIC_LINK_LOGIN="0"
|
||||
|
||||
# Email redirections and minio domain settings
|
||||
WEB_URL="http://localhost"
|
||||
|
||||
# Gunicorn Workers
|
||||
GUNICORN_WORKERS=2
|
||||
|
||||
|
||||
@@ -48,8 +48,10 @@ USER root
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY ./bin ./bin/
|
||||
|
||||
RUN mkdir /code/plane/logs
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
|
||||
RUN chmod -R 777 /code
|
||||
RUN chown -R captain:plane /code
|
||||
|
||||
USER captain
|
||||
|
||||
|
||||
@@ -21,11 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
|
||||
export MACHINE_SIGNATURE=$SIGNATURE
|
||||
|
||||
# Register instance
|
||||
python manage.py register_instance $MACHINE_SIGNATURE
|
||||
python manage.py register_instance "$MACHINE_SIGNATURE"
|
||||
|
||||
# Load the configuration variable
|
||||
python manage.py configure_instance
|
||||
|
||||
# Create the default bucket
|
||||
python manage.py create_bucket
|
||||
|
||||
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
# Clear Cache before starting to remove stale values
|
||||
python manage.py clear_cache
|
||||
|
||||
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
|
||||
@@ -21,12 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
|
||||
export MACHINE_SIGNATURE=$SIGNATURE
|
||||
|
||||
# Register instance
|
||||
python manage.py register_instance $MACHINE_SIGNATURE
|
||||
python manage.py register_instance "$MACHINE_SIGNATURE"
|
||||
# Load the configuration variable
|
||||
python manage.py configure_instance
|
||||
|
||||
# Create the default bucket
|
||||
python manage.py create_bucket
|
||||
|
||||
# Clear Cache before starting to remove stale values
|
||||
python manage.py clear_cache
|
||||
|
||||
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ from plane.db.models import (
|
||||
Project,
|
||||
ProjectIdentifier,
|
||||
WorkspaceMember,
|
||||
State,
|
||||
Estimate,
|
||||
)
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
# Python imports
|
||||
import zoneinfo
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import zoneinfo
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.api.middleware.api_authentication import APIKeyAuthentication
|
||||
from plane.api.rate_limit import ApiKeyRateThrottle
|
||||
from plane.utils.paginator import BasePaginator
|
||||
from plane.bgtasks.webhook_task import send_webhook
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
|
||||
class TimezoneMixin:
|
||||
@@ -107,27 +106,23 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
||||
|
||||
if isinstance(e, ValidationError):
|
||||
return Response(
|
||||
{
|
||||
"error": "The provided payload is not valid please try with a valid payload"
|
||||
},
|
||||
{"error": "Please provide valid detail"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if isinstance(e, ObjectDoesNotExist):
|
||||
return Response(
|
||||
{"error": f"The required object does not exist."},
|
||||
{"error": "The requested resource does not exist."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if isinstance(e, KeyError):
|
||||
return Response(
|
||||
{"error": f" The required key does not exist."},
|
||||
{"error": "The required key does not exist."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q, Count, Sum, Prefetch, F, OuterRef, Func
|
||||
from django.db.models import Q, Count, Sum, F, OuterRef, Func
|
||||
from django.utils import timezone
|
||||
from django.core import serializers
|
||||
|
||||
@@ -321,7 +321,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
and Cycle.objects.filter(
|
||||
project_id=project_id,
|
||||
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"),
|
||||
).exists()
|
||||
):
|
||||
|
||||
@@ -119,7 +119,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
# Check for valid priority
|
||||
if not request.data.get("issue", {}).get("priority", "none") in [
|
||||
if request.data.get("issue", {}).get("priority", "none") not in [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
# Python imports
|
||||
import json
|
||||
from itertools import chain
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import (
|
||||
OuterRef,
|
||||
Func,
|
||||
Q,
|
||||
F,
|
||||
Case,
|
||||
When,
|
||||
Value,
|
||||
CharField,
|
||||
Max,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
Max,
|
||||
OuterRef,
|
||||
Q,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
@@ -24,30 +24,31 @@ from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView, WebhookMixin
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectMemberPermission,
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
Project,
|
||||
Label,
|
||||
ProjectMember,
|
||||
IssueComment,
|
||||
IssueActivity,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.api.serializers import (
|
||||
IssueActivitySerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueActivitySerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
ProjectMemberPermission,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueAttachment,
|
||||
IssueComment,
|
||||
IssueLink,
|
||||
Label,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
|
||||
from .base import BaseAPIView, WebhookMixin
|
||||
|
||||
|
||||
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
@@ -653,7 +654,6 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
|
||||
# Validation check if the issue already exists
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -679,7 +679,6 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
|
||||
serializer = IssueCommentSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
@@ -717,7 +716,10 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
# Validation check if the issue already exists
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and (issue_comment.external_id != str(request.data.get("external_id")))
|
||||
and (
|
||||
issue_comment.external_id
|
||||
!= str(request.data.get("external_id"))
|
||||
)
|
||||
and IssueComment.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
@@ -735,7 +737,6 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
|
||||
serializer = IssueCommentSerializer(
|
||||
issue_comment, data=request.data, partial=True
|
||||
)
|
||||
|
||||
@@ -178,7 +178,9 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
and Module.objects.filter(
|
||||
project_id=project_id,
|
||||
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"),
|
||||
).exists()
|
||||
):
|
||||
|
||||
@@ -11,7 +11,6 @@ from rest_framework.serializers import ValidationError
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
ProjectFavorite,
|
||||
ProjectMember,
|
||||
ProjectDeployBoard,
|
||||
State,
|
||||
@@ -150,7 +149,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
serializer.save()
|
||||
|
||||
# Add the user as Administrator to the project
|
||||
project_member = ProjectMember.objects.create(
|
||||
_ = ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
member=request.user,
|
||||
role=20,
|
||||
@@ -245,12 +244,12 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except Workspace.DoesNotExist as e:
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ValidationError as e:
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
@@ -307,7 +306,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
{"error": "Project does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ValidationError as e:
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
|
||||
@@ -66,8 +66,10 @@ class StateAPIEndpoint(BaseAPIView):
|
||||
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError as e:
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except IntegrityError:
|
||||
state = State.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -136,7 +138,9 @@ class StateAPIEndpoint(BaseAPIView):
|
||||
and State.objects.filter(
|
||||
project_id=project_id,
|
||||
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"),
|
||||
).exists()
|
||||
):
|
||||
|
||||
@@ -86,16 +86,6 @@ from .module import (
|
||||
|
||||
from .api import APITokenSerializer, APITokenReadSerializer
|
||||
|
||||
from .integration import (
|
||||
IntegrationSerializer,
|
||||
WorkspaceIntegrationSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
SlackProjectSyncSerializer,
|
||||
)
|
||||
|
||||
from .importer import ImporterSerializer
|
||||
|
||||
from .page import (
|
||||
@@ -121,7 +111,10 @@ from .inbox import (
|
||||
|
||||
from .analytic import AnalyticViewSerializer
|
||||
|
||||
from .notification import NotificationSerializer, UserNotificationPreferenceSerializer
|
||||
from .notification import (
|
||||
NotificationSerializer,
|
||||
UserNotificationPreferenceSerializer,
|
||||
)
|
||||
|
||||
from .exporter import ExporterHistorySerializer
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from plane.db.models import (
|
||||
CycleUserProperties,
|
||||
)
|
||||
|
||||
|
||||
class CycleWriteSerializer(BaseSerializer):
|
||||
def validate(self, data):
|
||||
if (
|
||||
@@ -47,7 +48,6 @@ class CycleSerializer(BaseSerializer):
|
||||
# active | draft | upcoming | completed
|
||||
status = serializers.CharField(read_only=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Cycle
|
||||
fields = [
|
||||
|
||||
@@ -18,9 +18,4 @@ class WidgetSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Widget
|
||||
fields = [
|
||||
"id",
|
||||
"key",
|
||||
"is_visible",
|
||||
"widget_filters"
|
||||
]
|
||||
fields = ["id", "key", "is_visible", "widget_filters"]
|
||||
|
||||
@@ -74,5 +74,3 @@ class WorkspaceEstimateSerializer(BaseSerializer):
|
||||
"name",
|
||||
"description",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
|
||||
from .github import (
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
from .slack import SlackProjectSyncSerializer
|
||||
@@ -1,22 +0,0 @@
|
||||
# Module imports
|
||||
from plane.app.serializers import BaseSerializer
|
||||
from plane.db.models import Integration, WorkspaceIntegration
|
||||
|
||||
|
||||
class IntegrationSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Integration
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"verified",
|
||||
]
|
||||
|
||||
|
||||
class WorkspaceIntegrationSerializer(BaseSerializer):
|
||||
integration_detail = IntegrationSerializer(
|
||||
read_only=True, source="integration"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceIntegration
|
||||
fields = "__all__"
|
||||
@@ -1,45 +0,0 @@
|
||||
# Module imports
|
||||
from plane.app.serializers import BaseSerializer
|
||||
from plane.db.models import (
|
||||
GithubIssueSync,
|
||||
GithubRepository,
|
||||
GithubRepositorySync,
|
||||
GithubCommentSync,
|
||||
)
|
||||
|
||||
|
||||
class GithubRepositorySerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = GithubRepository
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class GithubRepositorySyncSerializer(BaseSerializer):
|
||||
repo_detail = GithubRepositorySerializer(source="repository")
|
||||
|
||||
class Meta:
|
||||
model = GithubRepositorySync
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class GithubIssueSyncSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = GithubIssueSync
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
"repository_sync",
|
||||
]
|
||||
|
||||
|
||||
class GithubCommentSyncSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = GithubCommentSync
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
"repository_sync",
|
||||
"issue_sync",
|
||||
]
|
||||
@@ -1,14 +0,0 @@
|
||||
# Module imports
|
||||
from plane.app.serializers import BaseSerializer
|
||||
from plane.db.models import SlackProjectSync
|
||||
|
||||
|
||||
class SlackProjectSyncSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = SlackProjectSync
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
"workspace_integration",
|
||||
]
|
||||
@@ -9,7 +9,7 @@ from rest_framework import serializers
|
||||
# Module imports
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from plane.db.models import (
|
||||
@@ -33,7 +33,6 @@ from plane.db.models import (
|
||||
IssueVote,
|
||||
IssueRelation,
|
||||
State,
|
||||
Project,
|
||||
)
|
||||
|
||||
|
||||
@@ -472,7 +471,6 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueLinkLiteSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IssueLink
|
||||
fields = [
|
||||
@@ -503,7 +501,6 @@ class IssueAttachmentSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
fields = [
|
||||
@@ -532,7 +529,6 @@ class IssueReactionSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueReactionLiteSerializer(DynamicBaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IssueReaction
|
||||
fields = [
|
||||
@@ -628,15 +624,18 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
# ids
|
||||
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
module_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(), required=False,
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Many to many
|
||||
label_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(), required=False,
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
)
|
||||
assignee_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(), required=False,
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Count items
|
||||
@@ -676,19 +675,7 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
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 Meta:
|
||||
model = Issue
|
||||
fields = [
|
||||
|
||||
@@ -3,7 +3,6 @@ from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
|
||||
from plane.db.models import (
|
||||
@@ -142,7 +141,6 @@ class ModuleIssueSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ModuleLinkSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ModuleLink
|
||||
fields = "__all__"
|
||||
@@ -215,13 +213,12 @@ class ModuleSerializer(DynamicBaseSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
|
||||
class ModuleDetailSerializer(ModuleSerializer):
|
||||
|
||||
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
||||
sub_issues = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta(ModuleSerializer.Meta):
|
||||
fields = ModuleSerializer.Meta.fields + ['link_module']
|
||||
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
|
||||
|
||||
|
||||
class ModuleFavoriteSerializer(BaseSerializer):
|
||||
|
||||
@@ -15,7 +15,6 @@ class NotificationSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class UserNotificationPreferenceSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = UserNotificationPreference
|
||||
fields = "__all__"
|
||||
|
||||
@@ -3,7 +3,7 @@ from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .issue import IssueFlatSerializer, LabelLiteSerializer
|
||||
from .issue import LabelLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from plane.db.models import (
|
||||
@@ -12,8 +12,6 @@ from plane.db.models import (
|
||||
PageFavorite,
|
||||
PageLabel,
|
||||
Label,
|
||||
Issue,
|
||||
Module,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,12 @@ class ProjectLiteSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ProjectListSerializer(DynamicBaseSerializer):
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
archived_issues = serializers.IntegerField(read_only=True)
|
||||
archived_sub_issues = serializers.IntegerField(read_only=True)
|
||||
draft_issues = serializers.IntegerField(read_only=True)
|
||||
draft_sub_issues = serializers.IntegerField(read_only=True)
|
||||
sub_issues = serializers.IntegerField(read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_cycles = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -4,7 +4,6 @@ from rest_framework import serializers
|
||||
# Module import
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import User, Workspace, WorkspaceMemberInvite
|
||||
from plane.license.models import InstanceAdmin, Instance
|
||||
|
||||
|
||||
class UserSerializer(BaseSerializer):
|
||||
@@ -99,13 +98,13 @@ class UserMeSettingsSerializer(BaseSerializer):
|
||||
).first()
|
||||
return {
|
||||
"last_workspace_id": obj.last_workspace_id,
|
||||
"last_workspace_slug": workspace.slug
|
||||
if workspace is not None
|
||||
else "",
|
||||
"last_workspace_slug": (
|
||||
workspace.slug if workspace is not None else ""
|
||||
),
|
||||
"fallback_workspace_id": obj.last_workspace_id,
|
||||
"fallback_workspace_slug": workspace.slug
|
||||
if workspace is not None
|
||||
else "",
|
||||
"fallback_workspace_slug": (
|
||||
workspace.slug if workspace is not None else ""
|
||||
),
|
||||
"invites": workspace_invites,
|
||||
}
|
||||
else:
|
||||
@@ -120,12 +119,16 @@ class UserMeSettingsSerializer(BaseSerializer):
|
||||
return {
|
||||
"last_workspace_id": None,
|
||||
"last_workspace_slug": None,
|
||||
"fallback_workspace_id": fallback_workspace.id
|
||||
if fallback_workspace is not None
|
||||
else None,
|
||||
"fallback_workspace_slug": fallback_workspace.slug
|
||||
if fallback_workspace is not None
|
||||
else None,
|
||||
"fallback_workspace_id": (
|
||||
fallback_workspace.id
|
||||
if fallback_workspace is not None
|
||||
else None
|
||||
),
|
||||
"fallback_workspace_slug": (
|
||||
fallback_workspace.slug
|
||||
if fallback_workspace is not None
|
||||
else None
|
||||
),
|
||||
"invites": workspace_invites,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Python imports
|
||||
import urllib
|
||||
import socket
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -6,9 +6,7 @@ from .cycle import urlpatterns as cycle_urls
|
||||
from .dashboard import urlpatterns as dashboard_urls
|
||||
from .estimate import urlpatterns as estimate_urls
|
||||
from .external import urlpatterns as external_urls
|
||||
from .importer import urlpatterns as importer_urls
|
||||
from .inbox import urlpatterns as inbox_urls
|
||||
from .integration import urlpatterns as integration_urls
|
||||
from .issue import urlpatterns as issue_urls
|
||||
from .module import urlpatterns as module_urls
|
||||
from .notification import urlpatterns as notification_urls
|
||||
@@ -32,9 +30,7 @@ urlpatterns = [
|
||||
*dashboard_urls,
|
||||
*estimate_urls,
|
||||
*external_urls,
|
||||
*importer_urls,
|
||||
*inbox_urls,
|
||||
*integration_urls,
|
||||
*issue_urls,
|
||||
*module_urls,
|
||||
*notification_urls,
|
||||
|
||||
@@ -2,7 +2,6 @@ from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import UnsplashEndpoint
|
||||
from plane.app.views import ReleaseNotesEndpoint
|
||||
from plane.app.views import GPTIntegrationEndpoint
|
||||
|
||||
|
||||
@@ -12,11 +11,6 @@ urlpatterns = [
|
||||
UnsplashEndpoint.as_view(),
|
||||
name="unsplash",
|
||||
),
|
||||
path(
|
||||
"release-notes/",
|
||||
ReleaseNotesEndpoint.as_view(),
|
||||
name="release-notes",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
|
||||
GPTIntegrationEndpoint.as_view(),
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import (
|
||||
ServiceIssueImportSummaryEndpoint,
|
||||
ImportServiceEndpoint,
|
||||
UpdateServiceImportStatusEndpoint,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/importers/<str:service>/",
|
||||
ServiceIssueImportSummaryEndpoint.as_view(),
|
||||
name="importer-summary",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/importers/<str:service>/",
|
||||
ImportServiceEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/importers/",
|
||||
ImportServiceEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/importers/<str:service>/<uuid:pk>/",
|
||||
ImportServiceEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/service/<str:service>/importers/<uuid:importer_id>/",
|
||||
UpdateServiceImportStatusEndpoint.as_view(),
|
||||
name="importer-status",
|
||||
),
|
||||
]
|
||||
@@ -1,150 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import (
|
||||
IntegrationViewSet,
|
||||
WorkspaceIntegrationViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
BulkCreateGithubIssueSyncEndpoint,
|
||||
SlackProjectSyncViewSet,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"integrations/",
|
||||
IntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="integrations",
|
||||
),
|
||||
path(
|
||||
"integrations/<uuid:pk>/",
|
||||
IntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/",
|
||||
WorkspaceIntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<str:provider>/",
|
||||
WorkspaceIntegrationViewSet.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<uuid:pk>/provider/",
|
||||
WorkspaceIntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
# Github Integrations
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<uuid:workspace_integration_id>/github-repositories/",
|
||||
GithubRepositoriesEndpoint.as_view(),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/",
|
||||
GithubRepositorySyncViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/<uuid:pk>/",
|
||||
GithubRepositorySyncViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/",
|
||||
GithubIssueSyncViewSet.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/bulk-create-github-issue-sync/",
|
||||
BulkCreateGithubIssueSyncEndpoint.as_view(),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
|
||||
GithubIssueSyncViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/",
|
||||
GithubCommentSyncViewSet.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/<uuid:pk>/",
|
||||
GithubCommentSyncViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
),
|
||||
## End Github Integrations
|
||||
# Slack Integration
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/",
|
||||
SlackProjectSyncViewSet.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/<uuid:pk>/",
|
||||
SlackProjectSyncViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
"get": "retrieve",
|
||||
}
|
||||
),
|
||||
),
|
||||
## End Slack Integration
|
||||
]
|
||||
@@ -1,30 +1,26 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import (
|
||||
IssueListEndpoint,
|
||||
IssueViewSet,
|
||||
LabelViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
BulkDeleteIssuesEndpoint,
|
||||
BulkImportIssuesEndpoint,
|
||||
UserWorkSpaceIssues,
|
||||
SubIssuesEndpoint,
|
||||
IssueLinkViewSet,
|
||||
IssueAttachmentEndpoint,
|
||||
CommentReactionViewSet,
|
||||
ExportIssuesEndpoint,
|
||||
IssueActivityEndpoint,
|
||||
IssueCommentViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
IssueReactionViewSet,
|
||||
CommentReactionViewSet,
|
||||
IssueUserDisplayPropertyEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueRelationViewSet,
|
||||
IssueCommentViewSet,
|
||||
IssueDraftViewSet,
|
||||
IssueListEndpoint,
|
||||
IssueReactionViewSet,
|
||||
IssueRelationViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
IssueUserDisplayPropertyEndpoint,
|
||||
IssueViewSet,
|
||||
LabelViewSet,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/list/",
|
||||
@@ -85,18 +81,7 @@ urlpatterns = [
|
||||
BulkDeleteIssuesEndpoint.as_view(),
|
||||
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(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
|
||||
SubIssuesEndpoint.as_view(),
|
||||
|
||||
@@ -6,7 +6,6 @@ from plane.app.views import (
|
||||
ModuleIssueViewSet,
|
||||
ModuleLinkViewSet,
|
||||
ModuleFavoriteViewSet,
|
||||
BulkImportModulesEndpoint,
|
||||
ModuleUserPropertiesEndpoint,
|
||||
)
|
||||
|
||||
@@ -106,11 +105,6 @@ urlpatterns = [
|
||||
),
|
||||
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(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/user-properties/",
|
||||
ModuleUserPropertiesEndpoint.as_view(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,26 @@
|
||||
from .project import (
|
||||
from .project.base import (
|
||||
ProjectViewSet,
|
||||
ProjectMemberViewSet,
|
||||
UserProjectInvitationsViewset,
|
||||
ProjectInvitationsViewset,
|
||||
AddTeamToProjectEndpoint,
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectJoinEndpoint,
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectMemberUserEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
ProjectDeployBoardViewSet,
|
||||
)
|
||||
|
||||
from .project.invite import (
|
||||
UserProjectInvitationsViewset,
|
||||
ProjectInvitationsViewset,
|
||||
ProjectJoinEndpoint,
|
||||
)
|
||||
|
||||
from .project.member import (
|
||||
ProjectMemberViewSet,
|
||||
AddTeamToProjectEndpoint,
|
||||
ProjectMemberUserEndpoint,
|
||||
UserProjectRolesEndpoint,
|
||||
)
|
||||
from .user import (
|
||||
|
||||
from .user.base import (
|
||||
UserEndpoint,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
UpdateUserTourCompletedEndpoint,
|
||||
@@ -24,71 +31,121 @@ from .oauth import OauthEndpoint
|
||||
|
||||
from .base import BaseAPIView, BaseViewSet, WebhookMixin
|
||||
|
||||
from .workspace import (
|
||||
from .workspace.base import (
|
||||
WorkSpaceViewSet,
|
||||
UserWorkSpacesEndpoint,
|
||||
WorkSpaceAvailabilityCheckEndpoint,
|
||||
WorkspaceJoinEndpoint,
|
||||
WorkSpaceMemberViewSet,
|
||||
TeamMemberViewSet,
|
||||
WorkspaceInvitationsViewset,
|
||||
UserWorkspaceInvitationsViewSet,
|
||||
UserLastProjectWithWorkspaceEndpoint,
|
||||
WorkspaceMemberUserEndpoint,
|
||||
WorkspaceMemberUserViewsEndpoint,
|
||||
UserActivityGraphEndpoint,
|
||||
UserIssueCompletedGraphEndpoint,
|
||||
UserWorkspaceDashboardEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
WorkspaceUserProfileStatsEndpoint,
|
||||
WorkspaceUserActivityEndpoint,
|
||||
WorkspaceUserProfileEndpoint,
|
||||
WorkspaceUserProfileIssuesEndpoint,
|
||||
WorkspaceLabelsEndpoint,
|
||||
ExportWorkspaceUserActivityEndpoint
|
||||
)
|
||||
|
||||
from .workspace.member import (
|
||||
WorkSpaceMemberViewSet,
|
||||
TeamMemberViewSet,
|
||||
WorkspaceMemberUserEndpoint,
|
||||
WorkspaceProjectMemberEndpoint,
|
||||
WorkspaceUserPropertiesEndpoint,
|
||||
WorkspaceMemberUserViewsEndpoint,
|
||||
)
|
||||
from .workspace.invite import (
|
||||
WorkspaceInvitationsViewset,
|
||||
WorkspaceJoinEndpoint,
|
||||
UserWorkspaceInvitationsViewSet,
|
||||
)
|
||||
from .workspace.label import (
|
||||
WorkspaceLabelsEndpoint,
|
||||
)
|
||||
from .workspace.state import (
|
||||
WorkspaceStatesEndpoint,
|
||||
)
|
||||
from .workspace.user import (
|
||||
UserLastProjectWithWorkspaceEndpoint,
|
||||
WorkspaceUserProfileIssuesEndpoint,
|
||||
WorkspaceUserPropertiesEndpoint,
|
||||
WorkspaceUserProfileEndpoint,
|
||||
WorkspaceUserActivityEndpoint,
|
||||
WorkspaceUserProfileStatsEndpoint,
|
||||
UserActivityGraphEndpoint,
|
||||
UserIssueCompletedGraphEndpoint,
|
||||
)
|
||||
from .workspace.estimate import (
|
||||
WorkspaceEstimatesEndpoint,
|
||||
ExportWorkspaceUserActivityEndpoint,
|
||||
)
|
||||
from .workspace.module import (
|
||||
WorkspaceModulesEndpoint,
|
||||
)
|
||||
from .workspace.cycle import (
|
||||
WorkspaceCyclesEndpoint,
|
||||
)
|
||||
from .state import StateViewSet
|
||||
from .view import (
|
||||
|
||||
from .state.base import StateViewSet
|
||||
from .view.base import (
|
||||
GlobalViewViewSet,
|
||||
GlobalViewIssuesViewSet,
|
||||
IssueViewViewSet,
|
||||
IssueViewFavoriteViewSet,
|
||||
)
|
||||
from .cycle import (
|
||||
from .cycle.base import (
|
||||
CycleViewSet,
|
||||
CycleIssueViewSet,
|
||||
CycleDateCheckEndpoint,
|
||||
CycleFavoriteViewSet,
|
||||
TransferCycleIssueEndpoint,
|
||||
CycleUserPropertiesEndpoint,
|
||||
)
|
||||
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||
from .issue import (
|
||||
from .cycle.issue import (
|
||||
CycleIssueViewSet,
|
||||
)
|
||||
|
||||
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||
from .issue.base import (
|
||||
IssueListEndpoint,
|
||||
IssueViewSet,
|
||||
WorkSpaceIssuesEndpoint,
|
||||
IssueActivityEndpoint,
|
||||
IssueCommentViewSet,
|
||||
IssueUserDisplayPropertyEndpoint,
|
||||
LabelViewSet,
|
||||
BulkDeleteIssuesEndpoint,
|
||||
UserWorkSpaceIssues,
|
||||
SubIssuesEndpoint,
|
||||
IssueLinkViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
)
|
||||
|
||||
from .issue.activity import (
|
||||
IssueActivityEndpoint,
|
||||
)
|
||||
|
||||
from .issue.archive import (
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
)
|
||||
|
||||
from .issue.attachment import (
|
||||
IssueAttachmentEndpoint,
|
||||
)
|
||||
|
||||
from .issue.comment import (
|
||||
IssueCommentViewSet,
|
||||
CommentReactionViewSet,
|
||||
IssueReactionViewSet,
|
||||
)
|
||||
|
||||
from .issue.draft import IssueDraftViewSet
|
||||
|
||||
from .issue.label import (
|
||||
LabelViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
)
|
||||
|
||||
from .issue.link import (
|
||||
IssueLinkViewSet,
|
||||
)
|
||||
|
||||
from .issue.relation import (
|
||||
IssueRelationViewSet,
|
||||
IssueDraftViewSet,
|
||||
)
|
||||
|
||||
from .issue.reaction import (
|
||||
IssueReactionViewSet,
|
||||
)
|
||||
|
||||
from .issue.sub_issue import (
|
||||
SubIssuesEndpoint,
|
||||
)
|
||||
|
||||
from .issue.subscriber import (
|
||||
IssueSubscriberViewSet,
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
@@ -107,36 +164,21 @@ from .authentication import (
|
||||
MagicSignInEndpoint,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
from .module.base import (
|
||||
ModuleViewSet,
|
||||
ModuleIssueViewSet,
|
||||
ModuleLinkViewSet,
|
||||
ModuleFavoriteViewSet,
|
||||
ModuleUserPropertiesEndpoint,
|
||||
)
|
||||
|
||||
from .module.issue import (
|
||||
ModuleIssueViewSet,
|
||||
)
|
||||
|
||||
from .api import ApiTokenEndpoint
|
||||
|
||||
from .integration import (
|
||||
WorkspaceIntegrationViewSet,
|
||||
IntegrationViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
BulkCreateGithubIssueSyncEndpoint,
|
||||
SlackProjectSyncViewSet,
|
||||
)
|
||||
|
||||
from .importer import (
|
||||
ServiceIssueImportSummaryEndpoint,
|
||||
ImportServiceEndpoint,
|
||||
UpdateServiceImportStatusEndpoint,
|
||||
BulkImportIssuesEndpoint,
|
||||
BulkImportModulesEndpoint,
|
||||
)
|
||||
|
||||
from .page import (
|
||||
from .page.base import (
|
||||
PageViewSet,
|
||||
PageFavoriteViewSet,
|
||||
PageLogEndpoint,
|
||||
@@ -146,20 +188,19 @@ from .page import (
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
|
||||
|
||||
from .external import (
|
||||
from .external.base import (
|
||||
GPTIntegrationEndpoint,
|
||||
ReleaseNotesEndpoint,
|
||||
UnsplashEndpoint,
|
||||
)
|
||||
|
||||
from .estimate import (
|
||||
from .estimate.base import (
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
)
|
||||
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet
|
||||
from .inbox.base import InboxViewSet, InboxIssueViewSet
|
||||
|
||||
from .analytic import (
|
||||
from .analytic.base import (
|
||||
AnalyticsEndpoint,
|
||||
AnalyticViewViewset,
|
||||
SavedAnalyticEndpoint,
|
||||
@@ -167,26 +208,23 @@ from .analytic import (
|
||||
DefaultAnalyticsEndpoint,
|
||||
)
|
||||
|
||||
from .notification import (
|
||||
from .notification.base import (
|
||||
NotificationViewSet,
|
||||
UnreadNotificationEndpoint,
|
||||
MarkAllReadNotificationViewSet,
|
||||
UserNotificationPreferenceEndpoint,
|
||||
)
|
||||
|
||||
from .exporter import ExportIssuesEndpoint
|
||||
from .exporter.base import ExportIssuesEndpoint
|
||||
|
||||
from .config import ConfigurationEndpoint, MobileConfigurationEndpoint
|
||||
|
||||
from .webhook import (
|
||||
from .webhook.base import (
|
||||
WebhookEndpoint,
|
||||
WebhookLogsEndpoint,
|
||||
WebhookSecretRegenerateEndpoint,
|
||||
)
|
||||
|
||||
from .dashboard import (
|
||||
DashboardEndpoint,
|
||||
WidgetsEndpoint
|
||||
)
|
||||
from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
|
||||
|
||||
from .error_404 import custom_404_view
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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.utils import timezone
|
||||
|
||||
@@ -10,7 +10,7 @@ from rest_framework.response import Response
|
||||
# Module imports
|
||||
from plane.app.views import BaseAPIView, BaseViewSet
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
from plane.db.models import Issue, AnalyticView, Workspace, State, Label
|
||||
from plane.db.models import Issue, AnalyticView, Workspace
|
||||
from plane.app.serializers import AnalyticViewSerializer
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.bgtasks.analytic_plot_export import analytic_export_task
|
||||
@@ -51,8 +51,8 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
if (
|
||||
not x_axis
|
||||
or not y_axis
|
||||
or not x_axis in valid_xaxis_segment
|
||||
or not y_axis in valid_yaxis
|
||||
or x_axis not in valid_xaxis_segment
|
||||
or y_axis not in valid_yaxis
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
@@ -266,8 +266,8 @@ class ExportAnalyticsEndpoint(BaseAPIView):
|
||||
if (
|
||||
not x_axis
|
||||
or not y_axis
|
||||
or not x_axis in valid_xaxis_segment
|
||||
or not y_axis in valid_yaxis
|
||||
or x_axis not in valid_xaxis_segment
|
||||
or y_axis not in valid_yaxis
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
@@ -43,7 +43,7 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
def get(self, request, slug, pk=None):
|
||||
if pk == None:
|
||||
if pk is None:
|
||||
api_tokens = APIToken.objects.filter(
|
||||
user=request.user, workspace__slug=slug
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView, BaseViewSet
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
from plane.db.models import FileAsset, Workspace
|
||||
from plane.app.serializers import FileAssetSerializer
|
||||
|
||||
@@ -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.core.validators import validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
## Third Party Imports
|
||||
from rest_framework import status
|
||||
@@ -172,7 +171,7 @@ class ResetPasswordEndpoint(BaseAPIView):
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
except DjangoUnicodeDecodeError as indentifier:
|
||||
except DjangoUnicodeDecodeError:
|
||||
return Response(
|
||||
{"error": "token is not valid, please check the new one"},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -7,7 +7,6 @@ import json
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Third party imports
|
||||
@@ -65,7 +64,7 @@ class SignUpEndpoint(BaseAPIView):
|
||||
email = email.strip().lower()
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError as e:
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"error": "Please provide a valid email address."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -151,7 +150,7 @@ class SignInEndpoint(BaseAPIView):
|
||||
email = email.strip().lower()
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError as e:
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"error": "Please provide a valid email address."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -238,9 +237,11 @@ class SignInEndpoint(BaseAPIView):
|
||||
[
|
||||
WorkspaceMember(
|
||||
workspace_id=project_member_invite.workspace_id,
|
||||
role=project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15,
|
||||
role=(
|
||||
project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15
|
||||
),
|
||||
member=user,
|
||||
created_by_id=project_member_invite.created_by_id,
|
||||
)
|
||||
@@ -254,9 +255,11 @@ class SignInEndpoint(BaseAPIView):
|
||||
[
|
||||
ProjectMember(
|
||||
workspace_id=project_member_invite.workspace_id,
|
||||
role=project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15,
|
||||
role=(
|
||||
project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15
|
||||
),
|
||||
member=user,
|
||||
created_by_id=project_member_invite.created_by_id,
|
||||
)
|
||||
@@ -392,9 +395,11 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
[
|
||||
WorkspaceMember(
|
||||
workspace_id=project_member_invite.workspace_id,
|
||||
role=project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15,
|
||||
role=(
|
||||
project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15
|
||||
),
|
||||
member=user,
|
||||
created_by_id=project_member_invite.created_by_id,
|
||||
)
|
||||
@@ -408,9 +413,11 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
[
|
||||
ProjectMember(
|
||||
workspace_id=project_member_invite.workspace_id,
|
||||
role=project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15,
|
||||
role=(
|
||||
project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15
|
||||
),
|
||||
member=user,
|
||||
created_by_id=project_member_invite.created_by_id,
|
||||
)
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
# Python imports
|
||||
import zoneinfo
|
||||
import json
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Django imports
|
||||
from django.urls import resolve
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.db import IntegrityError
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
# Third part imports
|
||||
from rest_framework import status
|
||||
from rest_framework import status
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from sentry_sdk import capture_exception
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
# Module imports
|
||||
from plane.utils.paginator import BasePaginator
|
||||
from plane.bgtasks.webhook_task import send_webhook
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
|
||||
class TimezoneMixin:
|
||||
@@ -90,7 +87,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
|
||||
try:
|
||||
return self.model.objects.all()
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
raise APIException(
|
||||
"Please check the view", status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
@@ -119,18 +116,18 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
|
||||
|
||||
if isinstance(e, ObjectDoesNotExist):
|
||||
return Response(
|
||||
{"error": f"The required object does not exist."},
|
||||
{"error": "The required object does not exist."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if isinstance(e, KeyError):
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": f"The required key does not exist."},
|
||||
{"error": "The required key does not exist."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -226,19 +223,17 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
||||
|
||||
if isinstance(e, ObjectDoesNotExist):
|
||||
return Response(
|
||||
{"error": f"The required object does not exist."},
|
||||
{"error": "The required object does not exist."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if isinstance(e, KeyError):
|
||||
return Response(
|
||||
{"error": f"The required key does not exist."},
|
||||
{"error": "The required key does not exist."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import os
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.permissions import AllowAny
|
||||
@@ -12,13 +11,14 @@ from rest_framework.response import Response
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
|
||||
from plane.utils.cache import cache_response
|
||||
|
||||
class ConfigurationEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
@cache_response(60 * 60 * 2, user=False)
|
||||
def get(self, request):
|
||||
# Get all the configuration
|
||||
(
|
||||
@@ -136,6 +136,7 @@ class MobileConfigurationEndpoint(BaseAPIView):
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
@cache_response(60 * 60 * 2, user=False)
|
||||
def get(self, request):
|
||||
(
|
||||
GOOGLE_CLIENT_ID,
|
||||
|
||||
@@ -10,19 +10,15 @@ from django.db.models import (
|
||||
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.fields import ArrayField
|
||||
from django.db.models import Value, UUIDField
|
||||
from django.db.models import UUIDField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party imports
|
||||
@@ -30,12 +26,10 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from .. import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
CycleSerializer,
|
||||
CycleIssueSerializer,
|
||||
CycleFavoriteSerializer,
|
||||
IssueSerializer,
|
||||
CycleWriteSerializer,
|
||||
CycleUserPropertiesSerializer,
|
||||
)
|
||||
@@ -49,13 +43,10 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
Issue,
|
||||
CycleFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
Label,
|
||||
CycleUserProperties,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
|
||||
@@ -107,15 +98,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(is_favorite=Exists(favorite_subquery))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
@@ -202,7 +184,15 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = self.get_queryset()
|
||||
queryset = self.get_queryset().annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
cycle_view = request.GET.get("cycle_view", "all")
|
||||
|
||||
# Update the order by
|
||||
@@ -356,8 +346,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"is_favorite",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@@ -403,7 +393,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"progress_snapshot",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@@ -427,9 +416,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
queryset = self.get_queryset().filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
cycle = queryset.first()
|
||||
request_data = request.data
|
||||
@@ -476,7 +464,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"progress_snapshot",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@@ -489,10 +476,42 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
queryset = self.get_queryset().filter(pk=pk)
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
data = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=True,
|
||||
issue_cycle__cycle_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=False,
|
||||
issue_cycle__cycle_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.values(
|
||||
# necessary fields
|
||||
"id",
|
||||
@@ -509,6 +528,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_source",
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
"sub_issues",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
@@ -662,276 +682,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
serializer_class = CycleIssueSerializer
|
||||
model = CycleIssue
|
||||
|
||||
webhook_event = "cycle_issue"
|
||||
bulk = True
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
"issue__labels__id",
|
||||
"issue__assignees__id",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("issue_id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(cycle_id=self.kwargs.get("cycle_id"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("cycle")
|
||||
.select_related("issue", "issue__state", "issue__project")
|
||||
.prefetch_related("issue__assignees", "issue__labels")
|
||||
.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),
|
||||
),
|
||||
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)
|
||||
|
||||
|
||||
class CycleDateCheckEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
@@ -0,0 +1,312 @@
|
||||
# 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(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),
|
||||
),
|
||||
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)
|
||||
+2
-4
@@ -9,7 +9,6 @@ from django.db.models import (
|
||||
F,
|
||||
Exists,
|
||||
OuterRef,
|
||||
Max,
|
||||
Subquery,
|
||||
JSONField,
|
||||
Func,
|
||||
@@ -18,7 +17,7 @@ from django.db.models import (
|
||||
)
|
||||
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 import UUIDField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -27,7 +26,7 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from . import BaseAPIView
|
||||
from .. import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueActivity,
|
||||
@@ -39,7 +38,6 @@ from plane.db.models import (
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueRelation,
|
||||
IssueAssignee,
|
||||
User,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
@@ -3,7 +3,7 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from ..base import BaseViewSet, BaseAPIView
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Project, Estimate, EstimatePoint
|
||||
from plane.app.serializers import (
|
||||
@@ -11,7 +11,7 @@ from plane.app.serializers import (
|
||||
EstimatePointSerializer,
|
||||
EstimateReadSerializer,
|
||||
)
|
||||
|
||||
from plane.utils.cache import invalidate_cache
|
||||
|
||||
class ProjectEstimatePointEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
@@ -49,6 +49,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
serializer = EstimateReadSerializer(estimates, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
|
||||
def create(self, request, slug, project_id):
|
||||
if not request.data.get("estimate", False):
|
||||
return Response(
|
||||
@@ -114,6 +115,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
|
||||
def partial_update(self, request, slug, project_id, estimate_id):
|
||||
if not request.data.get("estimate", False):
|
||||
return Response(
|
||||
@@ -182,6 +184,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
|
||||
def destroy(self, request, slug, project_id, estimate_id):
|
||||
estimate = Estimate.objects.get(
|
||||
pk=estimate_id, workspace__slug=slug, project_id=project_id
|
||||
@@ -3,7 +3,7 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from . import BaseAPIView
|
||||
from .. import BaseAPIView
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
from plane.bgtasks.export_task import issue_export_task
|
||||
from plane.db.models import Project, ExporterHistory, Workspace
|
||||
@@ -50,7 +50,7 @@ class ExportIssuesEndpoint(BaseAPIView):
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"message": f"Once the export is ready you will be able to download it"
|
||||
"message": "Once the export is ready you will be able to download it"
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
+1
-9
@@ -8,17 +8,15 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from ..base import BaseAPIView
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Workspace, Project
|
||||
from plane.app.serializers import (
|
||||
ProjectLiteSerializer,
|
||||
WorkspaceLiteSerializer,
|
||||
)
|
||||
from plane.utils.integrations.github import get_release_notes
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
|
||||
|
||||
@@ -85,12 +83,6 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
|
||||
class ReleaseNotesEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
release_notes = get_release_notes()
|
||||
return Response(release_notes, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UnsplashEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
(UNSPLASH_ACCESS_KEY,) = get_configuration_value(
|
||||
@@ -1,558 +0,0 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Max, Q
|
||||
|
||||
# Module imports
|
||||
from plane.app.views import BaseAPIView
|
||||
from plane.db.models import (
|
||||
WorkspaceIntegration,
|
||||
Importer,
|
||||
APIToken,
|
||||
Project,
|
||||
State,
|
||||
IssueSequence,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueComment,
|
||||
IssueLink,
|
||||
IssueLabel,
|
||||
Workspace,
|
||||
IssueAssignee,
|
||||
Module,
|
||||
ModuleLink,
|
||||
ModuleIssue,
|
||||
Label,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
ImporterSerializer,
|
||||
IssueFlatSerializer,
|
||||
ModuleSerializer,
|
||||
)
|
||||
from plane.utils.integrations.github import get_github_repo_details
|
||||
from plane.utils.importers.jira import (
|
||||
jira_project_issue_summary,
|
||||
is_allowed_hostname,
|
||||
)
|
||||
from plane.bgtasks.importer_task import service_importer
|
||||
from plane.utils.html_processor import strip_tags
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
|
||||
|
||||
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, service):
|
||||
if service == "github":
|
||||
owner = request.GET.get("owner", False)
|
||||
repo = request.GET.get("repo", False)
|
||||
|
||||
if not owner or not repo:
|
||||
return Response(
|
||||
{"error": "Owner and repo are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
integration__provider="github", workspace__slug=slug
|
||||
)
|
||||
|
||||
access_tokens_url = workspace_integration.metadata.get(
|
||||
"access_tokens_url", False
|
||||
)
|
||||
|
||||
if not access_tokens_url:
|
||||
return Response(
|
||||
{
|
||||
"error": "There was an error during the installation of the GitHub app. To resolve this issue, we recommend reinstalling the GitHub app."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
issue_count, labels, collaborators = get_github_repo_details(
|
||||
access_tokens_url, owner, repo
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"issue_count": issue_count,
|
||||
"labels": labels,
|
||||
"collaborators": collaborators,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if service == "jira":
|
||||
# Check for all the keys
|
||||
params = {
|
||||
"project_key": "Project key is required",
|
||||
"api_token": "API token is required",
|
||||
"email": "Email is required",
|
||||
"cloud_hostname": "Cloud hostname is required",
|
||||
}
|
||||
|
||||
for key, error_message in params.items():
|
||||
if not request.GET.get(key, False):
|
||||
return Response(
|
||||
{"error": error_message},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
project_key = request.GET.get("project_key", "")
|
||||
api_token = request.GET.get("api_token", "")
|
||||
email = request.GET.get("email", "")
|
||||
cloud_hostname = request.GET.get("cloud_hostname", "")
|
||||
|
||||
response = jira_project_issue_summary(
|
||||
email, api_token, project_key, cloud_hostname
|
||||
)
|
||||
if "error" in response:
|
||||
return Response(response, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
response,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(
|
||||
{"error": "Service not supported yet"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ImportServiceEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, service):
|
||||
project_id = request.data.get("project_id", False)
|
||||
|
||||
if not project_id:
|
||||
return Response(
|
||||
{"error": "Project ID is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
if service == "github":
|
||||
data = request.data.get("data", False)
|
||||
metadata = request.data.get("metadata", False)
|
||||
config = request.data.get("config", False)
|
||||
if not data or not metadata or not config:
|
||||
return Response(
|
||||
{"error": "Data, config and metadata are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
api_token = APIToken.objects.filter(
|
||||
user=request.user, workspace=workspace
|
||||
).first()
|
||||
if api_token is None:
|
||||
api_token = APIToken.objects.create(
|
||||
user=request.user,
|
||||
label="Importer",
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
importer = Importer.objects.create(
|
||||
service=service,
|
||||
project_id=project_id,
|
||||
status="queued",
|
||||
initiated_by=request.user,
|
||||
data=data,
|
||||
metadata=metadata,
|
||||
token=api_token,
|
||||
config=config,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
|
||||
service_importer.delay(service, importer.id)
|
||||
serializer = ImporterSerializer(importer)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
if service == "jira":
|
||||
data = request.data.get("data", False)
|
||||
metadata = request.data.get("metadata", False)
|
||||
config = request.data.get("config", False)
|
||||
|
||||
cloud_hostname = metadata.get("cloud_hostname", False)
|
||||
|
||||
if not cloud_hostname:
|
||||
return Response(
|
||||
{"error": "Cloud hostname is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not is_allowed_hostname(cloud_hostname):
|
||||
return Response(
|
||||
{"error": "Hostname is not a valid hostname."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not data or not metadata:
|
||||
return Response(
|
||||
{"error": "Data, config and metadata are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
api_token = APIToken.objects.filter(
|
||||
user=request.user, workspace=workspace
|
||||
).first()
|
||||
if api_token is None:
|
||||
api_token = APIToken.objects.create(
|
||||
user=request.user,
|
||||
label="Importer",
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
importer = Importer.objects.create(
|
||||
service=service,
|
||||
project_id=project_id,
|
||||
status="queued",
|
||||
initiated_by=request.user,
|
||||
data=data,
|
||||
metadata=metadata,
|
||||
token=api_token,
|
||||
config=config,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
|
||||
service_importer.delay(service, importer.id)
|
||||
serializer = ImporterSerializer(importer)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(
|
||||
{"error": "Servivce not supported yet"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def get(self, request, slug):
|
||||
imports = (
|
||||
Importer.objects.filter(workspace__slug=slug)
|
||||
.order_by("-created_at")
|
||||
.select_related("initiated_by", "project", "workspace")
|
||||
)
|
||||
serializer = ImporterSerializer(imports, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def delete(self, request, slug, service, pk):
|
||||
importer = Importer.objects.get(
|
||||
pk=pk, service=service, workspace__slug=slug
|
||||
)
|
||||
|
||||
if importer.imported_data is not None:
|
||||
# Delete all imported Issues
|
||||
imported_issues = importer.imported_data.get("issues", [])
|
||||
Issue.issue_objects.filter(id__in=imported_issues).delete()
|
||||
|
||||
# Delete all imported Labels
|
||||
imported_labels = importer.imported_data.get("labels", [])
|
||||
Label.objects.filter(id__in=imported_labels).delete()
|
||||
|
||||
if importer.service == "jira":
|
||||
imported_modules = importer.imported_data.get("modules", [])
|
||||
Module.objects.filter(id__in=imported_modules).delete()
|
||||
importer.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def patch(self, request, slug, service, pk):
|
||||
importer = Importer.objects.get(
|
||||
pk=pk, service=service, workspace__slug=slug
|
||||
)
|
||||
serializer = ImporterSerializer(
|
||||
importer, data=request.data, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class UpdateServiceImportStatusEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id, service, importer_id):
|
||||
importer = Importer.objects.get(
|
||||
pk=importer_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
service=service,
|
||||
)
|
||||
importer.status = request.data.get("status", "processing")
|
||||
importer.save()
|
||||
return Response(status.HTTP_200_OK)
|
||||
|
||||
|
||||
class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id, service):
|
||||
# Get the project
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
# Get the default state
|
||||
default_state = State.objects.filter(
|
||||
~Q(name="Triage"), project_id=project_id, default=True
|
||||
).first()
|
||||
# if there is no default state assign any random state
|
||||
if default_state is None:
|
||||
default_state = State.objects.filter(
|
||||
~Q(name="Triage"), project_id=project_id
|
||||
).first()
|
||||
|
||||
# Get the maximum sequence_id
|
||||
last_id = IssueSequence.objects.filter(
|
||||
project_id=project_id
|
||||
).aggregate(largest=Max("sequence"))["largest"]
|
||||
|
||||
last_id = 1 if last_id is None else last_id + 1
|
||||
|
||||
# Get the maximum sort order
|
||||
largest_sort_order = Issue.objects.filter(
|
||||
project_id=project_id, state=default_state
|
||||
).aggregate(largest=Max("sort_order"))["largest"]
|
||||
|
||||
largest_sort_order = (
|
||||
65535 if largest_sort_order is None else largest_sort_order + 10000
|
||||
)
|
||||
|
||||
# Get the issues_data
|
||||
issues_data = request.data.get("issues_data", [])
|
||||
|
||||
if not len(issues_data):
|
||||
return Response(
|
||||
{"error": "Issue data is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Issues
|
||||
bulk_issues = []
|
||||
for issue_data in issues_data:
|
||||
bulk_issues.append(
|
||||
Issue(
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
state_id=issue_data.get("state")
|
||||
if issue_data.get("state", False)
|
||||
else default_state.id,
|
||||
name=issue_data.get("name", "Issue Created through Bulk"),
|
||||
description_html=issue_data.get(
|
||||
"description_html", "<p></p>"
|
||||
),
|
||||
description_stripped=(
|
||||
None
|
||||
if (
|
||||
issue_data.get("description_html") == ""
|
||||
or issue_data.get("description_html") is None
|
||||
)
|
||||
else strip_tags(issue_data.get("description_html"))
|
||||
),
|
||||
sequence_id=last_id,
|
||||
sort_order=largest_sort_order,
|
||||
start_date=issue_data.get("start_date", None),
|
||||
target_date=issue_data.get("target_date", None),
|
||||
priority=issue_data.get("priority", "none"),
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
|
||||
largest_sort_order = largest_sort_order + 10000
|
||||
last_id = last_id + 1
|
||||
|
||||
issues = Issue.objects.bulk_create(
|
||||
bulk_issues,
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
# Sequences
|
||||
_ = IssueSequence.objects.bulk_create(
|
||||
[
|
||||
IssueSequence(
|
||||
issue=issue,
|
||||
sequence=issue.sequence_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
# Attach Labels
|
||||
bulk_issue_labels = []
|
||||
for issue, issue_data in zip(issues, issues_data):
|
||||
labels_list = issue_data.get("labels_list", [])
|
||||
bulk_issue_labels = bulk_issue_labels + [
|
||||
IssueLabel(
|
||||
issue=issue,
|
||||
label_id=label_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
)
|
||||
for label_id in labels_list
|
||||
]
|
||||
|
||||
_ = IssueLabel.objects.bulk_create(
|
||||
bulk_issue_labels, batch_size=100, ignore_conflicts=True
|
||||
)
|
||||
|
||||
# Attach Assignees
|
||||
bulk_issue_assignees = []
|
||||
for issue, issue_data in zip(issues, issues_data):
|
||||
assignees_list = issue_data.get("assignees_list", [])
|
||||
bulk_issue_assignees = bulk_issue_assignees + [
|
||||
IssueAssignee(
|
||||
issue=issue,
|
||||
assignee_id=assignee_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
)
|
||||
for assignee_id in assignees_list
|
||||
]
|
||||
|
||||
_ = IssueAssignee.objects.bulk_create(
|
||||
bulk_issue_assignees, batch_size=100, ignore_conflicts=True
|
||||
)
|
||||
|
||||
# Track the issue activities
|
||||
IssueActivity.objects.bulk_create(
|
||||
[
|
||||
IssueActivity(
|
||||
issue=issue,
|
||||
actor=request.user,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
comment=f"imported the issue from {service}",
|
||||
verb="created",
|
||||
created_by=request.user,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
# Create Comments
|
||||
bulk_issue_comments = []
|
||||
for issue, issue_data in zip(issues, issues_data):
|
||||
comments_list = issue_data.get("comments_list", [])
|
||||
bulk_issue_comments = bulk_issue_comments + [
|
||||
IssueComment(
|
||||
issue=issue,
|
||||
comment_html=comment.get("comment_html", "<p></p>"),
|
||||
actor=request.user,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
)
|
||||
for comment in comments_list
|
||||
]
|
||||
|
||||
_ = IssueComment.objects.bulk_create(
|
||||
bulk_issue_comments, batch_size=100
|
||||
)
|
||||
|
||||
# Attach Links
|
||||
_ = IssueLink.objects.bulk_create(
|
||||
[
|
||||
IssueLink(
|
||||
issue=issue,
|
||||
url=issue_data.get("link", {}).get(
|
||||
"url", "https://github.com"
|
||||
),
|
||||
title=issue_data.get("link", {}).get(
|
||||
"title", "Original Issue"
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
)
|
||||
for issue, issue_data in zip(issues, issues_data)
|
||||
]
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"issues": IssueFlatSerializer(issues, many=True).data},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class BulkImportModulesEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id, service):
|
||||
modules_data = request.data.get("modules_data", [])
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
modules = Module.objects.bulk_create(
|
||||
[
|
||||
Module(
|
||||
name=module.get("name", uuid.uuid4().hex),
|
||||
description=module.get("description", ""),
|
||||
start_date=module.get("start_date", None),
|
||||
target_date=module.get("target_date", None),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
)
|
||||
for module in modules_data
|
||||
],
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
modules = Module.objects.filter(
|
||||
id__in=[module.id for module in modules]
|
||||
)
|
||||
|
||||
if len(modules) == len(modules_data):
|
||||
_ = ModuleLink.objects.bulk_create(
|
||||
[
|
||||
ModuleLink(
|
||||
module=module,
|
||||
url=module_data.get("link", {}).get(
|
||||
"url", "https://plane.so"
|
||||
),
|
||||
title=module_data.get("link", {}).get(
|
||||
"title", "Original Issue"
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
)
|
||||
for module, module_data in zip(modules, modules_data)
|
||||
],
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
bulk_module_issues = []
|
||||
for module, module_data in zip(modules, modules_data):
|
||||
module_issues_list = module_data.get("module_issues_list", [])
|
||||
bulk_module_issues = bulk_module_issues + [
|
||||
ModuleIssue(
|
||||
issue_id=issue,
|
||||
module=module,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
)
|
||||
for issue in module_issues_list
|
||||
]
|
||||
|
||||
_ = ModuleIssue.objects.bulk_create(
|
||||
bulk_module_issues, batch_size=100, ignore_conflicts=True
|
||||
)
|
||||
|
||||
serializer = ModuleSerializer(modules, many=True)
|
||||
return Response(
|
||||
{"modules": serializer.data}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"message": "Modules created but issues could not be imported"
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -15,7 +15,7 @@ from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet
|
||||
from ..base import BaseViewSet
|
||||
from plane.app.permissions import ProjectBasePermission, ProjectLitePermission
|
||||
from plane.db.models import (
|
||||
Inbox,
|
||||
@@ -213,7 +213,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Check for valid priority
|
||||
if not request.data.get("issue", {}).get("priority", "none") in [
|
||||
if request.data.get("issue", {}).get("priority", "none") not in [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
@@ -428,8 +428,11 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
).first()
|
||||
if issue is None:
|
||||
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
return Response(
|
||||
{"error": "Requested object was not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
serializer = IssueDetailSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
|
||||
from .github import (
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
BulkCreateGithubIssueSyncEndpoint,
|
||||
GithubCommentSyncViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
)
|
||||
from .slack import SlackProjectSyncViewSet
|
||||
@@ -1,181 +0,0 @@
|
||||
# Python improts
|
||||
import uuid
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.app.views import BaseViewSet
|
||||
from plane.db.models import (
|
||||
Integration,
|
||||
WorkspaceIntegration,
|
||||
Workspace,
|
||||
User,
|
||||
WorkspaceMember,
|
||||
APIToken,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IntegrationSerializer,
|
||||
WorkspaceIntegrationSerializer,
|
||||
)
|
||||
from plane.utils.integrations.github import (
|
||||
get_github_metadata,
|
||||
delete_github_installation,
|
||||
)
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
from plane.utils.integrations.slack import slack_oauth
|
||||
|
||||
|
||||
class IntegrationViewSet(BaseViewSet):
|
||||
serializer_class = IntegrationSerializer
|
||||
model = Integration
|
||||
|
||||
def create(self, request):
|
||||
serializer = IntegrationSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def partial_update(self, request, pk):
|
||||
integration = Integration.objects.get(pk=pk)
|
||||
if integration.verified:
|
||||
return Response(
|
||||
{"error": "Verified integrations cannot be updated"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = IntegrationSerializer(
|
||||
integration, data=request.data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, pk):
|
||||
integration = Integration.objects.get(pk=pk)
|
||||
if integration.verified:
|
||||
return Response(
|
||||
{"error": "Verified integrations cannot be updated"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
integration.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class WorkspaceIntegrationViewSet(BaseViewSet):
|
||||
serializer_class = WorkspaceIntegrationSerializer
|
||||
model = WorkspaceIntegration
|
||||
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("integration")
|
||||
)
|
||||
|
||||
def create(self, request, slug, provider):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
integration = Integration.objects.get(provider=provider)
|
||||
config = {}
|
||||
if provider == "github":
|
||||
installation_id = request.data.get("installation_id", None)
|
||||
if not installation_id:
|
||||
return Response(
|
||||
{"error": "Installation ID is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
metadata = get_github_metadata(installation_id)
|
||||
config = {"installation_id": installation_id}
|
||||
|
||||
if provider == "slack":
|
||||
code = request.data.get("code", False)
|
||||
|
||||
if not code:
|
||||
return Response(
|
||||
{"error": "Code is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
slack_response = slack_oauth(code=code)
|
||||
|
||||
metadata = slack_response
|
||||
access_token = metadata.get("access_token", False)
|
||||
team_id = metadata.get("team", {}).get("id", False)
|
||||
if not metadata or not access_token or not team_id:
|
||||
return Response(
|
||||
{
|
||||
"error": "Slack could not be installed. Please try again later"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
config = {"team_id": team_id, "access_token": access_token}
|
||||
|
||||
# Create a bot user
|
||||
bot_user = User.objects.create(
|
||||
email=f"{uuid.uuid4().hex}@plane.so",
|
||||
username=uuid.uuid4().hex,
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
is_bot=True,
|
||||
first_name=integration.title,
|
||||
avatar=integration.avatar_url
|
||||
if integration.avatar_url is not None
|
||||
else "",
|
||||
)
|
||||
|
||||
# Create an API Token for the bot user
|
||||
api_token = APIToken.objects.create(
|
||||
user=bot_user,
|
||||
user_type=1, # bot user
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
workspace_integration = WorkspaceIntegration.objects.create(
|
||||
workspace=workspace,
|
||||
integration=integration,
|
||||
actor=bot_user,
|
||||
api_token=api_token,
|
||||
metadata=metadata,
|
||||
config=config,
|
||||
)
|
||||
|
||||
# Add bot user as a member of workspace
|
||||
_ = WorkspaceMember.objects.create(
|
||||
workspace=workspace_integration.workspace,
|
||||
member=bot_user,
|
||||
role=20,
|
||||
)
|
||||
return Response(
|
||||
WorkspaceIntegrationSerializer(workspace_integration).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, pk):
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
pk=pk, workspace__slug=slug
|
||||
)
|
||||
|
||||
if workspace_integration.integration.provider == "github":
|
||||
installation_id = workspace_integration.config.get(
|
||||
"installation_id", False
|
||||
)
|
||||
if installation_id:
|
||||
delete_github_installation(installation_id=installation_id)
|
||||
|
||||
workspace_integration.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1,202 +0,0 @@
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.app.views import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import (
|
||||
GithubIssueSync,
|
||||
GithubRepositorySync,
|
||||
GithubRepository,
|
||||
WorkspaceIntegration,
|
||||
ProjectMember,
|
||||
Label,
|
||||
GithubCommentSync,
|
||||
Project,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
GithubIssueSyncSerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
from plane.utils.integrations.github import get_github_repos
|
||||
from plane.app.permissions import (
|
||||
ProjectBasePermission,
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
|
||||
|
||||
class GithubRepositoriesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, workspace_integration_id):
|
||||
page = request.GET.get("page", 1)
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
workspace__slug=slug, pk=workspace_integration_id
|
||||
)
|
||||
|
||||
if workspace_integration.integration.provider != "github":
|
||||
return Response(
|
||||
{"error": "Not a github integration"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
|
||||
repositories_url = (
|
||||
workspace_integration.metadata["repositories_url"]
|
||||
+ f"?per_page=100&page={page}"
|
||||
)
|
||||
repositories = get_github_repos(access_tokens_url, repositories_url)
|
||||
return Response(repositories, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class GithubRepositorySyncViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
serializer_class = GithubRepositorySyncSerializer
|
||||
model = GithubRepositorySync
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, workspace_integration_id):
|
||||
name = request.data.get("name", False)
|
||||
url = request.data.get("url", False)
|
||||
config = request.data.get("config", {})
|
||||
repository_id = request.data.get("repository_id", False)
|
||||
owner = request.data.get("owner", False)
|
||||
|
||||
if not name or not url or not repository_id or not owner:
|
||||
return Response(
|
||||
{"error": "Name, url, repository_id and owner are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the workspace integration
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
pk=workspace_integration_id
|
||||
)
|
||||
|
||||
# Delete the old repository object
|
||||
GithubRepositorySync.objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).delete()
|
||||
GithubRepository.objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).delete()
|
||||
|
||||
# Create repository
|
||||
repo = GithubRepository.objects.create(
|
||||
name=name,
|
||||
url=url,
|
||||
config=config,
|
||||
repository_id=repository_id,
|
||||
owner=owner,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# Create a Label for github
|
||||
label = Label.objects.filter(
|
||||
name="GitHub",
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if label is None:
|
||||
label = Label.objects.create(
|
||||
name="GitHub",
|
||||
project_id=project_id,
|
||||
description="Label to sync Plane issues with GitHub issues",
|
||||
color="#003773",
|
||||
)
|
||||
|
||||
# Create repo sync
|
||||
repo_sync = GithubRepositorySync.objects.create(
|
||||
repository=repo,
|
||||
workspace_integration=workspace_integration,
|
||||
actor=workspace_integration.actor,
|
||||
credentials=request.data.get("credentials", {}),
|
||||
project_id=project_id,
|
||||
label=label,
|
||||
)
|
||||
|
||||
# Add bot as a member in the project
|
||||
_ = ProjectMember.objects.get_or_create(
|
||||
member=workspace_integration.actor, role=20, project_id=project_id
|
||||
)
|
||||
|
||||
# Return Response
|
||||
return Response(
|
||||
GithubRepositorySyncSerializer(repo_sync).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class GithubIssueSyncViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = GithubIssueSyncSerializer
|
||||
model = GithubIssueSync
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
repository_sync_id=self.kwargs.get("repo_sync_id"),
|
||||
)
|
||||
|
||||
|
||||
class BulkCreateGithubIssueSyncEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id, repo_sync_id):
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
github_issue_syncs = request.data.get("github_issue_syncs", [])
|
||||
github_issue_syncs = GithubIssueSync.objects.bulk_create(
|
||||
[
|
||||
GithubIssueSync(
|
||||
issue_id=github_issue_sync.get("issue"),
|
||||
repo_issue_id=github_issue_sync.get("repo_issue_id"),
|
||||
issue_url=github_issue_sync.get("issue_url"),
|
||||
github_issue_id=github_issue_sync.get("github_issue_id"),
|
||||
repository_sync_id=repo_sync_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for github_issue_sync in github_issue_syncs
|
||||
],
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
serializer = GithubIssueSyncSerializer(github_issue_syncs, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class GithubCommentSyncViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = GithubCommentSyncSerializer
|
||||
model = GithubCommentSync
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
issue_sync_id=self.kwargs.get("issue_sync_id"),
|
||||
)
|
||||
@@ -1,96 +0,0 @@
|
||||
# Django import
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.app.views import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import (
|
||||
SlackProjectSync,
|
||||
WorkspaceIntegration,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers import SlackProjectSyncSerializer
|
||||
from plane.app.permissions import (
|
||||
ProjectBasePermission,
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
from plane.utils.integrations.slack import slack_oauth
|
||||
|
||||
|
||||
class SlackProjectSyncViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
serializer_class = SlackProjectSyncSerializer
|
||||
model = SlackProjectSync
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, workspace_integration_id):
|
||||
try:
|
||||
code = request.data.get("code", False)
|
||||
|
||||
if not code:
|
||||
return Response(
|
||||
{"error": "Code is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
slack_response = slack_oauth(code=code)
|
||||
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
workspace__slug=slug, pk=workspace_integration_id
|
||||
)
|
||||
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
pk=workspace_integration_id, workspace__slug=slug
|
||||
)
|
||||
slack_project_sync = SlackProjectSync.objects.create(
|
||||
access_token=slack_response.get("access_token"),
|
||||
scopes=slack_response.get("scope"),
|
||||
bot_user_id=slack_response.get("bot_user_id"),
|
||||
webhook_url=slack_response.get("incoming_webhook", {}).get(
|
||||
"url"
|
||||
),
|
||||
data=slack_response,
|
||||
team_id=slack_response.get("team", {}).get("id"),
|
||||
team_name=slack_response.get("team", {}).get("name"),
|
||||
workspace_integration=workspace_integration,
|
||||
project_id=project_id,
|
||||
)
|
||||
_ = ProjectMember.objects.get_or_create(
|
||||
member=workspace_integration.actor,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
)
|
||||
serializer = SlackProjectSyncSerializer(slack_project_sync)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "Slack is already installed for the project"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{
|
||||
"error": "Slack could not be installed. Please try again later"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
# 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,
|
||||
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,
|
||||
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)
|
||||
@@ -0,0 +1,347 @@
|
||||
# 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),
|
||||
),
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,658 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
)
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import UUIDField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
IssuePropertySerializer,
|
||||
IssueSerializer,
|
||||
IssueCreateSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Issue,
|
||||
IssueProperty,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
class IssueListEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
issue_ids = request.GET.get("issues", False)
|
||||
|
||||
if not issue_ids:
|
||||
return Response(
|
||||
{"error": "Issues are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
issue_ids = [
|
||||
issue_id for issue_id in issue_ids.split(",") if issue_id != ""
|
||||
]
|
||||
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=~Q(labels__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(assignees__id__isnull=True),
|
||||
),
|
||||
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),
|
||||
),
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
# 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,
|
||||
)
|
||||
.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,
|
||||
)
|
||||
.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)
|
||||
@@ -0,0 +1,367 @@
|
||||
# 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,
|
||||
IssueCreateSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
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),
|
||||
),
|
||||
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")
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
state_order = [
|
||||
"backlog",
|
||||
"unstarted",
|
||||
"started",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
# 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)
|
||||
@@ -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="#" + "%06x" % random.randint(0, 0xFFFFFF),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for label in label_data
|
||||
],
|
||||
batch_size=50,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"labels": LabelSerializer(labels, many=True).data},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
# 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,
|
||||
)
|
||||
.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)
|
||||
@@ -0,0 +1,89 @@
|
||||
# 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,
|
||||
)
|
||||
.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)
|
||||
@@ -0,0 +1,204 @@
|
||||
# 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,
|
||||
)
|
||||
.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)
|
||||
@@ -0,0 +1,195 @@
|
||||
# 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),
|
||||
),
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
# 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,
|
||||
)
|
||||
.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
|
||||
)
|
||||
@@ -3,9 +3,7 @@ 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.db.models import Prefetch, F, OuterRef, Exists, Count, Q, Func
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Value, UUIDField
|
||||
@@ -16,14 +14,12 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from .. import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
ModuleWriteSerializer,
|
||||
ModuleSerializer,
|
||||
ModuleIssueSerializer,
|
||||
ModuleLinkSerializer,
|
||||
ModuleFavoriteSerializer,
|
||||
IssueSerializer,
|
||||
ModuleUserPropertiesSerializer,
|
||||
ModuleDetailSerializer,
|
||||
)
|
||||
@@ -38,12 +34,9 @@ from plane.db.models import (
|
||||
Issue,
|
||||
ModuleLink,
|
||||
ModuleFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
ModuleUserProperties,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
|
||||
@@ -190,7 +183,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_id",
|
||||
# computed fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@@ -231,8 +223,8 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_source",
|
||||
"external_id",
|
||||
# computed fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"is_favorite",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@@ -244,7 +236,30 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
return Response(modules, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
queryset = self.get_queryset().filter(pk=pk)
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=True,
|
||||
issue_module__module_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.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(
|
||||
@@ -366,7 +381,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
module = queryset.values(
|
||||
module = queryset.values(
|
||||
# Required fields
|
||||
"id",
|
||||
"workspace_id",
|
||||
@@ -387,7 +402,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_id",
|
||||
# computed fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@@ -426,232 +440,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
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):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
@@ -0,0 +1,259 @@
|
||||
# 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),
|
||||
),
|
||||
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)
|
||||
+5
-2
@@ -8,7 +8,7 @@ from rest_framework.response import Response
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from ..base import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import (
|
||||
Notification,
|
||||
IssueAssignee,
|
||||
@@ -17,7 +17,10 @@ from plane.db.models import (
|
||||
WorkspaceMember,
|
||||
UserNotificationPreference,
|
||||
)
|
||||
from plane.app.serializers import NotificationSerializer, UserNotificationPreferenceSerializer
|
||||
from plane.app.serializers import (
|
||||
NotificationSerializer,
|
||||
UserNotificationPreferenceSerializer,
|
||||
)
|
||||
|
||||
|
||||
class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||
@@ -5,7 +5,6 @@ import os
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
# Third Party modules
|
||||
from rest_framework.response import Response
|
||||
@@ -250,9 +249,11 @@ class OauthEndpoint(BaseAPIView):
|
||||
[
|
||||
WorkspaceMember(
|
||||
workspace_id=project_member_invite.workspace_id,
|
||||
role=project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15,
|
||||
role=(
|
||||
project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15
|
||||
),
|
||||
member=user,
|
||||
created_by_id=project_member_invite.created_by_id,
|
||||
)
|
||||
@@ -266,9 +267,11 @@ class OauthEndpoint(BaseAPIView):
|
||||
[
|
||||
ProjectMember(
|
||||
workspace_id=project_member_invite.workspace_id,
|
||||
role=project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15,
|
||||
role=(
|
||||
project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15
|
||||
),
|
||||
member=user,
|
||||
created_by_id=project_member_invite.created_by_id,
|
||||
)
|
||||
@@ -391,9 +394,11 @@ class OauthEndpoint(BaseAPIView):
|
||||
[
|
||||
WorkspaceMember(
|
||||
workspace_id=project_member_invite.workspace_id,
|
||||
role=project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15,
|
||||
role=(
|
||||
project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15
|
||||
),
|
||||
member=user,
|
||||
created_by_id=project_member_invite.created_by_id,
|
||||
)
|
||||
@@ -407,9 +412,11 @@ class OauthEndpoint(BaseAPIView):
|
||||
[
|
||||
ProjectMember(
|
||||
workspace_id=project_member_invite.workspace_id,
|
||||
role=project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15,
|
||||
role=(
|
||||
project_member_invite.role
|
||||
if project_member_invite.role in [5, 10, 15]
|
||||
else 15
|
||||
),
|
||||
member=user,
|
||||
created_by_id=project_member_invite.created_by_id,
|
||||
)
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
# Python imports
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
# Django imports
|
||||
from django.db import connection
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer,
|
||||
PageLogSerializer, PageSerializer,
|
||||
SubPageSerializer)
|
||||
from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page,
|
||||
PageFavorite, PageLog, ProjectMember)
|
||||
from plane.app.serializers import (
|
||||
PageFavoriteSerializer,
|
||||
PageLogSerializer,
|
||||
PageSerializer,
|
||||
SubPageSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Page,
|
||||
PageFavorite,
|
||||
PageLog,
|
||||
ProjectMember,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView, BaseViewSet
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,619 @@
|
||||
# Python imports
|
||||
import boto3
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
Q,
|
||||
Exists,
|
||||
OuterRef,
|
||||
F,
|
||||
Func,
|
||||
Subquery,
|
||||
)
|
||||
from django.conf import settings
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
ProjectSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
ProjectDeployBoardSerializer,
|
||||
)
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectBasePermission,
|
||||
ProjectMemberPermission,
|
||||
)
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
State,
|
||||
ProjectFavorite,
|
||||
ProjectIdentifier,
|
||||
Module,
|
||||
Cycle,
|
||||
Inbox,
|
||||
ProjectDeployBoard,
|
||||
IssueProperty,
|
||||
Issue,
|
||||
)
|
||||
from plane.utils.cache import cache_response
|
||||
|
||||
|
||||
class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
serializer_class = ProjectListSerializer
|
||||
model = Project
|
||||
webhook_event = "project"
|
||||
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
sort_order = ProjectMember.objects.filter(
|
||||
member=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
is_active=True,
|
||||
).values("sort_order")
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(
|
||||
Q(project_projectmember__member=self.request.user)
|
||||
| Q(network=2)
|
||||
)
|
||||
.select_related(
|
||||
"workspace",
|
||||
"workspace__owner",
|
||||
"default_assignee",
|
||||
"project_lead",
|
||||
)
|
||||
.annotate(
|
||||
is_favorite=Exists(
|
||||
ProjectFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_member=Exists(
|
||||
ProjectMember.objects.filter(
|
||||
member=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_members=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("id"),
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
total_modules=Module.objects.filter(project_id=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
member_role=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("pk"),
|
||||
member_id=self.request.user.id,
|
||||
is_active=True,
|
||||
).values("role")
|
||||
)
|
||||
.annotate(
|
||||
is_deployed=Exists(
|
||||
ProjectDeployBoard.objects.filter(
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(sort_order=Subquery(sort_order))
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"project_projectmember",
|
||||
queryset=ProjectMember.objects.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
is_active=True,
|
||||
).select_related("member"),
|
||||
to_attr="members_list",
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug):
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
projects = self.get_queryset().order_by("sort_order", "name")
|
||||
if request.GET.get("per_page", False) and request.GET.get(
|
||||
"cursor", False
|
||||
):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(projects),
|
||||
on_results=lambda projects: ProjectListSerializer(
|
||||
projects, many=True
|
||||
).data,
|
||||
)
|
||||
projects = ProjectListSerializer(
|
||||
projects, many=True, fields=fields if fields else None
|
||||
).data
|
||||
return Response(projects, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, pk):
|
||||
project = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
parent__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
parent__isnull=False,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
archived_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
archived_at__isnull=False,
|
||||
parent__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
archived_sub_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
archived_at__isnull=False,
|
||||
parent__isnull=False,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
draft_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
is_draft=True,
|
||||
parent__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
draft_sub_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
is_draft=True,
|
||||
parent__isnull=False,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
).first()
|
||||
|
||||
serializer = ProjectListSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug):
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
serializer = ProjectSerializer(
|
||||
data={**request.data}, context={"workspace_id": workspace.id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
# Add the user as Administrator to the project
|
||||
_ = ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
member=request.user,
|
||||
role=20,
|
||||
)
|
||||
# Also create the issue property for the user
|
||||
_ = IssueProperty.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
user=request.user,
|
||||
)
|
||||
|
||||
if serializer.data["project_lead"] is not None and str(
|
||||
serializer.data["project_lead"]
|
||||
) != str(request.user.id):
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
member_id=serializer.data["project_lead"],
|
||||
role=20,
|
||||
)
|
||||
# Also create the issue property for the user
|
||||
IssueProperty.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
user_id=serializer.data["project_lead"],
|
||||
)
|
||||
|
||||
# Default states
|
||||
states = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
"color": "#A3A3A3",
|
||||
"sequence": 15000,
|
||||
"group": "backlog",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "Todo",
|
||||
"color": "#3A3A3A",
|
||||
"sequence": 25000,
|
||||
"group": "unstarted",
|
||||
},
|
||||
{
|
||||
"name": "In Progress",
|
||||
"color": "#F59E0B",
|
||||
"sequence": 35000,
|
||||
"group": "started",
|
||||
},
|
||||
{
|
||||
"name": "Done",
|
||||
"color": "#16A34A",
|
||||
"sequence": 45000,
|
||||
"group": "completed",
|
||||
},
|
||||
{
|
||||
"name": "Cancelled",
|
||||
"color": "#EF4444",
|
||||
"sequence": 55000,
|
||||
"group": "cancelled",
|
||||
},
|
||||
]
|
||||
|
||||
State.objects.bulk_create(
|
||||
[
|
||||
State(
|
||||
name=state["name"],
|
||||
color=state["color"],
|
||||
project=serializer.instance,
|
||||
sequence=state["sequence"],
|
||||
workspace=serializer.instance.workspace,
|
||||
group=state["group"],
|
||||
default=state.get("default", False),
|
||||
created_by=request.user,
|
||||
)
|
||||
for state in states
|
||||
]
|
||||
)
|
||||
|
||||
project = (
|
||||
self.get_queryset()
|
||||
.filter(pk=serializer.data["id"])
|
||||
.first()
|
||||
)
|
||||
serializer = ProjectListSerializer(project)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
return Response(
|
||||
serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except serializers.ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, pk=None):
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
project = Project.objects.get(pk=pk)
|
||||
|
||||
serializer = ProjectSerializer(
|
||||
project,
|
||||
data={**request.data},
|
||||
context={"workspace_id": workspace.id},
|
||||
partial=True,
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
if serializer.data["inbox_view"]:
|
||||
Inbox.objects.get_or_create(
|
||||
name=f"{project.name} Inbox",
|
||||
project=project,
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
# Create the triage state in Backlog group
|
||||
State.objects.get_or_create(
|
||||
name="Triage",
|
||||
group="backlog",
|
||||
description="Default state for managing all Inbox Issues",
|
||||
project_id=pk,
|
||||
color="#ff7700",
|
||||
)
|
||||
|
||||
project = (
|
||||
self.get_queryset()
|
||||
.filter(pk=serializer.data["id"])
|
||||
.first()
|
||||
)
|
||||
serializer = ProjectListSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except (Project.DoesNotExist, Workspace.DoesNotExist):
|
||||
return Response(
|
||||
{"error": "Project does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except serializers.ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
|
||||
class ProjectIdentifierEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
name = request.GET.get("name", "").strip().upper()
|
||||
|
||||
if name == "":
|
||||
return Response(
|
||||
{"error": "Name is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
exists = ProjectIdentifier.objects.filter(
|
||||
name=name, workspace__slug=slug
|
||||
).values("id", "name", "project")
|
||||
|
||||
return Response(
|
||||
{"exists": len(exists), "identifiers": exists},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request, slug):
|
||||
name = request.data.get("name", "").strip().upper()
|
||||
|
||||
if name == "":
|
||||
return Response(
|
||||
{"error": "Name is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if Project.objects.filter(
|
||||
identifier=name, workspace__slug=slug
|
||||
).exists():
|
||||
return Response(
|
||||
{
|
||||
"error": "Cannot delete an identifier of an existing project"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
ProjectIdentifier.objects.filter(
|
||||
name=name, workspace__slug=slug
|
||||
).delete()
|
||||
|
||||
return Response(
|
||||
status=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
|
||||
|
||||
class ProjectUserViewsEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id):
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
project_member = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
project=project,
|
||||
is_active=True,
|
||||
).first()
|
||||
|
||||
if project_member is None:
|
||||
return Response(
|
||||
{"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
view_props = project_member.view_props
|
||||
default_props = project_member.default_props
|
||||
preferences = project_member.preferences
|
||||
sort_order = project_member.sort_order
|
||||
|
||||
project_member.view_props = request.data.get("view_props", view_props)
|
||||
project_member.default_props = request.data.get(
|
||||
"default_props", default_props
|
||||
)
|
||||
project_member.preferences = request.data.get(
|
||||
"preferences", preferences
|
||||
)
|
||||
project_member.sort_order = request.data.get("sort_order", sort_order)
|
||||
|
||||
project_member.save()
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ProjectFavoritesViewSet(BaseViewSet):
|
||||
serializer_class = ProjectFavoriteSerializer
|
||||
model = ProjectFavorite
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(user=self.request.user)
|
||||
.select_related(
|
||||
"project", "project__project_lead", "project__default_assignee"
|
||||
)
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
def create(self, request, slug):
|
||||
serializer = ProjectFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(user=request.user)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, slug, project_id):
|
||||
project_favorite = ProjectFavorite.objects.get(
|
||||
project=project_id, user=request.user, workspace__slug=slug
|
||||
)
|
||||
project_favorite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
# Cache the below api for 24 hours
|
||||
@cache_response(60 * 60 * 24, user=False)
|
||||
def get(self, request):
|
||||
files = []
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
params = {
|
||||
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||
"Prefix": "static/project-cover/",
|
||||
}
|
||||
|
||||
response = s3.list_objects_v2(**params)
|
||||
# Extracting file keys from the response
|
||||
if "Contents" in response:
|
||||
for content in response["Contents"]:
|
||||
if not content["Key"].endswith(
|
||||
"/"
|
||||
): # This line ensures we're only getting files, not "sub-folders"
|
||||
files.append(
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
|
||||
)
|
||||
|
||||
return Response(files, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ProjectDeployBoardViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectMemberPermission,
|
||||
]
|
||||
serializer_class = ProjectDeployBoardSerializer
|
||||
model = ProjectDeployBoard
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
.select_related("project")
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
comments = request.data.get("comments", False)
|
||||
reactions = request.data.get("reactions", False)
|
||||
inbox = request.data.get("inbox", None)
|
||||
votes = request.data.get("votes", False)
|
||||
views = request.data.get(
|
||||
"views",
|
||||
{
|
||||
"list": True,
|
||||
"kanban": True,
|
||||
"calendar": True,
|
||||
"gantt": True,
|
||||
"spreadsheet": True,
|
||||
},
|
||||
)
|
||||
|
||||
project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create(
|
||||
anchor=f"{slug}/{project_id}",
|
||||
project_id=project_id,
|
||||
)
|
||||
project_deploy_board.comments = comments
|
||||
project_deploy_board.reactions = reactions
|
||||
project_deploy_board.inbox = inbox
|
||||
project_deploy_board.votes = votes
|
||||
project_deploy_board.views = views
|
||||
|
||||
project_deploy_board.save()
|
||||
|
||||
serializer = ProjectDeployBoardSerializer(project_deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -0,0 +1,286 @@
|
||||
# Python imports
|
||||
import jwt
|
||||
from datetime import datetime
|
||||
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.app.serializers import ProjectMemberInviteSerializer
|
||||
|
||||
from plane.app.permissions import ProjectBasePermission
|
||||
|
||||
from plane.db.models import (
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
ProjectMemberInvite,
|
||||
User,
|
||||
WorkspaceMember,
|
||||
IssueProperty,
|
||||
)
|
||||
|
||||
|
||||
class ProjectInvitationsViewset(BaseViewSet):
|
||||
serializer_class = ProjectMemberInviteSerializer
|
||||
model = ProjectMemberInvite
|
||||
|
||||
search_fields = []
|
||||
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.select_related("project")
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
emails = request.data.get("emails", [])
|
||||
|
||||
# Check if email is provided
|
||||
if not emails:
|
||||
return Response(
|
||||
{"error": "Emails are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
requesting_user = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member_id=request.user.id,
|
||||
)
|
||||
|
||||
# Check if any invited user has an higher role
|
||||
if len(
|
||||
[
|
||||
email
|
||||
for email in emails
|
||||
if int(email.get("role", 10)) > requesting_user.role
|
||||
]
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot invite a user with higher role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
project_invitations = []
|
||||
for email in emails:
|
||||
try:
|
||||
validate_email(email.get("email"))
|
||||
project_invitations.append(
|
||||
ProjectMemberInvite(
|
||||
email=email.get("email").strip().lower(),
|
||||
project_id=project_id,
|
||||
workspace_id=workspace.id,
|
||||
token=jwt.encode(
|
||||
{
|
||||
"email": email,
|
||||
"timestamp": datetime.now().timestamp(),
|
||||
},
|
||||
settings.SECRET_KEY,
|
||||
algorithm="HS256",
|
||||
),
|
||||
role=email.get("role", 10),
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{
|
||||
"error": f"Invalid email - {email} provided a valid email address is required to send the invite"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Create workspace member invite
|
||||
project_invitations = ProjectMemberInvite.objects.bulk_create(
|
||||
project_invitations, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
current_site = request.META.get("HTTP_ORIGIN")
|
||||
|
||||
# Send invitations
|
||||
for invitation in project_invitations:
|
||||
project_invitations.delay(
|
||||
invitation.email,
|
||||
project_id,
|
||||
invitation.token,
|
||||
current_site,
|
||||
request.user.email,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": "Email sent successfully",
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class UserProjectInvitationsViewset(BaseViewSet):
|
||||
serializer_class = ProjectMemberInviteSerializer
|
||||
model = ProjectMemberInvite
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(email=self.request.user.email)
|
||||
.select_related("workspace", "workspace__owner", "project")
|
||||
)
|
||||
|
||||
def create(self, request, slug):
|
||||
project_ids = request.data.get("project_ids", [])
|
||||
|
||||
# Get the workspace user role
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
workspace_role = workspace_member.role
|
||||
workspace = workspace_member.workspace
|
||||
|
||||
# If the user was already part of workspace
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id__in=project_ids,
|
||||
member=request.user,
|
||||
).update(is_active=True)
|
||||
|
||||
ProjectMember.objects.bulk_create(
|
||||
[
|
||||
ProjectMember(
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=15 if workspace_role >= 15 else 10,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
for project_id in project_ids
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
IssueProperty.objects.bulk_create(
|
||||
[
|
||||
IssueProperty(
|
||||
project_id=project_id,
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
for project_id in project_ids
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"message": "Projects joined successfully"},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class ProjectJoinEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id, pk):
|
||||
project_invite = ProjectMemberInvite.objects.get(
|
||||
pk=pk,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
email = request.data.get("email", "")
|
||||
|
||||
if email == "" or project_invite.email != email:
|
||||
return Response(
|
||||
{"error": "You do not have permission to join the project"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if project_invite.responded_at is None:
|
||||
project_invite.accepted = request.data.get("accepted", False)
|
||||
project_invite.responded_at = timezone.now()
|
||||
project_invite.save()
|
||||
|
||||
if project_invite.accepted:
|
||||
# Check if the user account exists
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
# Check if user is a part of workspace
|
||||
workspace_member = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member=user
|
||||
).first()
|
||||
# Add him to workspace
|
||||
if workspace_member is None:
|
||||
_ = WorkspaceMember.objects.create(
|
||||
workspace_id=project_invite.workspace_id,
|
||||
member=user,
|
||||
role=(
|
||||
15
|
||||
if project_invite.role >= 15
|
||||
else project_invite.role
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Else make him active
|
||||
workspace_member.is_active = True
|
||||
workspace_member.save()
|
||||
|
||||
# Check if the user was already a member of project then activate the user
|
||||
project_member = ProjectMember.objects.filter(
|
||||
workspace_id=project_invite.workspace_id, member=user
|
||||
).first()
|
||||
if project_member is None:
|
||||
# Create a Project Member
|
||||
_ = ProjectMember.objects.create(
|
||||
workspace_id=project_invite.workspace_id,
|
||||
member=user,
|
||||
role=project_invite.role,
|
||||
)
|
||||
else:
|
||||
project_member.is_active = True
|
||||
project_member.role = project_member.role
|
||||
project_member.save()
|
||||
|
||||
return Response(
|
||||
{"message": "Project Invitation Accepted"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"message": "Project Invitation was not accepted"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": "You have already responded to the invitation request"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, pk):
|
||||
project_invitation = ProjectMemberInvite.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
serializer = ProjectMemberInviteSerializer(project_invitation)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -0,0 +1,349 @@
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.app.serializers import (
|
||||
ProjectMemberSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
ProjectMemberRoleSerializer,
|
||||
)
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectBasePermission,
|
||||
ProjectMemberPermission,
|
||||
ProjectLitePermission,
|
||||
WorkspaceUserPermission,
|
||||
)
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
TeamMember,
|
||||
IssueProperty,
|
||||
)
|
||||
|
||||
|
||||
class ProjectMemberViewSet(BaseViewSet):
|
||||
serializer_class = ProjectMemberAdminSerializer
|
||||
model = ProjectMember
|
||||
permission_classes = [
|
||||
ProjectMemberPermission,
|
||||
]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == "leave":
|
||||
self.permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
else:
|
||||
self.permission_classes = [
|
||||
ProjectMemberPermission,
|
||||
]
|
||||
|
||||
return super(ProjectMemberViewSet, self).get_permissions()
|
||||
|
||||
search_fields = [
|
||||
"member__display_name",
|
||||
"member__first_name",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(member__is_bot=False)
|
||||
.filter()
|
||||
.select_related("project")
|
||||
.select_related("member")
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
members = request.data.get("members", [])
|
||||
|
||||
# get the project
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
if not len(members):
|
||||
return Response(
|
||||
{"error": "Atleast one member is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
bulk_project_members = []
|
||||
bulk_issue_props = []
|
||||
|
||||
project_members = (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id__in=[member.get("member_id") for member in members],
|
||||
)
|
||||
.values("member_id", "sort_order")
|
||||
.order_by("sort_order")
|
||||
)
|
||||
|
||||
bulk_project_members = []
|
||||
member_roles = {
|
||||
member.get("member_id"): member.get("role") for member in members
|
||||
}
|
||||
# Update roles in the members array based on the member_roles dictionary
|
||||
for project_member in ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member_id__in=[member.get("member_id") for member in members],
|
||||
):
|
||||
project_member.role = member_roles[str(project_member.member_id)]
|
||||
project_member.is_active = True
|
||||
bulk_project_members.append(project_member)
|
||||
|
||||
# Update the roles of the existing members
|
||||
ProjectMember.objects.bulk_update(
|
||||
bulk_project_members, ["is_active", "role"], batch_size=100
|
||||
)
|
||||
|
||||
for member in members:
|
||||
sort_order = [
|
||||
project_member.get("sort_order")
|
||||
for project_member in project_members
|
||||
if str(project_member.get("member_id"))
|
||||
== str(member.get("member_id"))
|
||||
]
|
||||
bulk_project_members.append(
|
||||
ProjectMember(
|
||||
member_id=member.get("member_id"),
|
||||
role=member.get("role", 10),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
sort_order=(
|
||||
sort_order[0] - 10000 if len(sort_order) else 65535
|
||||
),
|
||||
)
|
||||
)
|
||||
bulk_issue_props.append(
|
||||
IssueProperty(
|
||||
user_id=member.get("member_id"),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
)
|
||||
|
||||
project_members = ProjectMember.objects.bulk_create(
|
||||
bulk_project_members,
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
_ = IssueProperty.objects.bulk_create(
|
||||
bulk_issue_props, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member_id__in=[member.get("member_id") for member in members],
|
||||
)
|
||||
serializer = ProjectMemberRoleSerializer(project_members, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
# Get the list of project members for the project
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
).select_related("project", "member", "workspace")
|
||||
|
||||
serializer = ProjectMemberRoleSerializer(
|
||||
project_members, fields=("id", "member", "role"), many=True
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
project_member = ProjectMember.objects.get(
|
||||
pk=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
)
|
||||
if request.user.id == project_member.member_id:
|
||||
return Response(
|
||||
{"error": "You cannot update your own role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Check while updating user roles
|
||||
requested_project_member = ProjectMember.objects.get(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
if (
|
||||
"role" in request.data
|
||||
and int(request.data.get("role", project_member.role))
|
||||
> requested_project_member.role
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = ProjectMemberSerializer(
|
||||
project_member, data=request.data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
pk=pk,
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
# check requesting user role
|
||||
requesting_project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
)
|
||||
# User cannot remove himself
|
||||
if str(project_member.id) == str(requesting_project_member.id):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot remove yourself from the workspace. Please use leave workspace"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# User cannot deactivate higher role
|
||||
if requesting_project_member.role < project_member.role:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot remove a user having role higher than you"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
project_member.is_active = False
|
||||
project_member.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def leave(self, request, slug, project_id):
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Check if the leaving user is the only admin of the project
|
||||
if (
|
||||
project_member.role == 20
|
||||
and not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
role=20,
|
||||
is_active=True,
|
||||
).count()
|
||||
> 1
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Deactivate the user
|
||||
project_member.is_active = False
|
||||
project_member.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class AddTeamToProjectEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
team_members = TeamMember.objects.filter(
|
||||
workspace__slug=slug, team__in=request.data.get("teams", [])
|
||||
).values_list("member", flat=True)
|
||||
|
||||
if len(team_members) == 0:
|
||||
return Response(
|
||||
{"error": "No such team exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
project_members = []
|
||||
issue_props = []
|
||||
for member in team_members:
|
||||
project_members.append(
|
||||
ProjectMember(
|
||||
project_id=project_id,
|
||||
member_id=member,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
issue_props.append(
|
||||
IssueProperty(
|
||||
project_id=project_id,
|
||||
user_id=member,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
|
||||
ProjectMember.objects.bulk_create(
|
||||
project_members, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
_ = IssueProperty.objects.bulk_create(
|
||||
issue_props, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class ProjectMemberUserEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
project_member = ProjectMember.objects.get(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
serializer = ProjectMemberSerializer(project_member)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UserProjectRolesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceUserPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
project_members = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id=request.user.id,
|
||||
).values("project_id", "role")
|
||||
|
||||
project_members = {
|
||||
str(member["project_id"]): member["role"]
|
||||
for member in project_members
|
||||
}
|
||||
return Response(project_members, status=status.HTTP_200_OK)
|
||||
@@ -235,6 +235,7 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
cycle = request.query_params.get("cycle", "false")
|
||||
module = request.query_params.get("module", False)
|
||||
sub_issue = request.query_params.get("sub_issue", "false")
|
||||
target_date = request.query_params.get("target_date", True)
|
||||
|
||||
issue_id = request.query_params.get("issue_id", False)
|
||||
|
||||
@@ -253,7 +254,8 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
if parent == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id))
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
|
||||
)
|
||||
if issue_relation == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
@@ -273,6 +275,9 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
if module:
|
||||
issues = issues.exclude(issue_module__module=module)
|
||||
|
||||
if target_date == "none":
|
||||
issues = issues.filter(target_date__isnull=True)
|
||||
|
||||
return Response(
|
||||
issues.values(
|
||||
"name",
|
||||
|
||||
@@ -9,14 +9,13 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import StateSerializer
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
WorkspaceEntityPermission,
|
||||
)
|
||||
from plane.db.models import State, Issue
|
||||
|
||||
from plane.utils.cache import invalidate_cache
|
||||
|
||||
class StateViewSet(BaseViewSet):
|
||||
serializer_class = StateSerializer
|
||||
@@ -41,6 +40,7 @@ class StateViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
|
||||
def create(self, request, slug, project_id):
|
||||
serializer = StateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
@@ -61,6 +61,7 @@ class StateViewSet(BaseViewSet):
|
||||
return Response(state_dict, status=status.HTTP_200_OK)
|
||||
return Response(states, status=status.HTTP_200_OK)
|
||||
|
||||
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
|
||||
def mark_as_default(self, request, slug, project_id, pk):
|
||||
# Select all the states which are marked as default
|
||||
_ = State.objects.filter(
|
||||
@@ -71,6 +72,7 @@ class StateViewSet(BaseViewSet):
|
||||
).update(default=True)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
state = State.objects.get(
|
||||
~Q(name="Triage"),
|
||||
@@ -1,25 +1,24 @@
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
# Django imports
|
||||
from django.db.models import Case, Count, IntegerField, Q, When
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
UserSerializer,
|
||||
IssueActivitySerializer,
|
||||
UserMeSerializer,
|
||||
UserMeSettingsSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
|
||||
from plane.app.views.base import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet
|
||||
from plane.db.models import IssueActivity, ProjectMember, User, WorkspaceMember
|
||||
from plane.license.models import Instance, InstanceAdmin
|
||||
from plane.utils.cache import cache_response, invalidate_cache
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
|
||||
from django.db.models import Q, F, Count, Case, When, IntegerField
|
||||
|
||||
|
||||
class UserEndpoint(BaseViewSet):
|
||||
serializer_class = UserSerializer
|
||||
model = User
|
||||
@@ -27,6 +26,7 @@ class UserEndpoint(BaseViewSet):
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
@cache_response(60 * 60)
|
||||
def retrieve(self, request):
|
||||
serialized_data = UserMeSerializer(request.user).data
|
||||
return Response(
|
||||
@@ -34,10 +34,12 @@ class UserEndpoint(BaseViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@cache_response(60 * 60)
|
||||
def retrieve_user_settings(self, request):
|
||||
serialized_data = UserMeSettingsSerializer(request.user).data
|
||||
return Response(serialized_data, status=status.HTTP_200_OK)
|
||||
|
||||
@cache_response(60 * 60)
|
||||
def retrieve_instance_admin(self, request):
|
||||
instance = Instance.objects.first()
|
||||
is_admin = InstanceAdmin.objects.filter(
|
||||
@@ -47,6 +49,11 @@ class UserEndpoint(BaseViewSet):
|
||||
{"is_instance_admin": is_admin}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/users/me/")
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@invalidate_cache(path="/api/users/me/")
|
||||
def deactivate(self, request):
|
||||
# Check all workspace user is active
|
||||
user = self.get_object()
|
||||
@@ -145,6 +152,8 @@ class UserEndpoint(BaseViewSet):
|
||||
|
||||
|
||||
class UpdateUserOnBoardedEndpoint(BaseAPIView):
|
||||
|
||||
@invalidate_cache(path="/api/users/me/")
|
||||
def patch(self, request):
|
||||
user = User.objects.get(pk=request.user.id, is_active=True)
|
||||
user.is_onboarded = request.data.get("is_onboarded", False)
|
||||
@@ -155,6 +164,8 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class UpdateUserTourCompletedEndpoint(BaseAPIView):
|
||||
|
||||
@invalidate_cache(path="/api/users/me/")
|
||||
def patch(self, request):
|
||||
user = User.objects.get(pk=request.user.id, is_active=True)
|
||||
user.is_tour_completed = request.data.get("is_tour_completed", False)
|
||||
@@ -165,6 +176,7 @@ class UpdateUserTourCompletedEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class UserActivityEndpoint(BaseAPIView, BasePaginator):
|
||||
|
||||
def get(self, request):
|
||||
queryset = IssueActivity.objects.filter(
|
||||
actor=request.user
|
||||
@@ -15,18 +15,15 @@ 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 import UUIDField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Value, UUIDField
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
IssueViewSerializer,
|
||||
IssueSerializer,
|
||||
@@ -146,11 +143,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
@@ -8,7 +8,7 @@ from rest_framework.response import Response
|
||||
# Module imports
|
||||
from plane.db.models import Webhook, WebhookLog, Workspace
|
||||
from plane.db.models.webhook import generate_token
|
||||
from .base import BaseAPIView
|
||||
from ..base import BaseAPIView
|
||||
from plane.app.permissions import WorkspaceOwnerPermission
|
||||
from plane.app.serializers import WebhookSerializer, WebhookLogSerializer
|
||||
|
||||
@@ -41,7 +41,7 @@ class WebhookEndpoint(BaseAPIView):
|
||||
raise IntegrityError
|
||||
|
||||
def get(self, request, slug, pk=None):
|
||||
if pk == None:
|
||||
if pk is None:
|
||||
webhooks = Webhook.objects.filter(workspace__slug=slug)
|
||||
serializer = WebhookSerializer(
|
||||
webhooks,
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,414 @@
|
||||
# Python imports
|
||||
from datetime import date
|
||||
from dateutil.relativedelta import relativedelta
|
||||
import csv
|
||||
import io
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.http import HttpResponse
|
||||
from django.db import IntegrityError
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Count,
|
||||
)
|
||||
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
|
||||
from django.db.models.fields import DateField
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
WorkSpaceSerializer,
|
||||
WorkspaceThemeSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
IssueActivity,
|
||||
Issue,
|
||||
WorkspaceTheme,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
WorkSpaceBasePermission,
|
||||
WorkSpaceAdminPermission,
|
||||
WorkspaceEntityPermission,
|
||||
)
|
||||
from plane.utils.cache import cache_response, invalidate_cache
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
model = Workspace
|
||||
serializer_class = WorkSpaceSerializer
|
||||
permission_classes = [
|
||||
WorkSpaceBasePermission,
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"name",
|
||||
]
|
||||
filterset_fields = [
|
||||
"owner",
|
||||
]
|
||||
|
||||
lookup_field = "slug"
|
||||
|
||||
def get_queryset(self):
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"),
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.issue_objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
return (
|
||||
self.filter_queryset(
|
||||
super().get_queryset().select_related("owner")
|
||||
)
|
||||
.order_by("name")
|
||||
.filter(
|
||||
workspace_member__member=self.request.user,
|
||||
workspace_member__is_active=True,
|
||||
)
|
||||
.annotate(total_members=member_count)
|
||||
.annotate(total_issues=issue_count)
|
||||
.select_related("owner")
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/", user=False)
|
||||
@invalidate_cache(path="/api/users/me/workspaces/")
|
||||
def create(self, request):
|
||||
try:
|
||||
serializer = WorkSpaceSerializer(data=request.data)
|
||||
|
||||
slug = request.data.get("slug", False)
|
||||
name = request.data.get("name", False)
|
||||
|
||||
if not name or not slug:
|
||||
return Response(
|
||||
{"error": "Both name and slug are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if len(name) > 80 or len(slug) > 48:
|
||||
return Response(
|
||||
{
|
||||
"error": "The maximum length for name is 80 and for slug is 48"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save(owner=request.user)
|
||||
# Create Workspace member
|
||||
_ = WorkspaceMember.objects.create(
|
||||
workspace_id=serializer.data["id"],
|
||||
member=request.user,
|
||||
role=20,
|
||||
company_role=request.data.get("company_role", ""),
|
||||
)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
return Response(
|
||||
[serializer.errors[error][0] for error in serializer.errors],
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"slug": "The workspace with the slug already exists"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
@cache_response(60 * 60 * 2)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/", user=False)
|
||||
@invalidate_cache(path="/api/users/me/workspaces/")
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/", user=False)
|
||||
@invalidate_cache(path="/api/users/me/workspaces/")
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
search_fields = [
|
||||
"name",
|
||||
]
|
||||
filterset_fields = [
|
||||
"owner",
|
||||
]
|
||||
|
||||
@cache_response(60 * 60 * 2)
|
||||
def get(self, request):
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"),
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.issue_objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
workspace = (
|
||||
Workspace.objects.prefetch_related(
|
||||
Prefetch(
|
||||
"workspace_member",
|
||||
queryset=WorkspaceMember.objects.filter(
|
||||
member=request.user, is_active=True
|
||||
),
|
||||
)
|
||||
)
|
||||
.select_related("owner")
|
||||
.annotate(total_members=member_count)
|
||||
.annotate(total_issues=issue_count)
|
||||
.filter(
|
||||
workspace_member__member=request.user,
|
||||
workspace_member__is_active=True,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
workspaces = WorkSpaceSerializer(
|
||||
self.filter_queryset(workspace),
|
||||
fields=fields if fields else None,
|
||||
many=True,
|
||||
).data
|
||||
return Response(workspaces, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
slug = request.GET.get("slug", False)
|
||||
|
||||
if not slug or slug == "":
|
||||
return Response(
|
||||
{"error": "Workspace Slug is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.filter(slug=slug).exists()
|
||||
return Response({"status": not workspace}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WeekInMonth(Func):
|
||||
function = "FLOOR"
|
||||
template = "(((%(expressions)s - 1) / 7) + 1)::INTEGER"
|
||||
|
||||
|
||||
class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
issue_activities = (
|
||||
IssueActivity.objects.filter(
|
||||
actor=request.user,
|
||||
workspace__slug=slug,
|
||||
created_at__date__gte=date.today() + relativedelta(months=-3),
|
||||
)
|
||||
.annotate(created_date=Cast("created_at", DateField()))
|
||||
.values("created_date")
|
||||
.annotate(activity_count=Count("created_date"))
|
||||
.order_by("created_date")
|
||||
)
|
||||
|
||||
month = request.GET.get("month", 1)
|
||||
|
||||
completed_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
assignees__in=[request.user],
|
||||
workspace__slug=slug,
|
||||
completed_at__month=month,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.annotate(day_of_month=ExtractDay("completed_at"))
|
||||
.annotate(week_in_month=WeekInMonth(F("day_of_month")))
|
||||
.values("week_in_month")
|
||||
.annotate(completed_count=Count("id"))
|
||||
.order_by("week_in_month")
|
||||
)
|
||||
|
||||
assigned_issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug, assignees__in=[request.user]
|
||||
).count()
|
||||
|
||||
pending_issues_count = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
).count()
|
||||
|
||||
completed_issues_count = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
state__group="completed",
|
||||
).count()
|
||||
|
||||
issues_due_week = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.annotate(target_week=ExtractWeek("target_date"))
|
||||
.filter(target_week=timezone.now().date().isocalendar()[1])
|
||||
.count()
|
||||
)
|
||||
|
||||
state_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug, assignees__in=[request.user]
|
||||
)
|
||||
.annotate(state_group=F("state__group"))
|
||||
.values("state_group")
|
||||
.annotate(state_count=Count("state_group"))
|
||||
.order_by("state_group")
|
||||
)
|
||||
|
||||
overdue_issues = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
target_date__lt=timezone.now(),
|
||||
completed_at__isnull=True,
|
||||
).values("id", "name", "workspace__slug", "project_id", "target_date")
|
||||
|
||||
upcoming_issues = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
start_date__gte=timezone.now(),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
completed_at__isnull=True,
|
||||
).values("id", "name", "workspace__slug", "project_id", "start_date")
|
||||
|
||||
return Response(
|
||||
{
|
||||
"issue_activities": issue_activities,
|
||||
"completed_issues": completed_issues,
|
||||
"assigned_issues_count": assigned_issues,
|
||||
"pending_issues_count": pending_issues_count,
|
||||
"completed_issues_count": completed_issues_count,
|
||||
"issues_due_week_count": issues_due_week,
|
||||
"state_distribution": state_distribution,
|
||||
"overdue_issues": overdue_issues,
|
||||
"upcoming_issues": upcoming_issues,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceThemeViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
model = WorkspaceTheme
|
||||
serializer_class = WorkspaceThemeSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
)
|
||||
|
||||
def create(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = WorkspaceThemeSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace=workspace, actor=request.user)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def generate_csv_from_rows(self, rows):
|
||||
"""Generate CSV buffer from rows."""
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
[writer.writerow(row) for row in rows]
|
||||
csv_buffer.seek(0)
|
||||
return csv_buffer
|
||||
|
||||
def post(self, request, slug, user_id):
|
||||
|
||||
if not request.data.get("date"):
|
||||
return Response(
|
||||
{"error": "Date is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user_activities = IssueActivity.objects.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
workspace__slug=slug,
|
||||
created_at__date=request.data.get("date"),
|
||||
project__project_projectmember__member=request.user,
|
||||
actor_id=user_id,
|
||||
).select_related("actor", "workspace", "issue", "project")[:10000]
|
||||
|
||||
header = [
|
||||
"Actor name",
|
||||
"Issue ID",
|
||||
"Project",
|
||||
"Created at",
|
||||
"Updated at",
|
||||
"Action",
|
||||
"Field",
|
||||
"Old value",
|
||||
"New value",
|
||||
]
|
||||
rows = [
|
||||
(
|
||||
activity.actor.display_name,
|
||||
f"{activity.project.identifier} - {activity.issue.sequence_id if activity.issue else ''}",
|
||||
activity.project.name,
|
||||
activity.created_at,
|
||||
activity.updated_at,
|
||||
activity.verb,
|
||||
activity.field,
|
||||
activity.old_value,
|
||||
activity.new_value,
|
||||
)
|
||||
for activity in user_activities
|
||||
]
|
||||
csv_buffer = self.generate_csv_from_rows([header] + rows)
|
||||
response = HttpResponse(csv_buffer.getvalue(), content_type="text/csv")
|
||||
response["Content-Disposition"] = (
|
||||
'attachment; filename="workspace-user-activity.csv"'
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,116 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Q,
|
||||
Count,
|
||||
Sum,
|
||||
)
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import Cycle
|
||||
from plane.app.permissions import WorkspaceViewerPermission
|
||||
from plane.app.serializers.cycle import CycleSerializer
|
||||
|
||||
|
||||
class WorkspaceCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceViewerPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
cycles = (
|
||||
Cycle.objects.filter(workspace__slug=slug)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_estimates=Sum("issue_cycle__issue__estimate_point")
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||
.distinct()
|
||||
)
|
||||
serializer = CycleSerializer(cycles, many=True).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import WorkspaceEstimateSerializer
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import Project, Estimate
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
)
|
||||
from plane.utils.cache import cache_response
|
||||
|
||||
|
||||
class WorkspaceEstimatesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
@cache_response(60 * 60 * 2)
|
||||
def get(self, request, slug):
|
||||
estimate_ids = Project.objects.filter(
|
||||
workspace__slug=slug, estimate__isnull=False
|
||||
).values_list("estimate_id", flat=True)
|
||||
estimates = Estimate.objects.filter(
|
||||
pk__in=estimate_ids
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
"points",
|
||||
queryset=Project.objects.select_related(
|
||||
"estimate", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
serializer = WorkspaceEstimateSerializer(estimates, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -0,0 +1,301 @@
|
||||
# Python imports
|
||||
import jwt
|
||||
from datetime import datetime
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.db.models import Count
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
WorkSpaceMemberSerializer,
|
||||
WorkSpaceMemberInviteSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from .. import BaseViewSet
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMemberInvite,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
from plane.bgtasks.event_tracking_task import workspace_invite_event
|
||||
from plane.utils.cache import invalidate_cache
|
||||
|
||||
class WorkspaceInvitationsViewset(BaseViewSet):
|
||||
"""Endpoint for creating, listing and deleting workspaces"""
|
||||
|
||||
serializer_class = WorkSpaceMemberInviteSerializer
|
||||
model = WorkspaceMemberInvite
|
||||
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "workspace__owner", "created_by")
|
||||
)
|
||||
|
||||
def create(self, request, slug):
|
||||
emails = request.data.get("emails", [])
|
||||
# Check if email is provided
|
||||
if not emails:
|
||||
return Response(
|
||||
{"error": "Emails are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# check for role level of the requesting user
|
||||
requesting_user = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Check if any invited user has an higher role
|
||||
if len(
|
||||
[
|
||||
email
|
||||
for email in emails
|
||||
if int(email.get("role", 10)) > requesting_user.role
|
||||
]
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot invite a user with higher role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the workspace object
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# Check if user is already a member of workspace
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace_id=workspace.id,
|
||||
member__email__in=[email.get("email") for email in emails],
|
||||
is_active=True,
|
||||
).select_related("member", "workspace", "workspace__owner")
|
||||
|
||||
if workspace_members:
|
||||
return Response(
|
||||
{
|
||||
"error": "Some users are already member of workspace",
|
||||
"workspace_users": WorkSpaceMemberSerializer(
|
||||
workspace_members, many=True
|
||||
).data,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace_invitations = []
|
||||
for email in emails:
|
||||
try:
|
||||
validate_email(email.get("email"))
|
||||
workspace_invitations.append(
|
||||
WorkspaceMemberInvite(
|
||||
email=email.get("email").strip().lower(),
|
||||
workspace_id=workspace.id,
|
||||
token=jwt.encode(
|
||||
{
|
||||
"email": email,
|
||||
"timestamp": datetime.now().timestamp(),
|
||||
},
|
||||
settings.SECRET_KEY,
|
||||
algorithm="HS256",
|
||||
),
|
||||
role=email.get("role", 10),
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{
|
||||
"error": f"Invalid email - {email} provided a valid email address is required to send the invite"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Create workspace member invite
|
||||
workspace_invitations = WorkspaceMemberInvite.objects.bulk_create(
|
||||
workspace_invitations, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
current_site = request.META.get("HTTP_ORIGIN")
|
||||
|
||||
# Send invitations
|
||||
for invitation in workspace_invitations:
|
||||
workspace_invitation.delay(
|
||||
invitation.email,
|
||||
workspace.id,
|
||||
invitation.token,
|
||||
current_site,
|
||||
request.user.email,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": "Emails sent successfully",
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, pk):
|
||||
workspace_member_invite = WorkspaceMemberInvite.objects.get(
|
||||
pk=pk, workspace__slug=slug
|
||||
)
|
||||
workspace_member_invite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class WorkspaceJoinEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
"""Invitation response endpoint the user can respond to the invitation"""
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/", user=False)
|
||||
@invalidate_cache(path="/api/users/me/workspaces/")
|
||||
def post(self, request, slug, pk):
|
||||
workspace_invite = WorkspaceMemberInvite.objects.get(
|
||||
pk=pk, workspace__slug=slug
|
||||
)
|
||||
|
||||
email = request.data.get("email", "")
|
||||
|
||||
# Check the email
|
||||
if email == "" or workspace_invite.email != email:
|
||||
return Response(
|
||||
{"error": "You do not have permission to join the workspace"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# If already responded then return error
|
||||
if workspace_invite.responded_at is None:
|
||||
workspace_invite.accepted = request.data.get("accepted", False)
|
||||
workspace_invite.responded_at = timezone.now()
|
||||
workspace_invite.save()
|
||||
|
||||
if workspace_invite.accepted:
|
||||
# Check if the user created account after invitation
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
# If the user is present then create the workspace member
|
||||
if user is not None:
|
||||
# Check if the user was already a member of workspace then activate the user
|
||||
workspace_member = WorkspaceMember.objects.filter(
|
||||
workspace=workspace_invite.workspace, member=user
|
||||
).first()
|
||||
if workspace_member is not None:
|
||||
workspace_member.is_active = True
|
||||
workspace_member.role = workspace_invite.role
|
||||
workspace_member.save()
|
||||
else:
|
||||
# Create a Workspace
|
||||
_ = WorkspaceMember.objects.create(
|
||||
workspace=workspace_invite.workspace,
|
||||
member=user,
|
||||
role=workspace_invite.role,
|
||||
)
|
||||
|
||||
# Set the user last_workspace_id to the accepted workspace
|
||||
user.last_workspace_id = workspace_invite.workspace.id
|
||||
user.save()
|
||||
|
||||
# Delete the invitation
|
||||
workspace_invite.delete()
|
||||
|
||||
# Send event
|
||||
workspace_invite_event.delay(
|
||||
user=user.id if user is not None else None,
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="MEMBER_ACCEPTED",
|
||||
accepted_from="EMAIL",
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"message": "Workspace Invitation Accepted"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Workspace invitation rejected
|
||||
return Response(
|
||||
{"message": "Workspace Invitation was not accepted"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": "You have already responded to the invitation request"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def get(self, request, slug, pk):
|
||||
workspace_invitation = WorkspaceMemberInvite.objects.get(
|
||||
workspace__slug=slug, pk=pk
|
||||
)
|
||||
serializer = WorkSpaceMemberInviteSerializer(workspace_invitation)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UserWorkspaceInvitationsViewSet(BaseViewSet):
|
||||
serializer_class = WorkSpaceMemberInviteSerializer
|
||||
model = WorkspaceMemberInvite
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(email=self.request.user.email)
|
||||
.select_related("workspace", "workspace__owner", "created_by")
|
||||
.annotate(total_members=Count("workspace__workspace_member"))
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/", user=False)
|
||||
@invalidate_cache(path="/api/users/me/workspaces/")
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/members/", url_params=True, user=False
|
||||
)
|
||||
def create(self, request):
|
||||
invitations = request.data.get("invitations", [])
|
||||
workspace_invitations = WorkspaceMemberInvite.objects.filter(
|
||||
pk__in=invitations, email=request.user.email
|
||||
).order_by("-created_at")
|
||||
|
||||
# If the user is already a member of workspace and was deactivated then activate the user
|
||||
for invitation in workspace_invitations:
|
||||
# Update the WorkspaceMember for this specific invitation
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace_id=invitation.workspace_id, member=request.user
|
||||
).update(is_active=True, role=invitation.role)
|
||||
|
||||
# Bulk create the user for all the workspaces
|
||||
WorkspaceMember.objects.bulk_create(
|
||||
[
|
||||
WorkspaceMember(
|
||||
workspace=invitation.workspace,
|
||||
member=request.user,
|
||||
role=invitation.role,
|
||||
created_by=request.user,
|
||||
)
|
||||
for invitation in workspace_invitations
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
# Delete joined workspace invites
|
||||
workspace_invitations.delete()
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,25 @@
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import LabelSerializer
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import Label
|
||||
from plane.app.permissions import WorkspaceViewerPermission
|
||||
from plane.utils.cache import cache_response
|
||||
|
||||
class WorkspaceLabelsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceViewerPermission,
|
||||
]
|
||||
|
||||
@cache_response(60 * 60 * 2)
|
||||
def get(self, request, slug):
|
||||
labels = Label.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
serializer = LabelSerializer(labels, many=True).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
@@ -0,0 +1,396 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Q,
|
||||
Count,
|
||||
)
|
||||
from django.db.models.functions import Cast
|
||||
from django.db.models import CharField
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
WorkSpaceMemberSerializer,
|
||||
TeamSerializer,
|
||||
UserLiteSerializer,
|
||||
WorkspaceMemberAdminSerializer,
|
||||
WorkspaceMemberMeSerializer,
|
||||
ProjectMemberRoleSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from .. import BaseViewSet
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
Team,
|
||||
ProjectMember,
|
||||
Project,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
WorkSpaceAdminPermission,
|
||||
WorkspaceEntityPermission,
|
||||
WorkspaceUserPermission,
|
||||
)
|
||||
from plane.utils.cache import cache_response, invalidate_cache
|
||||
|
||||
|
||||
class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
serializer_class = WorkspaceMemberAdminSerializer
|
||||
model = WorkspaceMember
|
||||
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == "leave":
|
||||
self.permission_classes = [
|
||||
WorkspaceUserPermission,
|
||||
]
|
||||
else:
|
||||
self.permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
return super(WorkSpaceMemberViewSet, self).get_permissions()
|
||||
|
||||
search_fields = [
|
||||
"member__display_name",
|
||||
"member__first_name",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
is_active=True,
|
||||
)
|
||||
.select_related("workspace", "workspace__owner")
|
||||
.select_related("member")
|
||||
)
|
||||
|
||||
@cache_response(60 * 60 * 2)
|
||||
def list(self, request, slug):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Get all active workspace members
|
||||
workspace_members = self.get_queryset()
|
||||
|
||||
if workspace_member.role > 10:
|
||||
serializer = WorkspaceMemberAdminSerializer(
|
||||
workspace_members,
|
||||
fields=("id", "member", "role"),
|
||||
many=True,
|
||||
)
|
||||
else:
|
||||
serializer = WorkSpaceMemberSerializer(
|
||||
workspace_members,
|
||||
fields=("id", "member", "role"),
|
||||
many=True,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/members/", url_params=True, user=False
|
||||
)
|
||||
def partial_update(self, request, slug, pk):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
pk=pk,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
if request.user.id == workspace_member.member_id:
|
||||
return Response(
|
||||
{"error": "You cannot update your own role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the requested user role
|
||||
requested_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
# Check if role is being updated
|
||||
# One cannot update role higher than his own role
|
||||
if (
|
||||
"role" in request.data
|
||||
and int(request.data.get("role", workspace_member.role))
|
||||
> requested_workspace_member.role
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = WorkSpaceMemberSerializer(
|
||||
workspace_member, data=request.data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/members/", url_params=True, user=False
|
||||
)
|
||||
def destroy(self, request, slug, pk):
|
||||
# Check the user role who is deleting the user
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
pk=pk,
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# check requesting user role
|
||||
requesting_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
if str(workspace_member.id) == str(requesting_workspace_member.id):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot remove yourself from the workspace. Please use leave workspace"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if requesting_workspace_member.role < workspace_member.role:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot remove a user having role higher than you"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if (
|
||||
Project.objects.annotate(
|
||||
total_members=Count("project_projectmember"),
|
||||
member_with_role=Count(
|
||||
"project_projectmember",
|
||||
filter=Q(
|
||||
project_projectmember__member_id=workspace_member.id,
|
||||
project_projectmember__role=20,
|
||||
),
|
||||
),
|
||||
)
|
||||
.filter(total_members=1, member_with_role=1, workspace__slug=slug)
|
||||
.exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id=workspace_member.member_id,
|
||||
is_active=True,
|
||||
).update(is_active=False)
|
||||
|
||||
workspace_member.is_active = False
|
||||
workspace_member.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/members/", url_params=True, user=False
|
||||
)
|
||||
def leave(self, request, slug):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Check if the leaving user is the only admin of the workspace
|
||||
if (
|
||||
workspace_member.role == 20
|
||||
and not WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
role=20,
|
||||
is_active=True,
|
||||
).count()
|
||||
> 1
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if (
|
||||
Project.objects.annotate(
|
||||
total_members=Count("project_projectmember"),
|
||||
member_with_role=Count(
|
||||
"project_projectmember",
|
||||
filter=Q(
|
||||
project_projectmember__member_id=request.user.id,
|
||||
project_projectmember__role=20,
|
||||
),
|
||||
),
|
||||
)
|
||||
.filter(total_members=1, member_with_role=1, workspace__slug=slug)
|
||||
.exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# # Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id=workspace_member.member_id,
|
||||
is_active=True,
|
||||
).update(is_active=False)
|
||||
|
||||
# # Deactivate the user
|
||||
workspace_member.is_active = False
|
||||
workspace_member.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
|
||||
def post(self, request, slug):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
workspace_member.view_props = request.data.get("view_props", {})
|
||||
workspace_member.save()
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class WorkspaceMemberUserEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
)
|
||||
serializer = WorkspaceMemberMeSerializer(workspace_member)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
||||
serializer_class = ProjectMemberRoleSerializer
|
||||
model = ProjectMember
|
||||
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
# Fetch all project IDs where the user is involved
|
||||
project_ids = (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
.values_list("project_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Get all the project members in which the user is involved
|
||||
project_members = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id__in=project_ids,
|
||||
is_active=True,
|
||||
).select_related("project", "member", "workspace")
|
||||
project_members = ProjectMemberRoleSerializer(
|
||||
project_members, many=True
|
||||
).data
|
||||
|
||||
project_members_dict = dict()
|
||||
|
||||
# Construct a dictionary with project_id as key and project_members as value
|
||||
for project_member in project_members:
|
||||
project_id = project_member.pop("project")
|
||||
if str(project_id) not in project_members_dict:
|
||||
project_members_dict[str(project_id)] = []
|
||||
project_members_dict[str(project_id)].append(project_member)
|
||||
|
||||
return Response(project_members_dict, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TeamMemberViewSet(BaseViewSet):
|
||||
serializer_class = TeamSerializer
|
||||
model = Team
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"member__display_name",
|
||||
"member__first_name",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "workspace__owner")
|
||||
.prefetch_related("members")
|
||||
)
|
||||
|
||||
def create(self, request, slug):
|
||||
members = list(
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member__id__in=request.data.get("members", []),
|
||||
is_active=True,
|
||||
)
|
||||
.annotate(member_str_id=Cast("member", output_field=CharField()))
|
||||
.distinct()
|
||||
.values_list("member_str_id", flat=True)
|
||||
)
|
||||
|
||||
if len(members) != len(request.data.get("members", [])):
|
||||
users = list(
|
||||
set(request.data.get("members", [])).difference(members)
|
||||
)
|
||||
users = User.objects.filter(pk__in=users)
|
||||
|
||||
serializer = UserLiteSerializer(users, many=True)
|
||||
return Response(
|
||||
{
|
||||
"error": f"{len(users)} of the member(s) are not a part of the workspace",
|
||||
"members": serializer.data,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
serializer = TeamSerializer(
|
||||
data=request.data, context={"workspace": workspace}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -0,0 +1,104 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
Q,
|
||||
Count,
|
||||
)
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Module,
|
||||
ModuleLink,
|
||||
)
|
||||
from plane.app.permissions import WorkspaceViewerPermission
|
||||
from plane.app.serializers.module import ModuleSerializer
|
||||
|
||||
class WorkspaceModulesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceViewerPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
modules = (
|
||||
Module.objects.filter(workspace__slug=slug)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("lead")
|
||||
.prefetch_related("members")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"link_module",
|
||||
queryset=ModuleLink.objects.select_related(
|
||||
"module", "created_by"
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_module",
|
||||
filter=Q(
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="completed",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="cancelled",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="started",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="unstarted",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="backlog",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||
)
|
||||
|
||||
serializer = ModuleSerializer(modules, many=True).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
@@ -0,0 +1,25 @@
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import StateSerializer
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import State
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
from plane.utils.cache import cache_response
|
||||
|
||||
class WorkspaceStatesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
@cache_response(60 * 60 * 2)
|
||||
def get(self, request, slug):
|
||||
states = State.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
serializer = StateSerializer(states, many=True).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
@@ -0,0 +1,573 @@
|
||||
# Python imports
|
||||
from datetime import date
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Count,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Max,
|
||||
IntegerField,
|
||||
UUIDField,
|
||||
)
|
||||
from django.db.models.functions import ExtractWeek, Cast
|
||||
from django.db.models.fields import DateField
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
WorkSpaceSerializer,
|
||||
ProjectMemberSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueSerializer,
|
||||
WorkspaceUserPropertiesSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
ProjectMember,
|
||||
IssueActivity,
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
WorkspaceMember,
|
||||
CycleIssue,
|
||||
WorkspaceUserProperties,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
WorkspaceViewerPermission,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
|
||||
last_workspace_id = user.last_workspace_id
|
||||
|
||||
if last_workspace_id is None:
|
||||
return Response(
|
||||
{
|
||||
"project_details": [],
|
||||
"workspace_details": {},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(pk=last_workspace_id)
|
||||
workspace_serializer = WorkSpaceSerializer(workspace)
|
||||
|
||||
project_member = ProjectMember.objects.filter(
|
||||
workspace_id=last_workspace_id, member=request.user
|
||||
).select_related("workspace", "project", "member", "workspace__owner")
|
||||
|
||||
project_member_serializer = ProjectMemberSerializer(
|
||||
project_member, many=True
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"workspace_details": workspace_serializer.data,
|
||||
"project_details": project_member_serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceViewerPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, user_id):
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
state_order = [
|
||||
"backlog",
|
||||
"unstarted",
|
||||
"started",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
Q(assignees__in=[user_id])
|
||||
| Q(created_by_id=user_id)
|
||||
| Q(issue_subscribers__subscriber_id=user_id),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=~Q(labels__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(assignees__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=~Q(issue_module__module_id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.order_by("created_at")
|
||||
).distinct()
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
priority_order = (
|
||||
priority_order
|
||||
if order_by_param == "priority"
|
||||
else priority_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
# State Ordering
|
||||
elif order_by_param in [
|
||||
"state__name",
|
||||
"state__group",
|
||||
"-state__name",
|
||||
"-state__group",
|
||||
]:
|
||||
state_order = (
|
||||
state_order
|
||||
if order_by_param in ["state__name", "state__group"]
|
||||
else state_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
state_order=Case(
|
||||
*[
|
||||
When(state__group=state_group, then=Value(i))
|
||||
for i, state_group in enumerate(state_order)
|
||||
],
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order")
|
||||
# assignee and label ordering
|
||||
elif order_by_param in [
|
||||
"labels__name",
|
||||
"-labels__name",
|
||||
"assignees__first_name",
|
||||
"-assignees__first_name",
|
||||
]:
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
max_values=Max(
|
||||
order_by_param[1::]
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values"
|
||||
if order_by_param.startswith("-")
|
||||
else "max_values"
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issues = IssueSerializer(
|
||||
issue_queryset, many=True, fields=fields if fields else None
|
||||
).data
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkspaceUserPropertiesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceViewerPermission,
|
||||
]
|
||||
|
||||
def patch(self, request, slug):
|
||||
workspace_properties = WorkspaceUserProperties.objects.get(
|
||||
user=request.user,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
workspace_properties.filters = request.data.get(
|
||||
"filters", workspace_properties.filters
|
||||
)
|
||||
workspace_properties.display_filters = request.data.get(
|
||||
"display_filters", workspace_properties.display_filters
|
||||
)
|
||||
workspace_properties.display_properties = request.data.get(
|
||||
"display_properties", workspace_properties.display_properties
|
||||
)
|
||||
workspace_properties.save()
|
||||
|
||||
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def get(self, request, slug):
|
||||
(
|
||||
workspace_properties,
|
||||
_,
|
||||
) = WorkspaceUserProperties.objects.get_or_create(
|
||||
user=request.user, workspace__slug=slug
|
||||
)
|
||||
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkspaceUserProfileEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, user_id):
|
||||
user_data = User.objects.get(pk=user_id)
|
||||
|
||||
requesting_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
projects = []
|
||||
if requesting_workspace_member.role >= 10:
|
||||
projects = (
|
||||
Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_projectmember__member=request.user,
|
||||
project_projectmember__is_active=True,
|
||||
)
|
||||
.annotate(
|
||||
created_issues=Count(
|
||||
"project_issue",
|
||||
filter=Q(
|
||||
project_issue__created_by_id=user_id,
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
assigned_issues=Count(
|
||||
"project_issue",
|
||||
filter=Q(
|
||||
project_issue__assignees__in=[user_id],
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"project_issue",
|
||||
filter=Q(
|
||||
project_issue__completed_at__isnull=False,
|
||||
project_issue__assignees__in=[user_id],
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"project_issue",
|
||||
filter=Q(
|
||||
project_issue__state__group__in=[
|
||||
"backlog",
|
||||
"unstarted",
|
||||
"started",
|
||||
],
|
||||
project_issue__assignees__in=[user_id],
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.values(
|
||||
"id",
|
||||
"logo_props",
|
||||
"created_issues",
|
||||
"assigned_issues",
|
||||
"completed_issues",
|
||||
"pending_issues",
|
||||
)
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"project_data": projects,
|
||||
"user_data": {
|
||||
"email": user_data.email,
|
||||
"first_name": user_data.first_name,
|
||||
"last_name": user_data.last_name,
|
||||
"avatar": user_data.avatar,
|
||||
"cover_image": user_data.cover_image,
|
||||
"date_joined": user_data.date_joined,
|
||||
"user_timezone": user_data.user_timezone,
|
||||
"display_name": user_data.display_name,
|
||||
},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceUserActivityEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, user_id):
|
||||
projects = request.query_params.getlist("project", [])
|
||||
|
||||
queryset = IssueActivity.objects.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
actor=user_id,
|
||||
).select_related("actor", "workspace", "issue", "project")
|
||||
|
||||
if projects:
|
||||
queryset = queryset.filter(project__in=projects)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=queryset,
|
||||
on_results=lambda issue_activities: IssueActivitySerializer(
|
||||
issue_activities, many=True
|
||||
).data,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, user_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
state_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(state_group=F("state__group"))
|
||||
.values("state_group")
|
||||
.annotate(state_count=Count("state_group"))
|
||||
.order_by("state_group")
|
||||
)
|
||||
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
|
||||
priority_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.values("priority")
|
||||
.annotate(priority_count=Count("priority"))
|
||||
.filter(priority_count__gte=1)
|
||||
.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
default=Value(len(priority_order)),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
)
|
||||
.order_by("priority_order")
|
||||
)
|
||||
|
||||
created_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
.filter(**filters)
|
||||
.count()
|
||||
)
|
||||
|
||||
assigned_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.count()
|
||||
)
|
||||
|
||||
pending_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.count()
|
||||
)
|
||||
|
||||
completed_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
state__group="completed",
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.count()
|
||||
)
|
||||
|
||||
subscribed_issues_count = (
|
||||
IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug,
|
||||
subscriber_id=user_id,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.count()
|
||||
)
|
||||
|
||||
upcoming_cycles = CycleIssue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
cycle__start_date__gt=timezone.now().date(),
|
||||
issue__assignees__in=[
|
||||
user_id,
|
||||
],
|
||||
).values("cycle__name", "cycle__id", "cycle__project_id")
|
||||
|
||||
present_cycle = CycleIssue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
cycle__start_date__lt=timezone.now().date(),
|
||||
cycle__end_date__gt=timezone.now().date(),
|
||||
issue__assignees__in=[
|
||||
user_id,
|
||||
],
|
||||
).values("cycle__name", "cycle__id", "cycle__project_id")
|
||||
|
||||
return Response(
|
||||
{
|
||||
"state_distribution": state_distribution,
|
||||
"priority_distribution": priority_distribution,
|
||||
"created_issues": created_issues,
|
||||
"assigned_issues": assigned_issues_count,
|
||||
"completed_issues": completed_issues_count,
|
||||
"pending_issues": pending_issues_count,
|
||||
"subscribed_issues": subscribed_issues_count,
|
||||
"present_cycles": present_cycle,
|
||||
"upcoming_cycles": upcoming_cycles,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class UserActivityGraphEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
issue_activities = (
|
||||
IssueActivity.objects.filter(
|
||||
actor=request.user,
|
||||
workspace__slug=slug,
|
||||
created_at__date__gte=date.today() + relativedelta(months=-6),
|
||||
)
|
||||
.annotate(created_date=Cast("created_at", DateField()))
|
||||
.values("created_date")
|
||||
.annotate(activity_count=Count("created_date"))
|
||||
.order_by("created_date")
|
||||
)
|
||||
|
||||
return Response(issue_activities, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UserIssueCompletedGraphEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
month = request.GET.get("month", 1)
|
||||
|
||||
issues = (
|
||||
Issue.issue_objects.filter(
|
||||
assignees__in=[request.user],
|
||||
workspace__slug=slug,
|
||||
completed_at__month=month,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.annotate(completed_week=ExtractWeek("completed_at"))
|
||||
.annotate(week=F("completed_week") % 4)
|
||||
.values("week")
|
||||
.annotate(completed_count=Count("completed_week"))
|
||||
.order_by("week")
|
||||
)
|
||||
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
@@ -1,24 +1,22 @@
|
||||
# Python imports
|
||||
import csv
|
||||
import io
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
row_mapping = {
|
||||
"state__name": "State",
|
||||
@@ -212,9 +210,9 @@ def generate_segmented_rows(
|
||||
None,
|
||||
)
|
||||
if assignee:
|
||||
generated_row[
|
||||
0
|
||||
] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
generated_row[0] = (
|
||||
f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
)
|
||||
|
||||
if x_axis == LABEL_ID:
|
||||
label = next(
|
||||
@@ -281,9 +279,9 @@ def generate_segmented_rows(
|
||||
None,
|
||||
)
|
||||
if assignee:
|
||||
row_zero[
|
||||
index + 2
|
||||
] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
row_zero[index + 2] = (
|
||||
f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
)
|
||||
|
||||
if segmented == LABEL_ID:
|
||||
for index, segm in enumerate(row_zero[2:]):
|
||||
@@ -368,9 +366,9 @@ def generate_non_segmented_rows(
|
||||
None,
|
||||
)
|
||||
if assignee:
|
||||
row[
|
||||
0
|
||||
] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
row[0] = (
|
||||
f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
)
|
||||
|
||||
if x_axis == LABEL_ID:
|
||||
label = next(
|
||||
@@ -506,10 +504,8 @@ def analytic_export_task(email, data, slug):
|
||||
|
||||
csv_buffer = generate_csv_from_rows(rows)
|
||||
send_export_email(email, slug, csv_buffer, rows)
|
||||
logging.getLogger("plane").info("Email sent succesfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EmailNotificationLog, User, Issue
|
||||
from plane.db.models import EmailNotificationLog, Issue, User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
# acquire and delete redis lock
|
||||
def acquire_lock(lock_id, expire_time=300):
|
||||
redis_client = redis_instance()
|
||||
"""Attempt to acquire a lock with a specified expiration time."""
|
||||
return redis_client.set(lock_id, 'true', nx=True, ex=expire_time)
|
||||
return redis_client.set(lock_id, "true", nx=True, ex=expire_time)
|
||||
|
||||
|
||||
def release_lock(lock_id):
|
||||
"""Release a lock."""
|
||||
redis_client = redis_instance()
|
||||
redis_client.delete(lock_id)
|
||||
|
||||
|
||||
@shared_task
|
||||
def stack_email_notification():
|
||||
# get all email notifications
|
||||
@@ -101,31 +105,31 @@ def create_payload(notification_data):
|
||||
|
||||
# Append old_value if it's not empty and not already in the list
|
||||
if old_value:
|
||||
data.setdefault(actor_id, {}).setdefault(
|
||||
field, {}
|
||||
).setdefault("old_value", []).append(
|
||||
old_value
|
||||
) if old_value not in data.setdefault(
|
||||
actor_id, {}
|
||||
).setdefault(
|
||||
field, {}
|
||||
).get(
|
||||
"old_value", []
|
||||
) else None
|
||||
(
|
||||
data.setdefault(actor_id, {})
|
||||
.setdefault(field, {})
|
||||
.setdefault("old_value", [])
|
||||
.append(old_value)
|
||||
if old_value
|
||||
not in data.setdefault(actor_id, {})
|
||||
.setdefault(field, {})
|
||||
.get("old_value", [])
|
||||
else None
|
||||
)
|
||||
|
||||
# Append new_value if it's not empty and not already in the list
|
||||
if new_value:
|
||||
data.setdefault(actor_id, {}).setdefault(
|
||||
field, {}
|
||||
).setdefault("new_value", []).append(
|
||||
new_value
|
||||
) if new_value not in data.setdefault(
|
||||
actor_id, {}
|
||||
).setdefault(
|
||||
field, {}
|
||||
).get(
|
||||
"new_value", []
|
||||
) else None
|
||||
(
|
||||
data.setdefault(actor_id, {})
|
||||
.setdefault(field, {})
|
||||
.setdefault("new_value", [])
|
||||
.append(new_value)
|
||||
if new_value
|
||||
not in data.setdefault(actor_id, {})
|
||||
.setdefault(field, {})
|
||||
.get("new_value", [])
|
||||
else None
|
||||
)
|
||||
|
||||
if not data.get("actor_id", {}).get("activity_time", False):
|
||||
data[actor_id]["activity_time"] = str(
|
||||
@@ -136,22 +140,24 @@ def create_payload(notification_data):
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def process_mention(mention_component):
|
||||
soup = BeautifulSoup(mention_component, 'html.parser')
|
||||
mentions = soup.find_all('mention-component')
|
||||
soup = BeautifulSoup(mention_component, "html.parser")
|
||||
mentions = soup.find_all("mention-component")
|
||||
for mention in mentions:
|
||||
user_id = mention['id']
|
||||
user_id = mention["id"]
|
||||
user = User.objects.get(pk=user_id)
|
||||
user_name = user.display_name
|
||||
highlighted_name = f"@{user_name}"
|
||||
mention.replace_with(highlighted_name)
|
||||
return str(soup)
|
||||
|
||||
|
||||
def process_html_content(content):
|
||||
processed_content_list = []
|
||||
for html_content in content:
|
||||
processed_content = process_mention(html_content)
|
||||
processed_content_list.append(processed_content)
|
||||
processed_content_list.append(processed_content)
|
||||
return processed_content_list
|
||||
|
||||
|
||||
@@ -169,7 +175,7 @@ def send_email_notification(
|
||||
if acquire_lock(lock_id=lock_id):
|
||||
# get the redis instance
|
||||
ri = redis_instance()
|
||||
base_api = (ri.get(str(issue_id)).decode())
|
||||
base_api = ri.get(str(issue_id)).decode()
|
||||
data = create_payload(notification_data=notification_data)
|
||||
|
||||
# Get email configurations
|
||||
@@ -206,8 +212,12 @@ def send_email_notification(
|
||||
}
|
||||
)
|
||||
if mention:
|
||||
mention["new_value"] = process_html_content(mention.get("new_value"))
|
||||
mention["old_value"] = process_html_content(mention.get("old_value"))
|
||||
mention["new_value"] = process_html_content(
|
||||
mention.get("new_value")
|
||||
)
|
||||
mention["old_value"] = process_html_content(
|
||||
mention.get("old_value")
|
||||
)
|
||||
comments.append(
|
||||
{
|
||||
"actor_comments": mention,
|
||||
@@ -220,7 +230,9 @@ def send_email_notification(
|
||||
)
|
||||
activity_time = changes.pop("activity_time")
|
||||
# Parse the input string into a datetime object
|
||||
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
|
||||
formatted_time = datetime.strptime(
|
||||
activity_time, "%Y-%m-%d %H:%M:%S"
|
||||
).strftime("%H:%M %p")
|
||||
|
||||
if changes:
|
||||
template_data.append(
|
||||
@@ -237,12 +249,14 @@ def send_email_notification(
|
||||
},
|
||||
"activity_time": str(formatted_time),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
summary = "Updates were made to the issue by"
|
||||
|
||||
# Send the mail
|
||||
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
|
||||
subject = (
|
||||
f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
|
||||
)
|
||||
context = {
|
||||
"data": template_data,
|
||||
"summary": summary,
|
||||
@@ -257,7 +271,7 @@ def send_email_notification(
|
||||
},
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
|
||||
"workspace":str(issue.project.workspace.slug),
|
||||
"workspace": str(issue.project.workspace.slug),
|
||||
"project": str(issue.project.name),
|
||||
"user_preference": f"{base_api}/profile/preferences/email",
|
||||
"comments": comments,
|
||||
@@ -285,7 +299,9 @@ def send_email_notification(
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane").info("Email Sent Successfully")
|
||||
|
||||
# Update the logs
|
||||
EmailNotificationLog.objects.filter(
|
||||
pk__in=email_notification_ids
|
||||
).update(sent_at=timezone.now())
|
||||
@@ -294,15 +310,20 @@ def send_email_notification(
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
# release the lock
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
else:
|
||||
print("Duplicate task recived. Skipping...")
|
||||
logging.getLogger("plane").info(
|
||||
"Duplicate email received skipping"
|
||||
)
|
||||
return
|
||||
except (Issue.DoesNotExist, User.DoesNotExist) as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
log_exception(e)
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import uuid
|
||||
import os
|
||||
import uuid
|
||||
|
||||
# third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from posthog import Posthog
|
||||
|
||||
# module imports
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
def posthogConfiguration():
|
||||
@@ -51,7 +51,8 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -77,4 +78,5 @@ def workspace_invite_event(
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import boto3
|
||||
import zipfile
|
||||
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from botocore.client import Config
|
||||
from openpyxl import Workbook
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, ExporterHistory
|
||||
from plane.db.models import ExporterHistory, Issue
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
def dateTimeConverter(time):
|
||||
@@ -144,12 +145,17 @@ def generate_table_row(issue):
|
||||
issue["description_stripped"],
|
||||
issue["state__name"],
|
||||
issue["priority"],
|
||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||
else "",
|
||||
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
|
||||
if issue["assignees__first_name"] and issue["assignees__last_name"]
|
||||
else "",
|
||||
(
|
||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"]
|
||||
and issue["created_by__last_name"]
|
||||
else ""
|
||||
),
|
||||
(
|
||||
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
|
||||
if issue["assignees__first_name"] and issue["assignees__last_name"]
|
||||
else ""
|
||||
),
|
||||
issue["labels__name"],
|
||||
issue["issue_cycle__cycle__name"],
|
||||
dateConverter(issue["issue_cycle__cycle__start_date"]),
|
||||
@@ -172,12 +178,17 @@ def generate_json_row(issue):
|
||||
"Description": issue["description_stripped"],
|
||||
"State": issue["state__name"],
|
||||
"Priority": issue["priority"],
|
||||
"Created By": f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||
else "",
|
||||
"Assignee": f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
|
||||
if issue["assignees__first_name"] and issue["assignees__last_name"]
|
||||
else "",
|
||||
"Created By": (
|
||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"]
|
||||
and issue["created_by__last_name"]
|
||||
else ""
|
||||
),
|
||||
"Assignee": (
|
||||
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
|
||||
if issue["assignees__first_name"] and issue["assignees__last_name"]
|
||||
else ""
|
||||
),
|
||||
"Labels": issue["labels__name"],
|
||||
"Cycle Name": issue["issue_cycle__cycle__name"],
|
||||
"Cycle Start Date": dateConverter(
|
||||
@@ -292,7 +303,7 @@ def issue_export_task(
|
||||
workspace__id=workspace_id,
|
||||
project_id__in=project_ids,
|
||||
project__project_projectmember__member=exporter_instance.initiated_by_id,
|
||||
project__project_projectmember__is_active=True
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.select_related(
|
||||
"project", "workspace", "state", "parent", "created_by"
|
||||
@@ -393,8 +404,5 @@ def issue_export_task(
|
||||
exporter_instance.status = "failed"
|
||||
exporter_instance.reason = str(e)
|
||||
exporter_instance.save(update_fields=["status", "reason"])
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
# Python import
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
try:
|
||||
relative_link = (
|
||||
f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}"
|
||||
)
|
||||
relative_link = f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}"
|
||||
abs_url = str(current_site) + relative_link
|
||||
|
||||
(
|
||||
@@ -65,10 +60,8 @@ def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane").info("Email sent successfully")
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user