Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48c34e068b | ||
|
|
fc6701fc4a | ||
|
|
88914fec7c | ||
|
|
0692dd9ef5 | ||
|
|
f56d39d7bb | ||
|
|
b77da77d58 | ||
|
|
e2fa03d66c | ||
|
|
89a94fe056 | ||
|
|
78526ff852 | ||
|
|
a56606e070 | ||
|
|
e3e5a1f1e7 | ||
|
|
7b10918084 | ||
|
|
269d045185 | ||
|
|
2f281323a3 | ||
|
|
e169a0187d | ||
|
|
e001d6c605 | ||
|
|
cdb488f864 | ||
|
|
6a54a07cee | ||
|
|
78169fec66 | ||
|
|
b2ed07a778 | ||
|
|
1f7ef1865c | ||
|
|
18b206952b |
+1
-13
@@ -29,10 +29,9 @@ AWS_S3_BUCKET_NAME="uploads"
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# GPT settings
|
||||
SILO_BASE_URL=
|
||||
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
|
||||
OPENAI_API_KEY="sk-" # deprecated
|
||||
GPT_ENGINE="gpt-4o-mini" # deprecated
|
||||
GPT_ENGINE="gpt-3.5-turbo" # deprecated
|
||||
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1 # deprecated
|
||||
@@ -53,16 +52,5 @@ CERT_ACME_DNS=
|
||||
# Force HTTPS for handling SSL Termination
|
||||
MINIO_ENDPOINT_SSL=0
|
||||
|
||||
# Imports Config
|
||||
SILO_BASE_URL=
|
||||
|
||||
# Force HTTPS for handling SSL Termination
|
||||
MINIO_ENDPOINT_SSL=0
|
||||
|
||||
# API key rate limit
|
||||
API_KEY_RATE_LIMIT="60/minute"
|
||||
|
||||
# Mongo DB
|
||||
MONGO_DB_URL="mongodb://plane-mongodb:27017/"
|
||||
SILO_DB=silo
|
||||
SILO_DB_URL=postgresql://plane:plane@plane-db/silo
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
name: "Build and Push Docker Image"
|
||||
description: "Reusable action for building and pushing Docker images"
|
||||
inputs:
|
||||
docker-username:
|
||||
description: "The Dockerhub username"
|
||||
required: true
|
||||
dockerhub-token:
|
||||
description: "The Dockerhub Token"
|
||||
required: true
|
||||
|
||||
# Docker Image Options
|
||||
docker-image-owner:
|
||||
description: "The owner of the Docker image"
|
||||
required: true
|
||||
docker-image-name:
|
||||
description: "The name of the Docker image"
|
||||
required: true
|
||||
build-context:
|
||||
description: "The build context"
|
||||
required: true
|
||||
default: "."
|
||||
dockerfile-path:
|
||||
description: "The path to the Dockerfile"
|
||||
required: true
|
||||
build-args:
|
||||
description: "The build arguments"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Buildx Options
|
||||
buildx-driver:
|
||||
description: "Buildx driver"
|
||||
required: true
|
||||
default: "docker-container"
|
||||
buildx-version:
|
||||
description: "Buildx version"
|
||||
required: true
|
||||
default: "latest"
|
||||
buildx-platforms:
|
||||
description: "Buildx platforms"
|
||||
required: true
|
||||
default: "linux/amd64"
|
||||
buildx-endpoint:
|
||||
description: "Buildx endpoint"
|
||||
required: true
|
||||
default: "default"
|
||||
|
||||
# Release Build Options
|
||||
build-release:
|
||||
description: "Flag to publish release"
|
||||
required: false
|
||||
default: "false"
|
||||
build-prerelease:
|
||||
description: "Flag to publish prerelease"
|
||||
required: false
|
||||
default: "false"
|
||||
release-version:
|
||||
description: "The release version"
|
||||
required: false
|
||||
default: "latest"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set Docker Tag
|
||||
shell: bash
|
||||
env:
|
||||
IMG_OWNER: ${{ inputs.docker-image-owner }}
|
||||
IMG_NAME: ${{ inputs.docker-image-name }}
|
||||
BUILD_RELEASE: ${{ inputs.build-release }}
|
||||
IS_PRERELEASE: ${{ inputs.build-prerelease }}
|
||||
REL_VERSION: ${{ inputs.release-version }}
|
||||
run: |
|
||||
FLAT_BRANCH_VERSION=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9.-]//g')
|
||||
|
||||
if [ "${{ env.BUILD_RELEASE }}" == "true" ]; then
|
||||
semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$"
|
||||
if [[ ! ${{ env.REL_VERSION }} =~ $semver_regex ]]; then
|
||||
echo "Invalid Release Version Format : ${{ env.REL_VERSION }}"
|
||||
echo "Please provide a valid SemVer version"
|
||||
echo "e.g. v1.2.3 or v1.2.3-alpha-1"
|
||||
echo "Exiting the build process"
|
||||
exit 1 # Exit with status 1 to fail the step
|
||||
fi
|
||||
|
||||
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${{ env.REL_VERSION }}
|
||||
|
||||
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
|
||||
TAG=${TAG},${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:stable
|
||||
fi
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:latest
|
||||
else
|
||||
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${FLAT_BRANCH_VERSION}
|
||||
fi
|
||||
|
||||
echo "DOCKER_TAGS=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.dockerhub-token}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ inputs.buildx-driver }}
|
||||
version: ${{ inputs.buildx-version }}
|
||||
endpoint: ${{ inputs.buildx-endpoint }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
with:
|
||||
context: ${{ inputs.build-context }}
|
||||
file: ${{ inputs.dockerfile-path }}
|
||||
platforms: ${{ inputs.buildx-platforms }}
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
push: true
|
||||
build-args: ${{ inputs.build-args }}
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ inputs.docker-username }}
|
||||
DOCKER_PASSWORD: ${{ inputs.dockerhub-token }}
|
||||
@@ -1,168 +0,0 @@
|
||||
name: "Build and Push Docker Image"
|
||||
description: "Reusable action for building and pushing Docker images"
|
||||
inputs:
|
||||
docker-username:
|
||||
description: "The Dockerhub username"
|
||||
required: true
|
||||
dockerhub-token:
|
||||
description: "The Dockerhub Token"
|
||||
required: true
|
||||
|
||||
# Harbor Options
|
||||
harbor-push:
|
||||
description: "Flag to push to Harbor"
|
||||
required: false
|
||||
default: "false"
|
||||
harbor-username:
|
||||
description: "The Harbor username"
|
||||
required: false
|
||||
harbor-token:
|
||||
description: "The Harbor token"
|
||||
required: false
|
||||
harbor-registry:
|
||||
description: "The Harbor registry"
|
||||
required: false
|
||||
default: "registry.plane.tools"
|
||||
harbor-project:
|
||||
description: "The Harbor project"
|
||||
required: false
|
||||
|
||||
# Docker Image Options
|
||||
docker-image-owner:
|
||||
description: "The owner of the Docker image"
|
||||
required: true
|
||||
docker-image-name:
|
||||
description: "The name of the Docker image"
|
||||
required: true
|
||||
build-context:
|
||||
description: "The build context"
|
||||
required: true
|
||||
default: "."
|
||||
dockerfile-path:
|
||||
description: "The path to the Dockerfile"
|
||||
required: true
|
||||
build-args:
|
||||
description: "The build arguments"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Buildx Options
|
||||
buildx-driver:
|
||||
description: "Buildx driver"
|
||||
required: true
|
||||
default: "docker-container"
|
||||
buildx-version:
|
||||
description: "Buildx version"
|
||||
required: true
|
||||
default: "latest"
|
||||
buildx-platforms:
|
||||
description: "Buildx platforms"
|
||||
required: true
|
||||
default: "linux/amd64"
|
||||
buildx-endpoint:
|
||||
description: "Buildx endpoint"
|
||||
required: true
|
||||
default: "default"
|
||||
|
||||
# Release Build Options
|
||||
build-release:
|
||||
description: "Flag to publish release"
|
||||
required: false
|
||||
default: "false"
|
||||
build-prerelease:
|
||||
description: "Flag to publish prerelease"
|
||||
required: false
|
||||
default: "false"
|
||||
release-version:
|
||||
description: "The release version"
|
||||
required: false
|
||||
default: "latest"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set Docker Tag
|
||||
shell: bash
|
||||
env:
|
||||
IMG_OWNER: ${{ inputs.docker-image-owner }}
|
||||
IMG_NAME: ${{ inputs.docker-image-name }}
|
||||
HARBOR_PUSH: ${{ inputs.harbor-push }}
|
||||
HARBOR_REGISTRY: ${{ inputs.harbor-registry }}
|
||||
HARBOR_PROJECT: ${{ inputs.harbor-project }}
|
||||
BUILD_RELEASE: ${{ inputs.build-release }}
|
||||
IS_PRERELEASE: ${{ inputs.build-prerelease }}
|
||||
REL_VERSION: ${{ inputs.release-version }}
|
||||
run: |
|
||||
FLAT_BRANCH_VERSION=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9.-]//g')
|
||||
|
||||
if [ "${{ env.BUILD_RELEASE }}" == "true" ]; then
|
||||
semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$"
|
||||
if [[ ! ${{ env.REL_VERSION }} =~ $semver_regex ]]; then
|
||||
echo "Invalid Release Version Format : ${{ env.REL_VERSION }}"
|
||||
echo "Please provide a valid SemVer version"
|
||||
echo "e.g. v1.2.3 or v1.2.3-alpha-1"
|
||||
echo "Exiting the build process"
|
||||
exit 1 # Exit with status 1 to fail the step
|
||||
fi
|
||||
|
||||
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${{ env.REL_VERSION }}
|
||||
|
||||
if [ "${{ env.HARBOR_PUSH }}" == "true" ]; then
|
||||
TAG=${TAG},${{ env.HARBOR_REGISTRY }}/${{ env.HARBOR_PROJECT }}/${{ env.IMG_NAME }}:${{ env.REL_VERSION }}
|
||||
fi
|
||||
|
||||
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
|
||||
TAG=${TAG},${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:stable
|
||||
if [ "${{ env.HARBOR_PUSH }}" == "true" ]; then
|
||||
TAG=${TAG},${{ env.HARBOR_REGISTRY }}/${{ env.HARBOR_PROJECT }}/${{ env.IMG_NAME }}:stable
|
||||
fi
|
||||
fi
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:latest
|
||||
if [ "${{ env.HARBOR_PUSH }}" == "true" ]; then
|
||||
TAG=${TAG},${{ env.HARBOR_REGISTRY }}/${{ env.HARBOR_PROJECT }}/${{ env.IMG_NAME }}:latest
|
||||
fi
|
||||
else
|
||||
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${FLAT_BRANCH_VERSION}
|
||||
if [ "${{ env.HARBOR_PUSH }}" == "true" ]; then
|
||||
TAG=${TAG},${{ env.HARBOR_REGISTRY }}/${{ env.HARBOR_PROJECT }}/${{ env.IMG_NAME }}:${FLAT_BRANCH_VERSION}
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "DOCKER_TAGS=${TAG}" >> $GITHUB_ENV
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.dockerhub-token}}
|
||||
- name: Login to Harbor
|
||||
if: ${{ inputs.harbor-push }} == "true"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.harbor-username }}
|
||||
password: ${{ inputs.harbor-token }}
|
||||
registry: ${{ inputs.harbor-registry }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ inputs.buildx-driver }}
|
||||
version: ${{ inputs.buildx-version }}
|
||||
endpoint: ${{ inputs.buildx-endpoint }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
with:
|
||||
context: ${{ inputs.build-context }}
|
||||
file: ${{ inputs.dockerfile-path }}
|
||||
platforms: ${{ inputs.buildx-platforms }}
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
push: true
|
||||
build-args: ${{ inputs.build-args }}
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ inputs.docker-username }}
|
||||
DOCKER_PASSWORD: ${{ inputs.dockerhub-token }}
|
||||
@@ -1,305 +0,0 @@
|
||||
name: AMI Build - AWS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ami_prefix:
|
||||
description: 'AMI Prefix'
|
||||
required: true
|
||||
default: 'plane-commercial'
|
||||
prime_host:
|
||||
description: 'Prime Host'
|
||||
required: true
|
||||
default: 'https://prime.plane.so'
|
||||
ami_publish_region:
|
||||
description: 'AMI Publish Regions (comma separated)'
|
||||
type: string
|
||||
required: true
|
||||
default: 'us-east-1'
|
||||
mark_manifest_latest:
|
||||
description: 'Mark manifest as latest'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
env:
|
||||
# Inputs
|
||||
AMI_PREFIX: ${{ inputs.ami_prefix || 'plane-commercial' }}
|
||||
PRIME_HOST: ${{ inputs.prime_host || 'https://prime.plane.so' }}
|
||||
AMI_PUBLISH_REGION: ${{ inputs.ami_publish_region || 'us-east-1' }}
|
||||
# Inputs by Devops
|
||||
AWS_MANIFEST_BUCKET: 'plane-terraform-marketplace'
|
||||
AWS_VPC_CIDR: '10.34.0.0/16'
|
||||
AWS_SUBNET_CIDR: '10.34.1.0/24'
|
||||
AWS_VPC_REGION: 'us-east-1'
|
||||
AWS_BASE_IMAGE_OWNER: '099720109477'
|
||||
# Secrets
|
||||
AWS_ACCESS_KEY: ${{ secrets.MARKETPLACE_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_KEY: ${{ secrets.MARKETPLACE_AWS_SECRET_ACCESS_KEY }}
|
||||
# Constants
|
||||
CURRENT_MANIFEST_FILE: 'ee-docker-aws-ami-manifest.json'
|
||||
EE_PACKER_FILE: 'ee-docker-aws-ami.pkr.hcl'
|
||||
CF_TEMPLATE_FILE: deployments/ami/commercial/ee-cloudformation-template.yaml
|
||||
CF_OUTPUT_FILE: deployments/ami/commercial/plane-commercial-cloudformation.yaml
|
||||
MARK_MANIFEST_LATEST: ${{ inputs.mark_manifest_latest || false }}
|
||||
|
||||
jobs:
|
||||
|
||||
build_ami:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ env.AWS_ACCESS_KEY }}
|
||||
aws-secret-access-key: ${{ env.AWS_SECRET_KEY }}
|
||||
aws-region: ${{ env.AWS_VPC_REGION }}
|
||||
|
||||
- name: Setup `packer`
|
||||
uses: hashicorp/setup-packer@main
|
||||
id: setup
|
||||
with:
|
||||
version: latest
|
||||
- name: Copy Upload Assets
|
||||
run: |
|
||||
mkdir -p plane-dist
|
||||
cp deployments/ami/commercial/cloudinit-ee/* plane-dist/
|
||||
|
||||
- name: Run `packer init`
|
||||
id: init
|
||||
run: "packer init ./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}"
|
||||
|
||||
- name: Run `packer validate`
|
||||
id: validate
|
||||
run: "packer validate ./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}"
|
||||
|
||||
- name: Make Variables File
|
||||
id: make_variables_file
|
||||
run: |
|
||||
touch variables.pkrvars.hcl
|
||||
echo "aws_region = \"${AWS_VPC_REGION}\"" >> variables.pkrvars.hcl
|
||||
echo "ami_name_prefix = \"${AMI_PREFIX}\"" >> variables.pkrvars.hcl
|
||||
echo "vpc_cidr = \"${AWS_VPC_CIDR}\"" >> variables.pkrvars.hcl
|
||||
echo "subnet_cidr = \"${AWS_SUBNET_CIDR}\"" >> variables.pkrvars.hcl
|
||||
echo "base_image_owner = \"${AWS_BASE_IMAGE_OWNER}\"" >> variables.pkrvars.hcl
|
||||
echo "prime_host = \"${PRIME_HOST}\"" >> variables.pkrvars.hcl
|
||||
echo "instance_type = \"t3a.xlarge\"" >> variables.pkrvars.hcl
|
||||
echo "manifest_file_name = \"${{ env.CURRENT_MANIFEST_FILE }}\"" >> variables.pkrvars.hcl
|
||||
|
||||
# split AMI_PUBLISH_REGION by comma and add to ami_regions
|
||||
ami_regions=$(echo "${AMI_PUBLISH_REGION}" | sed 's/[[:space:]]*,[[:space:]]*/\n/g' | jq -R . | jq -s -c .)
|
||||
echo "ami_regions = ${ami_regions}" >> variables.pkrvars.hcl
|
||||
|
||||
cat variables.pkrvars.hcl
|
||||
|
||||
- name: Run `packer build`
|
||||
id: build
|
||||
run: |
|
||||
packer build \
|
||||
-var "aws_access_key=${{ env.AWS_ACCESS_KEY }}" \
|
||||
-var "aws_secret_key=${{ env.AWS_SECRET_KEY }}" \
|
||||
-var-file=variables.pkrvars.hcl \
|
||||
./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}
|
||||
|
||||
- name: Extract AMI Information and Create Summary
|
||||
id: ami_info
|
||||
run: |
|
||||
# Extract AMI details from manifest
|
||||
AMI_STRING=$(jq -r '.builds[-1].artifact_id' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
AMI_NAME=$(jq -r '.builds[-1].custom_data.ami_name' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
BUILD_TIME=$(jq -r '.builds[-1].custom_data.build_time' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
|
||||
# Create array of AMI information
|
||||
declare -a AMI_INFO
|
||||
IFS=',' read -ra AMI_ARRAY <<< "$AMI_STRING"
|
||||
for ami in "${AMI_ARRAY[@]}"; do
|
||||
REGION=$(echo "$ami" | cut -d ":" -f1)
|
||||
AMI_ID=$(echo "$ami" | cut -d ":" -f2)
|
||||
AMI_INFO+=("$REGION:$AMI_ID")
|
||||
done
|
||||
|
||||
# Add git information to manifest
|
||||
jq --arg branch "${{ github.ref_name }}" \
|
||||
--arg commit "${{ github.sha }}" \
|
||||
--arg build_time "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||
'.builds[-1].custom_data += {git_branch: $branch, git_commit: $commit, build_timestamp: $build_time}' \
|
||||
${{env.CURRENT_MANIFEST_FILE}} > temp-manifest.json
|
||||
mv temp-manifest.json ${{env.CURRENT_MANIFEST_FILE}}
|
||||
|
||||
- name: Store Manifest in S3
|
||||
run: |
|
||||
# Also store a versioned copy
|
||||
aws s3 cp ${{env.CURRENT_MANIFEST_FILE}} "s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-${{ github.sha }}.json"
|
||||
|
||||
- name: Store Manifest in S3 as latest
|
||||
if: ${{ env.MARK_MANIFEST_LATEST == 'true' }}
|
||||
run: |
|
||||
# Store the current manifest as latest
|
||||
aws s3 cp ${{env.CURRENT_MANIFEST_FILE}} s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-latest.json || true
|
||||
|
||||
- name: Upload Build Manifest as Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ee-docker-aws-ami-manifest
|
||||
path: ${{env.CURRENT_MANIFEST_FILE}}
|
||||
retention-days: 30
|
||||
|
||||
- name: Tag AMIs
|
||||
run: |
|
||||
# Extract AMI string again
|
||||
AMI_STRING=$(jq -r '.builds[-1].artifact_id' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
|
||||
# Process and tag each AMI in its respective region
|
||||
IFS=',' read -ra AMI_ARRAY <<< "$AMI_STRING"
|
||||
for ami in "${AMI_ARRAY[@]}"; do
|
||||
REGION=$(echo "$ami" | cut -d ":" -f1)
|
||||
AMI_ID=$(echo "$ami" | cut -d ":" -f2)
|
||||
|
||||
echo "Tagging AMI ${AMI_ID} in region ${REGION}"
|
||||
aws ec2 create-tags \
|
||||
--region "$REGION" \
|
||||
--resources "$AMI_ID" \
|
||||
--tags \
|
||||
Key=GitBranch,Value=${{ github.ref_name }} \
|
||||
Key=GitCommit,Value=${{ github.sha }} \
|
||||
Key=BuildTime,Value=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
done
|
||||
|
||||
- name: Update CloudFormation Template
|
||||
run: |
|
||||
# Install yq if not present
|
||||
if ! command -v yq &> /dev/null; then
|
||||
echo "Installing yq..."
|
||||
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
||||
sudo chmod +x /usr/local/bin/yq
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "Error: jq is required but not installed. Please install jq to continue."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARTIFACT_ID=$(jq -r '.builds[0].artifact_id' "${{ env.CURRENT_MANIFEST_FILE }}")
|
||||
|
||||
if [[ "$ARTIFACT_ID" == "null" || -z "$ARTIFACT_ID" ]]; then
|
||||
echo "Error: Could not extract artifact_id from manifest file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found artifact_id: $ARTIFACT_ID"
|
||||
|
||||
REGIONS=()
|
||||
AMIS=()
|
||||
|
||||
# Split by comma and process each region:ami pair
|
||||
IFS=',' read -ra PAIRS <<< "$ARTIFACT_ID"
|
||||
for pair in "${PAIRS[@]}"; do
|
||||
# Trim whitespace
|
||||
pair=$(echo "$pair" | xargs)
|
||||
|
||||
# Split by colon to get region and ami
|
||||
IFS=':' read -ra REGION_AMI <<< "$pair"
|
||||
if [[ ${#REGION_AMI[@]} -eq 2 ]]; then
|
||||
region="${REGION_AMI[0]}"
|
||||
ami="${REGION_AMI[1]}"
|
||||
REGIONS+=("$region")
|
||||
AMIS+=("$ami")
|
||||
echo " $region -> $ami"
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if we found any AMI mappings
|
||||
if [[ ${#REGIONS[@]} -eq 0 ]]; then
|
||||
echo "Error: No valid region:ami pairs found in artifact_id"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy the original template to output file
|
||||
cp "${{ env.CF_TEMPLATE_FILE }}" "${{ env.CF_OUTPUT_FILE }}"
|
||||
|
||||
echo "Regions: ${REGIONS[@]}"
|
||||
echo "AMIs: ${AMIS[@]}"
|
||||
|
||||
echo "Updating AMI IDs in template..."
|
||||
|
||||
REGION_RESTRICTIONS=()
|
||||
# Update AMI IDs for each region found in the manifest
|
||||
REGIONS_STRING=""
|
||||
for i in "${!REGIONS[@]}"; do
|
||||
region="${REGIONS[$i]}"
|
||||
ami_id="${AMIS[$i]}"
|
||||
echo " Updating $region with AMI: $ami_id"
|
||||
REGIONS_STRING+="${region}, "
|
||||
yq eval ".Parameters.AMIId.Default = \"${ami_id}\"" -i "${{ env.CF_OUTPUT_FILE }}"
|
||||
done
|
||||
|
||||
cat "${{ env.CF_OUTPUT_FILE }}"
|
||||
|
||||
echo "Updated template saved as: ${{ env.CF_OUTPUT_FILE }}"
|
||||
|
||||
- name: Store CloudFormation Template in S3
|
||||
run: |
|
||||
# Store the current manifest as latest
|
||||
aws s3 cp ${{env.CF_OUTPUT_FILE}} s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/cloudformation/plane-commercial-cloudformation-latest.yaml
|
||||
|
||||
date_string=$(date +%d%b%Y)
|
||||
aws s3 cp ${{env.CF_OUTPUT_FILE}} s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/cloudformation/plane-commercial-cloudformation-${date_string}.yaml
|
||||
|
||||
- name: Upload Updated CloudFormation Template
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cloudformation-template
|
||||
path: ${{ env.CF_OUTPUT_FILE }}
|
||||
retention-days: 30
|
||||
|
||||
- name: Print Build Summary
|
||||
id: print_build_summary
|
||||
run: |
|
||||
# Extract AMI details from manifest
|
||||
AMI_STRING=$(jq -r '.builds[-1].artifact_id' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
AMI_NAME=$(jq -r '.builds[-1].custom_data.ami_name' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
BUILD_TIME=$(jq -r '.builds[-1].custom_data.build_time' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
|
||||
# Create array of AMI information
|
||||
declare -a AMI_INFO
|
||||
IFS=',' read -ra AMI_ARRAY <<< "$AMI_STRING"
|
||||
for ami in "${AMI_ARRAY[@]}"; do
|
||||
REGION=$(echo "$ami" | cut -d ":" -f1)
|
||||
AMI_ID=$(echo "$ami" | cut -d ":" -f2)
|
||||
AMI_INFO+=("$REGION:$AMI_ID")
|
||||
done
|
||||
|
||||
# Create build summary with all AMIs
|
||||
{
|
||||
echo "### 🌎 Regional AMI Distribution"
|
||||
echo "| Region | AMI ID |"
|
||||
echo "| --- | --- |"
|
||||
for ami_info in "${AMI_INFO[@]}"; do
|
||||
region=${ami_info%:*}
|
||||
ami_id=${ami_info#*:}
|
||||
echo "| \`${region}\` | \`${ami_id}\` |"
|
||||
done
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
date_string=$(date +%d%b%Y)
|
||||
{
|
||||
echo "### 📁 S3 Files"
|
||||
echo "| File | Path | "
|
||||
echo "| --- | --- |"
|
||||
echo "| Latest CF Template | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/cloudformation/plane-commercial-cloudformation-latest.yaml\` |"
|
||||
echo "| CF Template | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/cloudformation/plane-commercial-cloudformation-${date_string}.yaml\` |"
|
||||
echo "| Current Manifest | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-${{ github.sha }}.json\` |"
|
||||
echo "| Latest Manifest | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-latest.json\` |"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Console output for logs
|
||||
echo "✅ AMI built successfully!"
|
||||
echo "🔹 AMI Information:"
|
||||
for ami_info in "${AMI_INFO[@]}"; do
|
||||
region=${ami_info%:*}
|
||||
ami_id=${ami_info#*:}
|
||||
echo " • Region: ${region}, AMI ID: ${ami_id}"
|
||||
done
|
||||
@@ -1,180 +0,0 @@
|
||||
name: AMI Build - DigitalOcean
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- appliance-digitalocean
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ami_prefix:
|
||||
description: 'AMI Prefix'
|
||||
required: true
|
||||
default: 'plane-commercial'
|
||||
prime_host:
|
||||
description: 'Prime Host'
|
||||
required: true
|
||||
default: 'https://prime.plane.so'
|
||||
mark_manifest_latest:
|
||||
description: 'Mark manifest as latest'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
env:
|
||||
# Inputs
|
||||
AMI_PREFIX: ${{ inputs.ami_prefix || 'plane-commercial' }}
|
||||
PRIME_HOST: ${{ inputs.prime_host || 'https://prime.plane.so' }}
|
||||
# Inputs by Devops
|
||||
AWS_MANIFEST_BUCKET: 'plane-terraform-marketplace'
|
||||
AWS_VPC_REGION: 'us-east-1'
|
||||
# Secrets
|
||||
AWS_ACCESS_KEY: ${{ secrets.MARKETPLACE_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_KEY: ${{ secrets.MARKETPLACE_AWS_SECRET_ACCESS_KEY }}
|
||||
# Constants
|
||||
CURRENT_MANIFEST_FILE: 'ee-docker-digital-ocean-manifest.json'
|
||||
EE_PACKER_FILE: 'ee-docker-digital-ocean.pkr.hcl'
|
||||
MARK_MANIFEST_LATEST: ${{ inputs.mark_manifest_latest || false }}
|
||||
|
||||
jobs:
|
||||
|
||||
build_ami:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ env.AWS_ACCESS_KEY }}
|
||||
aws-secret-access-key: ${{ env.AWS_SECRET_KEY }}
|
||||
aws-region: ${{ env.AWS_VPC_REGION }}
|
||||
|
||||
- name: Setup `packer`
|
||||
uses: hashicorp/setup-packer@main
|
||||
id: setup
|
||||
with:
|
||||
version: latest
|
||||
- name: Copy Upload Assets
|
||||
run: |
|
||||
mkdir -p plane-dist
|
||||
cp deployments/ami/commercial/cloudinit-ee/* plane-dist/
|
||||
|
||||
- name: Run `packer init`
|
||||
id: init
|
||||
run: "packer init ./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}"
|
||||
|
||||
- name: Run `packer validate`
|
||||
id: validate
|
||||
run: "packer validate ./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}"
|
||||
|
||||
- name: Make Variables File
|
||||
id: make_variables_file
|
||||
run: |
|
||||
touch variables.pkrvars.hcl
|
||||
echo "ami_name_prefix = \"${AMI_PREFIX}\"" >> variables.pkrvars.hcl
|
||||
echo "prime_host = \"${PRIME_HOST}\"" >> variables.pkrvars.hcl
|
||||
echo "instance_type = \"s-2vcpu-4gb\"" >> variables.pkrvars.hcl
|
||||
echo "manifest_file_name = \"${{ env.CURRENT_MANIFEST_FILE }}\"" >> variables.pkrvars.hcl
|
||||
|
||||
cat variables.pkrvars.hcl
|
||||
|
||||
- name: Run `packer build`
|
||||
id: build
|
||||
run: |
|
||||
packer build \
|
||||
-var "api_token=${{ secrets.MARKETPLACE_DIGITAL_OCEAN_API_TOKEN }}" \
|
||||
-var-file=variables.pkrvars.hcl \
|
||||
./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}
|
||||
|
||||
- name: Extract AMI Information and Create Summary
|
||||
id: ami_info
|
||||
run: |
|
||||
# Extract AMI details from manifest
|
||||
AMI_STRING=$(jq -r '.builds[-1].artifact_id' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
AMI_NAME=$(jq -r '.builds[-1].custom_data.ami_name' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
BUILD_TIME=$(jq -r '.builds[-1].custom_data.build_time' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
|
||||
# Create array of AMI information
|
||||
declare -a AMI_INFO
|
||||
IFS=',' read -ra AMI_ARRAY <<< "$AMI_STRING"
|
||||
for ami in "${AMI_ARRAY[@]}"; do
|
||||
REGION=$(echo "$ami" | cut -d ":" -f1)
|
||||
AMI_ID=$(echo "$ami" | cut -d ":" -f2)
|
||||
AMI_INFO+=("$REGION:$AMI_ID")
|
||||
done
|
||||
|
||||
# Add git information to manifest
|
||||
jq --arg branch "${{ github.ref_name }}" \
|
||||
--arg commit "${{ github.sha }}" \
|
||||
--arg build_time "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||
'.builds[-1].custom_data += {git_branch: $branch, git_commit: $commit, build_timestamp: $build_time}' \
|
||||
${{env.CURRENT_MANIFEST_FILE}} > temp-manifest.json
|
||||
mv temp-manifest.json ${{env.CURRENT_MANIFEST_FILE}}
|
||||
|
||||
- name: Store Manifest in S3
|
||||
run: |
|
||||
# Also store a versioned copy
|
||||
aws s3 cp ${{env.CURRENT_MANIFEST_FILE}} "s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-digitalocean-manifest-${{ github.sha }}.json"
|
||||
|
||||
- name: Store Manifest in S3 as latest
|
||||
if: ${{ env.MARK_MANIFEST_LATEST == 'true' }}
|
||||
run: |
|
||||
# Store the current manifest as latest
|
||||
aws s3 cp ${{env.CURRENT_MANIFEST_FILE}} s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-digitalocean-manifest-latest.json || true
|
||||
|
||||
- name: Upload Build Manifest as Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ee-docker-digital-ocean-manifest
|
||||
path: ${{env.CURRENT_MANIFEST_FILE}}
|
||||
retention-days: 30
|
||||
|
||||
|
||||
- name: Print Build Summary
|
||||
id: print_build_summary
|
||||
run: |
|
||||
# Extract AMI details from manifest
|
||||
AMI_STRING=$(jq -r '.builds[-1].artifact_id' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
AMI_NAME=$(jq -r '.builds[-1].custom_data.ami_name' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
BUILD_TIME=$(jq -r '.builds[-1].custom_data.build_time' ${{env.CURRENT_MANIFEST_FILE}})
|
||||
|
||||
# Create array of AMI information
|
||||
declare -a AMI_INFO
|
||||
IFS=',' read -ra AMI_ARRAY <<< "$AMI_STRING"
|
||||
for ami in "${AMI_ARRAY[@]}"; do
|
||||
REGION=$(echo "$ami" | cut -d ":" -f1)
|
||||
AMI_ID=$(echo "$ami" | cut -d ":" -f2)
|
||||
AMI_INFO+=("$REGION:$AMI_ID")
|
||||
done
|
||||
|
||||
# Create build summary with all AMIs
|
||||
{
|
||||
echo "### 🌎 Regional AMI Distribution"
|
||||
echo "| Region | AMI ID |"
|
||||
echo "| --- | --- |"
|
||||
for ami_info in "${AMI_INFO[@]}"; do
|
||||
region=${ami_info%:*}
|
||||
ami_id=${ami_info#*:}
|
||||
echo "| \`${region}\` | \`${ami_id}\` |"
|
||||
done
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
date_string=$(date +%d%b%Y)
|
||||
{
|
||||
echo "### 📁 S3 Files"
|
||||
echo "| File | Path | "
|
||||
echo "| --- | --- |"
|
||||
echo "| Current Manifest | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-${{ github.sha }}.json\` |"
|
||||
echo "| Latest Manifest | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-latest.json\` |"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Console output for logs
|
||||
echo "✅ AMI built successfully!"
|
||||
echo "🔹 AMI Information:"
|
||||
for ami_info in "${AMI_INFO[@]}"; do
|
||||
region=${ami_info%:*}
|
||||
ami_id=${ami_info#*:}
|
||||
echo " • Region: ${region}, AMI ID: ${ami_id}"
|
||||
done
|
||||
@@ -1,370 +0,0 @@
|
||||
name: Branch Build Cloud
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_type:
|
||||
description: "Type of build to run"
|
||||
required: true
|
||||
type: choice
|
||||
default: "Build"
|
||||
options:
|
||||
- "Build"
|
||||
- "Release"
|
||||
releaseVersion:
|
||||
description: "Release Version"
|
||||
type: string
|
||||
default: v0.0.0-cloud
|
||||
useVaultSecrets:
|
||||
description: "Use Vault Secrets"
|
||||
type: boolean
|
||||
default: false
|
||||
required: true
|
||||
isPrerelease:
|
||||
description: "Is Pre-release"
|
||||
type: boolean
|
||||
default: false
|
||||
required: true
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.ref_name }}
|
||||
VAULT_KP_PREFIX: plane-ee-cloud-builds
|
||||
BUILD_TYPE: ${{ github.event.inputs.build_type }}
|
||||
RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }}
|
||||
IS_PRERELEASE: ${{ github.event.inputs.isPrerelease }}
|
||||
|
||||
jobs:
|
||||
branch_build_setup:
|
||||
name: Build Setup
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
|
||||
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
|
||||
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
|
||||
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
||||
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
|
||||
|
||||
dh_img_web: ${{ steps.set_env_variables.outputs.DH_IMG_WEB }}
|
||||
dh_img_space: ${{ steps.set_env_variables.outputs.DH_IMG_SPACE }}
|
||||
dh_img_admin: ${{ steps.set_env_variables.outputs.DH_IMG_ADMIN }}
|
||||
dh_img_live: ${{ steps.set_env_variables.outputs.DH_IMG_LIVE }}
|
||||
dh_img_silo: ${{ steps.set_env_variables.outputs.DH_IMG_SILO }}
|
||||
dh_img_backend: ${{ steps.set_env_variables.outputs.DH_IMG_BACKEND }}
|
||||
dh_img_email: ${{ steps.set_env_variables.outputs.DH_IMG_EMAIL }}
|
||||
harbor_push: ${{ steps.set_env_variables.outputs.HARBOR_PUSH }}
|
||||
|
||||
build_type: ${{steps.set_env_variables.outputs.BUILD_TYPE}}
|
||||
build_release: ${{ steps.set_env_variables.outputs.BUILD_RELEASE }}
|
||||
build_prerelease: ${{ steps.set_env_variables.outputs.BUILD_PRERELEASE }}
|
||||
release_version: ${{ steps.set_env_variables.outputs.RELEASE_VERSION }}
|
||||
vault_secrets: ${{ steps.get_vault_secrets.outputs.VAULT_SECRETS }}
|
||||
build_args: ${{ steps.prepare_build_args.outputs.BUILD_ARGS }}
|
||||
steps:
|
||||
- id: set_env_variables
|
||||
name: Set Environment Variables
|
||||
run: |
|
||||
echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
|
||||
|
||||
BR_NAME=$( echo "${{ env.TARGET_BRANCH }}" | sed 's/[^a-zA-Z0-9.-]//g')
|
||||
echo "TARGET_BRANCH=$BR_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "DH_IMG_WEB=web-cloud" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_SPACE=space-cloud" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_ADMIN=admin-cloud" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_LIVE=live-cloud" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_SILO=silo-cloud" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_BACKEND=backend-cloud" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_EMAIL=email-cloud" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "BUILD_TYPE=${{env.BUILD_TYPE}}" >> $GITHUB_OUTPUT
|
||||
BUILD_RELEASE=false
|
||||
BUILD_PRERELEASE=false
|
||||
RELVERSION="latest"
|
||||
|
||||
if [ "${{ env.BUILD_TYPE }}" == "Release" ]; then
|
||||
FLAT_RELEASE_VERSION=$(echo "${{ env.RELEASE_VERSION }}" | sed 's/[^a-zA-Z0-9.-]//g')
|
||||
echo "FLAT_RELEASE_VERSION=${FLAT_RELEASE_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$"
|
||||
if [[ ! $FLAT_RELEASE_VERSION =~ $semver_regex ]]; then
|
||||
echo "Invalid Release Version Format : $FLAT_RELEASE_VERSION"
|
||||
echo "Please provide a valid SemVer version"
|
||||
echo "e.g. v1.2.3 or v1.2.3-alpha-1"
|
||||
echo "Exiting the build process"
|
||||
exit 1 # Exit with status 1 to fail the step
|
||||
fi
|
||||
BUILD_RELEASE=true
|
||||
RELVERSION=$FLAT_RELEASE_VERSION
|
||||
|
||||
if [ "${{ env.IS_PRERELEASE }}" == "true" ]; then
|
||||
BUILD_PRERELEASE=true
|
||||
fi
|
||||
fi
|
||||
echo "BUILD_RELEASE=${BUILD_RELEASE}" >> $GITHUB_OUTPUT
|
||||
echo "BUILD_PRERELEASE=${BUILD_PRERELEASE}" >> $GITHUB_OUTPUT
|
||||
echo "RELEASE_VERSION=${RELVERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v2
|
||||
if: ${{github.event.inputs.useVaultSecrets == 'true'}}
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_CLIENT_SECRET }}
|
||||
tags: tag:ci
|
||||
|
||||
- name: Get the ENV values from Vault
|
||||
id: get_vault_secrets
|
||||
if: ${{github.event.inputs.useVaultSecrets == 'true'}}
|
||||
run: |
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
ENV_NAME="prod"
|
||||
else
|
||||
ENV_NAME="stage"
|
||||
fi
|
||||
|
||||
curl -fsSL \
|
||||
--header "X-Vault-Token: ${{ secrets.VAULT_TOKEN }}" \
|
||||
--request GET \
|
||||
${{ vars.VAULT_HOST }}/v1/kv/git-builds/data/${{ env.VAULT_KP_PREFIX }}-${ENV_NAME} | jq .data.data > vault_secrets.json
|
||||
|
||||
if [ $? != 0 ]; then
|
||||
echo "Failed to get the ENV values from Vault"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VAULT_SECRETS=$(cat vault_secrets.json | base64 -w 0)
|
||||
echo "VAULT_SECRETS=${VAULT_SECRETS}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Prepare Docker Build Args
|
||||
id: prepare_build_args
|
||||
if: ${{github.event.inputs.useVaultSecrets == 'true'}}
|
||||
run: |
|
||||
BUILD_ARGS=""
|
||||
add_build_arg() {
|
||||
if [ -n "$2" ]; then
|
||||
BUILD_ARGS="$BUILD_ARGS $1=$2"
|
||||
fi
|
||||
}
|
||||
add_build_arg "NEXT_PUBLIC_API_BASE_URL" "${{ env.NEXT_PUBLIC_API_BASE_URL }}"
|
||||
add_build_arg "NEXT_PUBLIC_API_BASE_PATH" "${{ env.NEXT_PUBLIC_API_BASE_PATH }}"
|
||||
|
||||
add_build_arg "NEXT_PUBLIC_ADMIN_BASE_URL" "${{ env.NEXT_PUBLIC_ADMIN_BASE_URL }}"
|
||||
add_build_arg "NEXT_PUBLIC_ADMIN_BASE_PATH" "${{ env.NEXT_PUBLIC_ADMIN_BASE_PATH }}"
|
||||
|
||||
add_build_arg "NEXT_PUBLIC_SPACE_BASE_URL" "${{ env.NEXT_PUBLIC_SPACE_BASE_URL }}"
|
||||
add_build_arg "NEXT_PUBLIC_SPACE_BASE_PATH" "${{ env.NEXT_PUBLIC_SPACE_BASE_PATH }}"
|
||||
|
||||
add_build_arg "NEXT_PUBLIC_LIVE_BASE_URL" "${{ env.NEXT_PUBLIC_LIVE_BASE_URL }}"
|
||||
add_build_arg "NEXT_PUBLIC_LIVE_BASE_PATH" "${{ env.NEXT_PUBLIC_LIVE_BASE_PATH }}"
|
||||
|
||||
add_build_arg "NEXT_PUBLIC_SILO_BASE_URL" "${{ env.NEXT_PUBLIC_SILO_BASE_URL }}"
|
||||
add_build_arg "NEXT_PUBLIC_SILO_BASE_PATH" "${{ env.NEXT_PUBLIC_SILO_BASE_PATH }}"
|
||||
|
||||
add_build_arg "NEXT_PUBLIC_WEB_BASE_URL" "${{ env.NEXT_PUBLIC_WEB_BASE_URL }}"
|
||||
|
||||
add_build_arg "SENTRY_AUTH_TOKEN" "${{ secrets.SENTRY_AUTH_TOKEN }}"
|
||||
|
||||
echo "BUILD_ARGS=$BUILD_ARGS" >> $GITHUB_OUTPUT
|
||||
|
||||
branch_build_push_admin:
|
||||
name: Build-Push Admin Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Admin Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }}
|
||||
build-context: .
|
||||
dockerfile-path: ./apps/admin/Dockerfile.admin
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
build-args: ${{ needs.branch_build_setup.outputs.build_args }}
|
||||
|
||||
branch_build_push_web:
|
||||
name: Build-Push Web Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Load Vault Secrets
|
||||
run: |
|
||||
echo ${{ needs.branch_build_setup.outputs.vault_secrets }} | base64 -d > vault_secrets.json
|
||||
jq -r 'to_entries|map("\(.key)=\(.value|tostring)")|.[]' vault_secrets.json >> $GITHUB_ENV
|
||||
|
||||
- name: Web Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }}
|
||||
build-context: .
|
||||
dockerfile-path: ./apps/web/Dockerfile.web
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
build-args: ${{ needs.branch_build_setup.outputs.build_args }}
|
||||
|
||||
branch_build_push_space:
|
||||
name: Build-Push Space Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Load Vault Secrets
|
||||
run: |
|
||||
echo ${{ needs.branch_build_setup.outputs.vault_secrets }} | base64 -d > vault_secrets.json
|
||||
jq -r 'to_entries|map("\(.key)=\(.value|tostring)")|.[]' vault_secrets.json >> $GITHUB_ENV
|
||||
|
||||
- name: Space Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }}
|
||||
build-context: .
|
||||
dockerfile-path: ./apps/space/Dockerfile.space
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
build-args: ${{ needs.branch_build_setup.outputs.build_args }}
|
||||
|
||||
branch_build_push_live:
|
||||
name: Build-Push Live Collaboration Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Live Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }}
|
||||
build-context: .
|
||||
dockerfile-path: ./apps/live/Dockerfile.live
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
|
||||
branch_build_push_silo:
|
||||
name: Build-Push Silo Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Silo Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_silo }}
|
||||
build-context: .
|
||||
dockerfile-path: ./apps/silo/Dockerfile.silo
|
||||
|
||||
branch_build_push_api:
|
||||
name: Build-Push API Server Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Backend Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }}
|
||||
build-context: ./apps/api
|
||||
dockerfile-path: ./apps/api/Dockerfile.api
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
|
||||
branch_build_push_email:
|
||||
name: Build-Push Email Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Email Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_email }}
|
||||
build-context: ./apps/email
|
||||
dockerfile-path: ./apps/email/Dockerfile
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
|
||||
publish_release:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Build Release
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
[
|
||||
branch_build_setup,
|
||||
branch_build_push_admin,
|
||||
branch_build_push_web,
|
||||
branch_build_push_space,
|
||||
branch_build_push_live,
|
||||
branch_build_push_silo,
|
||||
branch_build_push_api,
|
||||
branch_build_push_email,
|
||||
]
|
||||
env:
|
||||
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2.0.8
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
|
||||
with:
|
||||
tag_name: ${{ env.REL_VERSION }}
|
||||
name: ${{ env.REL_VERSION }}
|
||||
draft: false
|
||||
prerelease: ${{ env.IS_PRERELEASE }}
|
||||
generate_release_notes: true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,11 @@ name: "CodeQL"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["preview", "canary", "master"]
|
||||
branches: ["preview", "master"]
|
||||
pull_request:
|
||||
branches: ["preview", "canary", "master"]
|
||||
branches: ["develop", "preview", "master"]
|
||||
schedule:
|
||||
- cron: "53 19 * * 5"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
||||
@@ -49,5 +49,5 @@ jobs:
|
||||
- name: Check Affected format
|
||||
run: pnpm turbo run check:format --affected
|
||||
|
||||
# - name: Build Affected
|
||||
# run: pnpm turbo run build --affected
|
||||
- name: Build Affected
|
||||
run: pnpm turbo run build --affected
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Sync from Community Repo
|
||||
|
||||
on:
|
||||
# schedule:
|
||||
# - cron: "*/30 * * * *" # Runs every 30 minutes
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_branch:
|
||||
description: "Source branch in Community repo"
|
||||
required: true
|
||||
default: "preview"
|
||||
target_branch:
|
||||
description: "Target branch in Enterprise repo"
|
||||
required: true
|
||||
default: "preview"
|
||||
|
||||
jobs:
|
||||
sync-from-community-repo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout enterprise repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set branch names
|
||||
run: |
|
||||
echo "SOURCE_BRANCH=${{ github.event.inputs.source_branch || 'preview' }}" >> $GITHUB_ENV
|
||||
echo "TARGET_BRANCH=${{ github.event.inputs.target_branch || 'preview' }}" >> $GITHUB_ENV
|
||||
echo "SYNC_BRANCH=sync-${{ github.run_id }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Create sync branch
|
||||
run: git checkout -b ${{ env.SYNC_BRANCH }}
|
||||
|
||||
- name: Fetch from community repository
|
||||
run: |
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
git remote add community https://github.com/makeplane/plane.git
|
||||
git reset --hard community/${{ env.SOURCE_BRANCH }}
|
||||
|
||||
- name: Create Pull Request
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_TITLE="Sync changes from community repo"
|
||||
EXISTING_PR=$(gh pr list --base ${{ env.TARGET_BRANCH }} --head ${{ env.SYNC_BRANCH }} --json number --jq '.[0].number')
|
||||
if [ -z "$EXISTING_PR" ]; then
|
||||
pr_url=$(gh pr create --base ${{ env.TARGET_BRANCH }} --head ${{ env.SYNC_BRANCH }} --title "$PR_TITLE" --body "This PR syncs changes from the community repository's ${{ env.SOURCE_BRANCH }} branch.")
|
||||
echo "New Pull Request created: $pr_url"
|
||||
else
|
||||
echo "Pull Request already exists with number: $EXISTING_PR"
|
||||
gh pr edit $EXISTING_PR --title "$PR_TITLE" --body "This PR syncs changes from the community repository's ${{ env.SOURCE_BRANCH }} branch. (Updated)"
|
||||
echo "Existing Pull Request updated"
|
||||
fi
|
||||
@@ -41,16 +41,12 @@ jobs:
|
||||
|
||||
- name: Create PR to Target Branch
|
||||
run: |
|
||||
# Determine target branch based on current branch prefix
|
||||
TARGET_BRANCH="preview"
|
||||
PR_TITLE="${{vars.SYNC_PR_TITLE}}"
|
||||
|
||||
# get all pull requests and check if there is already a PR
|
||||
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number')
|
||||
if [ -n "$PR_EXISTS" ]; then
|
||||
echo "Pull Request already exists: $PR_EXISTS"
|
||||
else
|
||||
echo "Creating new pull request"
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "$PR_TITLE" --body "")
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "${{ vars.SYNC_PR_TITLE }}" --body "")
|
||||
echo "Pull Request created: $PR_URL"
|
||||
fi
|
||||
|
||||
@@ -36,8 +36,8 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
|
||||
-10
@@ -86,7 +86,6 @@ tmp/
|
||||
|
||||
## packages
|
||||
dist
|
||||
.flatfile
|
||||
.temp/
|
||||
deploy/selfhost/plane-app/
|
||||
|
||||
@@ -100,12 +99,3 @@ dev-editor
|
||||
*.rdb.gz
|
||||
|
||||
storybook-static
|
||||
# Monitor
|
||||
monitor/prime.key
|
||||
monitor/prime.key.pub
|
||||
monitor.db
|
||||
|
||||
.cursorrules
|
||||
.cursor
|
||||
claude.md
|
||||
agents.md
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
// plane internal packages
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
// plane internal packages
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { FC, useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
// plane internal packages
|
||||
|
||||
@@ -69,7 +69,7 @@ export const AdminHeader: FC = observer(() => {
|
||||
return breadcrumbItems;
|
||||
};
|
||||
|
||||
const breadcrumbItems = generateBreadcrumbItems(pathName || "");
|
||||
const breadcrumbItems = generateBreadcrumbItems(pathName);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-sidebar-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
import { FC, ReactNode, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { NewUserPopup } from "@/components/new-user-popup";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
// plane admin hooks
|
||||
import { useInstanceFeatureFlags } from "@/plane-admin/hooks/store/use-instance-feature-flag";
|
||||
// local components
|
||||
import { AdminHeader } from "./header";
|
||||
import { AdminSidebar } from "./sidebar";
|
||||
@@ -25,14 +22,6 @@ const AdminLayout: FC<TAdminLayout> = (props) => {
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const { isUserLoggedIn } = useUser();
|
||||
// plane admin hooks
|
||||
const { fetchInstanceFeatureFlags } = useInstanceFeatureFlags();
|
||||
// fetching instance feature flags
|
||||
const { isLoading: flagsLoader, error: flagsError } = useSWR(
|
||||
`INSTANCE_FEATURE_FLAGS`,
|
||||
() => fetchInstanceFeatureFlags(),
|
||||
{ revalidateOnFocus: false, revalidateIfStale: false, errorRetryCount: 1 }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserLoggedIn === false) {
|
||||
@@ -40,7 +29,7 @@ const AdminLayout: FC<TAdminLayout> = (props) => {
|
||||
}
|
||||
}, [router, isUserLoggedIn]);
|
||||
|
||||
if ((flagsLoader && !flagsError) || isUserLoggedIn === undefined) {
|
||||
if (isUserLoggedIn === undefined) {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
|
||||
@@ -7,11 +7,11 @@ import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
// plane internal packages
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { DiscordIcon, GithubIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { DiscordIcon, GithubIcon } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance, useTheme } from "@/hooks/store";
|
||||
import { useTheme } from "@/hooks/store";
|
||||
// assets
|
||||
// eslint-disable-next-line import/order
|
||||
import packageJson from "package.json";
|
||||
@@ -38,7 +38,6 @@ export const AdminSidebarHelpSection: FC = observer(() => {
|
||||
// states
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
// store
|
||||
const { instance } = useInstance();
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
// refs
|
||||
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -131,20 +130,8 @@ export const AdminSidebarHelpSection: FC = observer(() => {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* {instance?.latest_version && instance?.current_version != instance?.latest_version && (
|
||||
<a
|
||||
href="https://docs.plane.so/docs/changelog"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-2 py-1 text-xs font-medium cursor-pointer transition-all border rounded border-custom-primary-100/30 bg-custom-primary-100/20 hover:bg-custom-primary-100/30 text-custom-text-100"
|
||||
referrerPolicy="no-referrer"
|
||||
>
|
||||
<RefreshCcw className="flex-shrink-0 h-3 w-3 text-custom-text-100" />
|
||||
<div>Updates available</div>
|
||||
<div className="flex-shrink-0 ml-auto animate-pulse bg-custom-primary-100 !w-2 !h-2 rounded-full" />
|
||||
</a>
|
||||
)} */}
|
||||
</div>
|
||||
<div className="px-2 pb-1 pt-2 text-[10px]">Version: v{instance?.current_version}</div>
|
||||
<div className="px-2 pb-1 pt-2 text-[10px]">Version: v{packageJson.version}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,8 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { WorkspaceIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { WorkspaceIcon } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
@@ -65,7 +65,7 @@ export const AdminSidebarMenu = observer(() => {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-2.5 overflow-y-scroll vertical-scrollbar scrollbar-sm px-4 py-4">
|
||||
{INSTANCE_ADMIN_LINKS.map((item, index) => {
|
||||
const isActive = item.href === pathName || pathName?.includes(item.href);
|
||||
const isActive = item.href === pathName || pathName.includes(item.href);
|
||||
return (
|
||||
<Link key={index} href={item.href} onClick={handleItemClick}>
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PlaneLockup } from "@plane/propel/icons";
|
||||
import { PlaneLockup } from "@plane/ui";
|
||||
|
||||
export const AuthHeader = () => (
|
||||
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { FC, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
// plane internal packages
|
||||
import { IFormattedInstanceConfiguration, TInstanceOIDCAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { TCopyField, CopyField } from "@/components/common/copy-field";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
config: IFormattedInstanceConfiguration;
|
||||
};
|
||||
|
||||
type OIDCConfigFormValues = Record<TInstanceOIDCAuthenticationConfigurationKeys, string>;
|
||||
|
||||
export const InstanceOIDCConfigForm: FC<Props> = (props) => {
|
||||
const { config } = props;
|
||||
// states
|
||||
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { updateInstanceConfigurations } = useInstance();
|
||||
// form data
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = useForm<OIDCConfigFormValues>({
|
||||
defaultValues: {
|
||||
OIDC_CLIENT_ID: config["OIDC_CLIENT_ID"],
|
||||
OIDC_CLIENT_SECRET: config["OIDC_CLIENT_SECRET"],
|
||||
OIDC_TOKEN_URL: config["OIDC_TOKEN_URL"],
|
||||
OIDC_USERINFO_URL: config["OIDC_USERINFO_URL"],
|
||||
OIDC_AUTHORIZE_URL: config["OIDC_AUTHORIZE_URL"],
|
||||
OIDC_LOGOUT_URL: config["OIDC_LOGOUT_URL"],
|
||||
OIDC_PROVIDER_NAME: config["OIDC_PROVIDER_NAME"],
|
||||
},
|
||||
});
|
||||
|
||||
const originURL = typeof window !== "undefined" ? window.location.origin : "";
|
||||
|
||||
const OIDC_FORM_FIELDS: TControllerInputFormField[] = [
|
||||
{
|
||||
key: "OIDC_CLIENT_ID",
|
||||
type: "text",
|
||||
label: "Client ID",
|
||||
description: "A unique ID for this Plane app that you register on your IdP",
|
||||
placeholder: "abc123xyz789",
|
||||
error: Boolean(errors.OIDC_CLIENT_ID),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "OIDC_CLIENT_SECRET",
|
||||
type: "password",
|
||||
label: "Client secret",
|
||||
description: "The secret key that authenticates this Plane app to your IdP",
|
||||
placeholder: "s3cr3tK3y123!",
|
||||
error: Boolean(errors.OIDC_CLIENT_SECRET),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "OIDC_AUTHORIZE_URL",
|
||||
type: "text",
|
||||
label: "Authorize URL",
|
||||
description: (
|
||||
<>
|
||||
The URL that brings up your IdP{"'"}s authentication screen when your users click the{" "}
|
||||
<CodeBlock>{"Continue with"}</CodeBlock>
|
||||
</>
|
||||
),
|
||||
placeholder: "https://example.com/",
|
||||
error: Boolean(errors.OIDC_AUTHORIZE_URL),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "OIDC_TOKEN_URL",
|
||||
type: "text",
|
||||
label: "Token URL",
|
||||
description: "The URL that talks to the IdP and persists user authentication on Plane",
|
||||
placeholder: "https://example.com/oauth/token",
|
||||
error: Boolean(errors.OIDC_TOKEN_URL),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "OIDC_USERINFO_URL",
|
||||
type: "text",
|
||||
label: "Users' info URL",
|
||||
description: "The URL that fetches your users' info from your IdP",
|
||||
placeholder: "https://example.com/userinfo",
|
||||
error: Boolean(errors.OIDC_USERINFO_URL),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "OIDC_LOGOUT_URL",
|
||||
type: "text",
|
||||
label: "Logout URL",
|
||||
description: "Optional field that controls where your users go after they log out of Plane",
|
||||
placeholder: "https://example.com/logout",
|
||||
error: Boolean(errors.OIDC_LOGOUT_URL),
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "OIDC_PROVIDER_NAME",
|
||||
type: "text",
|
||||
label: "IdP's name",
|
||||
description: (
|
||||
<>
|
||||
Optional field for the name that your users see on the <CodeBlock>Continue with</CodeBlock> button
|
||||
</>
|
||||
),
|
||||
placeholder: "Okta",
|
||||
error: Boolean(errors.OIDC_PROVIDER_NAME),
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
const OIDC_SERVICE_DETAILS: TCopyField[] = [
|
||||
{
|
||||
key: "Origin_URI",
|
||||
label: "Origin URI",
|
||||
url: `${originURL}/auth/oidc/`,
|
||||
description:
|
||||
"We will generate this for this Plane app. Add this as a trusted origin on your IdP's corresponding field.",
|
||||
},
|
||||
{
|
||||
key: "Callback_URI",
|
||||
label: "Callback URI",
|
||||
url: `${originURL}/auth/oidc/callback/`,
|
||||
description: (
|
||||
<>
|
||||
We will generate this for you.Add this in the <CodeBlock darkerShade>Sign-in redirect URI</CodeBlock> field of
|
||||
your IdP.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "Logout_URI",
|
||||
label: "Logout URI",
|
||||
url: `${originURL}/auth/oidc/logout/`,
|
||||
description: (
|
||||
<>
|
||||
We will generate this for you. Add this in the <CodeBlock darkerShade>Logout redirect URI</CodeBlock> field of
|
||||
your IdP.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = async (formData: OIDCConfigFormValues) => {
|
||||
const payload: Partial<OIDCConfigFormValues> = { ...formData };
|
||||
|
||||
await updateInstanceConfigurations(payload)
|
||||
.then((response = []) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your OIDC-based authentication is configured. You should test it now.",
|
||||
});
|
||||
reset({
|
||||
OIDC_CLIENT_ID: response.find((item) => item.key === "OIDC_CLIENT_ID")?.value,
|
||||
OIDC_CLIENT_SECRET: response.find((item) => item.key === "OIDC_CLIENT_SECRET")?.value,
|
||||
OIDC_AUTHORIZE_URL: response.find((item) => item.key === "OIDC_AUTHORIZE_URL")?.value,
|
||||
OIDC_TOKEN_URL: response.find((item) => item.key === "OIDC_TOKEN_URL")?.value,
|
||||
OIDC_USERINFO_URL: response.find((item) => item.key === "OIDC_USERINFO_URL")?.value,
|
||||
OIDC_LOGOUT_URL: response.find((item) => item.key === "OIDC_LOGOUT_URL")?.value,
|
||||
OIDC_PROVIDER_NAME: response.find((item) => item.key === "OIDC_PROVIDER_NAME")?.value,
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
setIsDiscardChangesModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDiscardModal
|
||||
isOpen={isDiscardChangesModalOpen}
|
||||
onDiscardHref="/authentication"
|
||||
handleClose={() => setIsDiscardChangesModalOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
|
||||
<div className="pt-2.5 text-xl font-medium">IdP-provided details for Plane</div>
|
||||
{OIDC_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
key={field.key}
|
||||
control={control}
|
||||
type={field.type}
|
||||
name={field.key}
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
placeholder={field.placeholder}
|
||||
error={field.error}
|
||||
required={field.required}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-col gap-1 pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
|
||||
{isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
<Link
|
||||
href="/authentication"
|
||||
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
Go back
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-custom-background-80/60 rounded-lg">
|
||||
<div className="pt-2 text-xl font-medium">Plane-provided details for your IdP</div>
|
||||
{OIDC_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
import { PageHeader } from "@/components/common/page-header";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// icons
|
||||
import OIDCLogo from "/public/logos/oidc-logo.svg";
|
||||
// plane admin hooks
|
||||
import { useInstanceFlag } from "@/plane-admin/hooks/store/use-instance-flag";
|
||||
// local components
|
||||
import { InstanceOIDCConfigForm } from "./form";
|
||||
|
||||
const InstanceOIDCAuthenticationPage = observer(() => {
|
||||
// state
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
// plane admin store
|
||||
const isOIDCEnabled = useInstanceFlag("OIDC_SAML_AUTH");
|
||||
// config
|
||||
const enableOIDCConfig = formattedConfig?.IS_OIDC_ENABLED ?? "";
|
||||
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
const updateConfig = async (key: "IS_OIDC_ENABLED", value: string) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const payload = {
|
||||
[key]: value,
|
||||
};
|
||||
|
||||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration...",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `OIDC authentication is now ${value ? "active" : "disabled"}.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
},
|
||||
});
|
||||
|
||||
await updateConfigPromise
|
||||
.then(() => {
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (isOIDCEnabled === false) {
|
||||
return (
|
||||
<div className="relative container mx-auto w-full h-full p-4 py-4 my-6 space-y-6 flex flex-col">
|
||||
<PageHeader title="Authentication - God Mode" />
|
||||
<div className="text-center text-lg text-gray-500">
|
||||
<p>OpenID Connect (OIDC) authentication is not enabled for this instance.</p>
|
||||
<p>Activate any of your workspace to get this feature.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Authentication - God Mode" />
|
||||
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
|
||||
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<AuthenticationMethodCard
|
||||
name="OIDC"
|
||||
description="Authenticate your users via the OpenID connect protocol."
|
||||
icon={<Image src={OIDCLogo} height={24} width={24} alt="OIDC Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableOIDCConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableOIDCConfig)) === true
|
||||
? updateConfig("IS_OIDC_ENABLED", "0")
|
||||
: updateConfig("IS_OIDC_ENABLED", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
/>
|
||||
}
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
withBorder={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceOIDCConfigForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-8">
|
||||
<Loader.Item height="50px" width="25%" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" width="50%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default InstanceOIDCAuthenticationPage;
|
||||
@@ -1,234 +0,0 @@
|
||||
import { FC, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane internal packages
|
||||
import { IFormattedInstanceConfiguration, TInstanceSAMLAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, TextArea, getButtonStyling, setToast } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { CopyField, TCopyField } from "@/components/common/copy-field";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
import { SAMLAttributeMappingTable } from "@/plane-admin/components/authentication";
|
||||
|
||||
type Props = {
|
||||
config: IFormattedInstanceConfiguration;
|
||||
};
|
||||
|
||||
type SAMLConfigFormValues = Record<TInstanceSAMLAuthenticationConfigurationKeys, string>;
|
||||
|
||||
export const InstanceSAMLConfigForm: FC<Props> = (props) => {
|
||||
const { config } = props;
|
||||
// states
|
||||
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { updateInstanceConfigurations } = useInstance();
|
||||
// form data
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = useForm<SAMLConfigFormValues>({
|
||||
defaultValues: {
|
||||
SAML_ENTITY_ID: config["SAML_ENTITY_ID"],
|
||||
SAML_SSO_URL: config["SAML_SSO_URL"],
|
||||
SAML_LOGOUT_URL: config["SAML_LOGOUT_URL"],
|
||||
SAML_CERTIFICATE: config["SAML_CERTIFICATE"],
|
||||
SAML_PROVIDER_NAME: config["SAML_PROVIDER_NAME"],
|
||||
},
|
||||
});
|
||||
|
||||
const originURL = typeof window !== "undefined" ? window.location.origin : "";
|
||||
|
||||
const SAML_FORM_FIELDS: TControllerInputFormField[] = [
|
||||
{
|
||||
key: "SAML_ENTITY_ID",
|
||||
type: "text",
|
||||
label: "Entity ID",
|
||||
description: "A unique ID for this Plane app that you register on your IdP",
|
||||
placeholder: "70a44354520df8bd9bcd",
|
||||
error: Boolean(errors.SAML_ENTITY_ID),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "SAML_SSO_URL",
|
||||
type: "text",
|
||||
label: "SSO URL",
|
||||
description: (
|
||||
<>
|
||||
The URL that brings up your IdP{"'"}s authentication screen when your users click the{" "}
|
||||
<CodeBlock>{"Continue with"}</CodeBlock> button
|
||||
</>
|
||||
),
|
||||
placeholder: "https://example.com/sso",
|
||||
error: Boolean(errors.SAML_SSO_URL),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "SAML_LOGOUT_URL",
|
||||
type: "text",
|
||||
label: "Logout URL",
|
||||
description: "Optional field that tells your IdP your users have logged out of this Plane app",
|
||||
placeholder: "https://example.com/logout",
|
||||
error: Boolean(errors.SAML_LOGOUT_URL),
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "SAML_PROVIDER_NAME",
|
||||
type: "text",
|
||||
label: "IdP's name",
|
||||
description: (
|
||||
<>
|
||||
Optional field for the name that your users see on the <CodeBlock>Continue with</CodeBlock> button
|
||||
</>
|
||||
),
|
||||
placeholder: "Okta",
|
||||
error: Boolean(errors.SAML_PROVIDER_NAME),
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
const SAML_SERVICE_DETAILS: TCopyField[] = [
|
||||
{
|
||||
key: "Metadata_Information",
|
||||
label: "Entity ID | Audience | Metadata information",
|
||||
url: `${originURL}/auth/saml/metadata/`,
|
||||
description:
|
||||
"We will generate this bit of the metadata that identifies this Plane app as an authorized service on your IdP.",
|
||||
},
|
||||
{
|
||||
key: "Callback_URI",
|
||||
label: "Callback URI",
|
||||
url: `${originURL}/auth/saml/callback/`,
|
||||
description: (
|
||||
<>
|
||||
We will generate this <CodeBlock darkerShade>http-post request</CodeBlock> URL that you should paste into your{" "}
|
||||
<CodeBlock darkerShade>ACS URL</CodeBlock> or <CodeBlock darkerShade>Sign-in call back URL</CodeBlock> field
|
||||
on your IdP.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "Logout_URI",
|
||||
label: "Logout URI",
|
||||
url: `${originURL}/auth/saml/logout/`,
|
||||
description: (
|
||||
<>
|
||||
We will generate this <CodeBlock darkerShade>http-redirect request</CodeBlock> URL that you should paste into
|
||||
your <CodeBlock darkerShade>SLS URL</CodeBlock> or <CodeBlock darkerShade>Logout URL</CodeBlock>
|
||||
field on your IdP.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = async (formData: SAMLConfigFormValues) => {
|
||||
const payload: Partial<SAMLConfigFormValues> = { ...formData };
|
||||
|
||||
await updateInstanceConfigurations(payload)
|
||||
.then((response = []) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your SAML-based authentication is configured. You should test it now.",
|
||||
});
|
||||
reset({
|
||||
SAML_ENTITY_ID: response.find((item) => item.key === "SAML_ENTITY_ID")?.value,
|
||||
SAML_SSO_URL: response.find((item) => item.key === "SAML_SSO_URL")?.value,
|
||||
SAML_LOGOUT_URL: response.find((item) => item.key === "SAML_LOGOUT_URL")?.value,
|
||||
SAML_CERTIFICATE: response.find((item) => item.key === "SAML_CERTIFICATE")?.value,
|
||||
SAML_PROVIDER_NAME: response.find((item) => item.key === "SAML_PROVIDER_NAME")?.value,
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
setIsDiscardChangesModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDiscardModal
|
||||
isOpen={isDiscardChangesModalOpen}
|
||||
onDiscardHref="/authentication"
|
||||
handleClose={() => setIsDiscardChangesModalOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
|
||||
<div className="pt-2.5 text-xl font-medium">IdP-provided details for Plane</div>
|
||||
{SAML_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
key={field.key}
|
||||
control={control}
|
||||
type={field.type}
|
||||
name={field.key}
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
placeholder={field.placeholder}
|
||||
error={field.error}
|
||||
required={field.required}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">SAML certificate</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="SAML_CERTIFICATE"
|
||||
rules={{ required: "Certificate is required." }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TextArea
|
||||
id="SAML_CERTIFICATE"
|
||||
name="SAML_CERTIFICATE"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.SAML_CERTIFICATE)}
|
||||
placeholder="---BEGIN CERTIFICATE---\n2yWn1gc7DhOFB9\nr0gbE+\n---END CERTIFICATE---"
|
||||
className="min-h-[102px] w-full rounded-md font-medium text-sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="pt-0.5 text-xs text-custom-text-300">
|
||||
IdP-generated certificate for signing this Plane app as an authorized service provider for your IdP
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
|
||||
{isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
<Link
|
||||
href="/authentication"
|
||||
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
Go back
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-custom-background-80/60 rounded-lg">
|
||||
<div className="pt-2 text-xl font-medium">Plane-provided details for your IdP</div>
|
||||
{SAML_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm text-custom-text-200 font-medium">Mapping</h4>
|
||||
<SAMLAttributeMappingTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
import { PageHeader } from "@/components/common/page-header";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// icons
|
||||
import SAMLLogo from "/public/logos/saml-logo.svg";
|
||||
// plane admin hooks
|
||||
import { useInstanceFlag } from "@/plane-admin/hooks/store/use-instance-flag";
|
||||
// local components
|
||||
import { InstanceSAMLConfigForm } from "./form";
|
||||
|
||||
const InstanceSAMLAuthenticationPage = observer(() => {
|
||||
// state
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
// plane admin store
|
||||
const isSAMLEnabled = useInstanceFlag("OIDC_SAML_AUTH");
|
||||
// config
|
||||
const enableSAMLConfig = formattedConfig?.IS_SAML_ENABLED ?? "";
|
||||
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
const updateConfig = async (key: "IS_SAML_ENABLED", value: string) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const payload = {
|
||||
[key]: value,
|
||||
};
|
||||
|
||||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration...",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `SAML authentication is now ${value ? "active" : "disabled"}.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
},
|
||||
});
|
||||
|
||||
await updateConfigPromise
|
||||
.then(() => {
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (isSAMLEnabled === false) {
|
||||
return (
|
||||
<div className="relative container mx-auto w-full h-full p-4 py-4 my-6 space-y-6 flex flex-col">
|
||||
<PageHeader title="Authentication - God Mode" />
|
||||
<div className="text-center text-lg text-gray-500">
|
||||
<p>Security Assertion Markup Language (SAML) authentication is not enabled for this instance.</p>
|
||||
<p>Activate any of your workspace to get this feature.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Authentication - God Mode" />
|
||||
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
|
||||
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<AuthenticationMethodCard
|
||||
name="SAML"
|
||||
description="Authenticate your users via Security Assertion Markup Language
|
||||
protocol."
|
||||
icon={<Image src={SAMLLogo} height={24} width={24} alt="SAML Logo" className="pl-0.5" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableSAMLConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableSAMLConfig)) === true
|
||||
? updateConfig("IS_SAML_ENABLED", "0")
|
||||
: updateConfig("IS_SAML_ENABLED", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
/>
|
||||
}
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
withBorder={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceSAMLConfigForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-8">
|
||||
<Loader.Item height="50px" width="25%" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" width="50%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default InstanceSAMLAuthenticationPage;
|
||||
@@ -56,13 +56,13 @@ export const InstanceSetupForm: FC = (props) => {
|
||||
const {} = props;
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const firstNameParam = searchParams?.get("first_name") || undefined;
|
||||
const lastNameParam = searchParams?.get("last_name") || undefined;
|
||||
const companyParam = searchParams?.get("company") || undefined;
|
||||
const emailParam = searchParams?.get("email") || undefined;
|
||||
const isTelemetryEnabledParam = (searchParams?.get("is_telemetry_enabled") === "True" ? true : false) || true;
|
||||
const errorCode = searchParams?.get("error_code") || undefined;
|
||||
const errorMessage = searchParams?.get("error_message") || undefined;
|
||||
const firstNameParam = searchParams.get("first_name") || undefined;
|
||||
const lastNameParam = searchParams.get("last_name") || undefined;
|
||||
const companyParam = searchParams.get("company") || undefined;
|
||||
const emailParam = searchParams.get("email") || undefined;
|
||||
const isTelemetryEnabledParam = (searchParams.get("is_telemetry_enabled") === "True" ? true : false) || true;
|
||||
const errorCode = searchParams.get("error_code") || undefined;
|
||||
const errorMessage = searchParams.get("error_message") || undefined;
|
||||
// state
|
||||
const [showPassword, setShowPassword] = useState({
|
||||
password: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { set } from "lodash-es";
|
||||
import set from "lodash/set";
|
||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||
// plane internal packages
|
||||
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { set } from "lodash-es";
|
||||
import set from "lodash/set";
|
||||
import { action, observable, runInAction, makeObservable, computed } from "mobx";
|
||||
// plane imports
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
|
||||
@@ -1,135 +1 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Mails, KeyRound } from "lucide-react";
|
||||
import {
|
||||
TInstanceAuthenticationMethodKeys as TBaseAuthenticationMethods,
|
||||
TInstanceAuthenticationModes,
|
||||
TInstanceEnterpriseAuthenticationMethodKeys,
|
||||
} from "@plane/types";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// plane ce components
|
||||
import { getAuthenticationModes as getCEAuthenticationModes } from "@/ce/components/authentication/authentication-modes";
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
|
||||
import { GithubConfiguration } from "@/components/authentication/github-config";
|
||||
import { GitlabConfiguration } from "@/components/authentication/gitlab-config";
|
||||
import { GoogleConfiguration } from "@/components/authentication/google-config";
|
||||
import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch";
|
||||
// plane admin components
|
||||
import { OIDCConfiguration, SAMLConfiguration } from "@/plane-admin/components/authentication";
|
||||
// images
|
||||
import { useInstanceFlag } from "@/plane-admin/hooks/store/use-instance-flag";
|
||||
import githubLightModeImage from "@/public/logos/github-black.png";
|
||||
import githubDarkModeImage from "@/public/logos/github-white.png";
|
||||
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
|
||||
import GoogleLogo from "@/public/logos/google-logo.svg";
|
||||
import OIDCLogo from "@/public/logos/oidc-logo.svg";
|
||||
import SAMLLogo from "@/public/logos/saml-logo.svg";
|
||||
|
||||
type TInstanceAuthenticationMethodKeys = TBaseAuthenticationMethods | TInstanceEnterpriseAuthenticationMethodKeys;
|
||||
|
||||
export type TAuthenticationModeProps = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export type TGetAuthenticationModeProps = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
resolvedTheme: string | undefined;
|
||||
};
|
||||
|
||||
// Enterprise authentication methods
|
||||
export const getAuthenticationModes: (props: TGetAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
|
||||
disabled,
|
||||
updateConfig,
|
||||
resolvedTheme,
|
||||
}) => [
|
||||
{
|
||||
key: "unique-codes",
|
||||
name: "Unique codes",
|
||||
description:
|
||||
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
|
||||
icon: <Mails className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
|
||||
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "passwords-login",
|
||||
name: "Passwords",
|
||||
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
|
||||
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "google",
|
||||
name: "Google",
|
||||
description: "Allow members to log in or sign up for Plane with their Google accounts.",
|
||||
icon: <Image src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
|
||||
icon: (
|
||||
<Image
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
),
|
||||
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
|
||||
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "oidc",
|
||||
name: "OIDC",
|
||||
description: "Authenticate your users via the OpenID Connect protocol.",
|
||||
icon: <Image src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
|
||||
config: <OIDCConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "saml",
|
||||
name: "SAML",
|
||||
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
|
||||
icon: <Image src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
|
||||
config: <SAMLConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
];
|
||||
|
||||
export const AuthenticationModes: React.FC<TAuthenticationModeProps> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
// plane admin hooks
|
||||
const isOIDCSAMLEnabled = useInstanceFlag("OIDC_SAML_AUTH");
|
||||
|
||||
const authenticationModes = isOIDCSAMLEnabled
|
||||
? getAuthenticationModes({ disabled, updateConfig, resolvedTheme })
|
||||
: getCEAuthenticationModes({ disabled, updateConfig, resolvedTheme });
|
||||
|
||||
return (
|
||||
<>
|
||||
{authenticationModes.map((method) => (
|
||||
<AuthenticationMethodCard
|
||||
key={method.key}
|
||||
name={method.name}
|
||||
description={method.description}
|
||||
icon={method.icon}
|
||||
config={method.config}
|
||||
disabled={disabled}
|
||||
unavailable={method.unavailable}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
export * from "ce/components/authentication/authentication-modes";
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
export * from "./authentication-modes";
|
||||
export * from "./oidc-config";
|
||||
export * from "./saml-config";
|
||||
export * from "./saml-attribute-mapping-table";
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { TInstanceEnterpriseAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceEnterpriseAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const OIDCConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableOIDCConfig = formattedConfig?.IS_OIDC_ENABLED ?? "";
|
||||
const isOIDCConfigured = !!formattedConfig?.OIDC_CLIENT_ID && !!formattedConfig?.OIDC_CLIENT_SECRET;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOIDCConfigured ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/authentication/oidc" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
|
||||
Edit
|
||||
</Link>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableOIDCConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableOIDCConfig)) === true
|
||||
? updateConfig("IS_OIDC_ENABLED", "0")
|
||||
: updateConfig("IS_OIDC_ENABLED", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href="/authentication/oidc"
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
export const SAMLAttributeMappingTable = () => (
|
||||
<table className="table-auto border-collapse text-custom-text-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th className="border-b border-r border-custom-border-300 px-4 py-1.5">IdP</th>
|
||||
<th className="border-b border-custom-border-300 px-4 py-1.5">Plane</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border-t border-r border-custom-border-300 px-4 py-1.5">Name ID format</td>
|
||||
<td className="border-t border-custom-border-300 px-4 py-1.5">emailAddress</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-t border-r border-custom-border-300 px-4 py-1.5">first_name</td>
|
||||
<td className="border-t border-custom-border-300 px-4 py-1.5">user.firstName</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-t border-r border-custom-border-300 px-4 py-1.5">last_name</td>
|
||||
<td className="border-t border-custom-border-300 px-4 py-1.5">user.lastName</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-t border-r border-custom-border-300 px-4 py-1.5">email</td>
|
||||
<td className="border-t border-custom-border-300 px-4 py-1.5">user.email</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { TInstanceEnterpriseAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceEnterpriseAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const SAMLConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableSAMLConfig = formattedConfig?.IS_SAML_ENABLED ?? "";
|
||||
const isSAMLConfigured = !!formattedConfig?.SAML_ENTITY_ID && !!formattedConfig?.SAML_CERTIFICATE;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSAMLConfigured ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/authentication/saml" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
|
||||
Edit
|
||||
</Link>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableSAMLConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableSAMLConfig)) === true
|
||||
? updateConfig("IS_SAML_ENABLED", "0")
|
||||
: updateConfig("IS_SAML_ENABLED", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href="/authentication/saml"
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export * from "./upgrade-button";
|
||||
export * from "ce/components/common";
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
// plane internal packages
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { AlertModalCore, Button } from "@plane/ui";
|
||||
|
||||
export const UpgradeButton: React.FC = () => {
|
||||
// states
|
||||
const [isActivationModalOpen, setIsActivationModalOpen] = React.useState(false);
|
||||
// derived values
|
||||
const redirectionLink = encodeURI(WEB_BASE_URL + "/");
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertModalCore
|
||||
variant="primary"
|
||||
isOpen={isActivationModalOpen}
|
||||
handleClose={() => setIsActivationModalOpen(false)}
|
||||
handleSubmit={() => {
|
||||
window.open(redirectionLink, "_blank");
|
||||
setIsActivationModalOpen(false);
|
||||
}}
|
||||
isSubmitting={false}
|
||||
title="Activate workspace"
|
||||
content="Activate any of your workspace to get this feature."
|
||||
primaryButtonText={{
|
||||
loading: "Redirecting...",
|
||||
default: "Go to Plane",
|
||||
}}
|
||||
secondaryButtonText="Close"
|
||||
/>
|
||||
<Button variant="primary" size="sm" onClick={() => setIsActivationModalOpen(true)}>
|
||||
Activate workspace
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useContext } from "react";
|
||||
// context
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
// plane admin stores
|
||||
import { IInstanceFeatureFlagsStore } from "@/plane-admin/store/instance-feature-flags.store";
|
||||
|
||||
export const useInstanceFeatureFlags = (): IInstanceFeatureFlagsStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useInstanceFeatureFlags must be used within StoreProvider");
|
||||
return context.instanceFeatureFlags;
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useContext } from "react";
|
||||
// context
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
|
||||
export enum E_FEATURE_FLAGS {
|
||||
OIDC_SAML_AUTH = "OIDC_SAML_AUTH",
|
||||
}
|
||||
|
||||
export const useInstanceFlag = (flag: keyof typeof E_FEATURE_FLAGS, defaultValue: boolean = false): boolean => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useInstanceFlag must be used within StoreProvider");
|
||||
return context.instanceFeatureFlags.flags?.[E_FEATURE_FLAGS[flag]] ?? defaultValue;
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
// plane imports
|
||||
import { InstanceFeatureFlagService } from "@plane/services";
|
||||
import { TInstanceFeatureFlagsResponse } from "@plane/types";
|
||||
|
||||
const instanceFeatureFlagService = new InstanceFeatureFlagService();
|
||||
|
||||
type TFeatureFlagsMaps = Record<string, boolean>; // feature flag -> boolean
|
||||
|
||||
export interface IInstanceFeatureFlagsStore {
|
||||
flags: TFeatureFlagsMaps;
|
||||
// actions
|
||||
hydrate: (data: any) => void;
|
||||
fetchInstanceFeatureFlags: () => Promise<TInstanceFeatureFlagsResponse>;
|
||||
}
|
||||
|
||||
export class InstanceFeatureFlagsStore implements IInstanceFeatureFlagsStore {
|
||||
flags: TFeatureFlagsMaps = {};
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
flags: observable,
|
||||
fetchInstanceFeatureFlags: action,
|
||||
});
|
||||
}
|
||||
|
||||
hydrate = (data: any) => {
|
||||
if (data) this.flags = data;
|
||||
};
|
||||
|
||||
fetchInstanceFeatureFlags = async () => {
|
||||
try {
|
||||
const response = await instanceFeatureFlagService.list();
|
||||
runInAction(() => {
|
||||
if (response) {
|
||||
Object.keys(response).forEach((key) => {
|
||||
set(this.flags, key, response[key]);
|
||||
});
|
||||
}
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error fetching instance feature flags", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,29 +1 @@
|
||||
import { enableStaticRendering } from "mobx-react";
|
||||
// stores
|
||||
import {
|
||||
IInstanceFeatureFlagsStore,
|
||||
InstanceFeatureFlagsStore,
|
||||
} from "@/plane-admin/store/instance-feature-flags.store";
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
// plane admin store
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
export class RootStore extends CoreRootStore {
|
||||
instanceFeatureFlags: IInstanceFeatureFlagsStore;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.instanceFeatureFlags = new InstanceFeatureFlagsStore();
|
||||
}
|
||||
|
||||
hydrate(initialData: any) {
|
||||
super.hydrate(initialData);
|
||||
this.instanceFeatureFlags.hydrate(initialData.instanceFeatureFlags);
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
super.resetOnSignOut();
|
||||
this.instanceFeatureFlags = new InstanceFeatureFlagsStore();
|
||||
}
|
||||
}
|
||||
export * from "ce/store/root.store";
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
|
||||
"check:lint": "eslint . --max-warnings 20",
|
||||
"check:lint": "eslint . --max-warnings 19",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"fix:lint": "eslint . --fix",
|
||||
@@ -27,7 +27,7 @@
|
||||
"@plane/utils": "workspace:*",
|
||||
"autoprefixer": "10.4.14",
|
||||
"axios": "catalog:",
|
||||
"lodash-es": "catalog:",
|
||||
"lodash": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
@@ -45,7 +45,7 @@
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
"@/app/*": ["app/*"],
|
||||
"@/*": ["core/*"],
|
||||
"@/public/*": ["public/*"],
|
||||
"@/plane-admin/*": ["ee/*"],
|
||||
"@/styles/*": ["styles/*"],
|
||||
"@/ce/*": ["ce/*"]
|
||||
"@/plane-admin/*": ["ce/*"],
|
||||
"@/styles/*": ["styles/*"]
|
||||
},
|
||||
"strictNullChecks": true
|
||||
},
|
||||
|
||||
@@ -39,6 +39,8 @@ DOCKERIZED=1 # deprecated
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
USE_MINIO=0
|
||||
|
||||
|
||||
|
||||
# Email redirections and minio domain settings
|
||||
WEB_URL="http://localhost:8000"
|
||||
|
||||
@@ -58,11 +60,7 @@ APP_BASE_PATH=""
|
||||
LIVE_BASE_URL="http://localhost:3100"
|
||||
LIVE_BASE_PATH="/live"
|
||||
|
||||
SILO_BASE_URL="http://localhost:3200"
|
||||
SILO_BASE_PATH="/silo"
|
||||
|
||||
LIVE_SERVER_SECRET_KEY="secret-key"
|
||||
AES_SECRET_KEY="plane-testing-key"
|
||||
|
||||
# Hard delete files after days
|
||||
HARD_DELETE_AFTER_DAYS=60
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
dump.rdb
|
||||
@@ -4,7 +4,7 @@ FROM python:3.12.10-alpine
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/f366114e9aba4cbfbe4265b8f2b74f65/
|
||||
ENV INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
|
||||
|
||||
# Update system packages for security
|
||||
RUN apk update && apk upgrade
|
||||
@@ -31,7 +31,6 @@ RUN apk add --no-cache --virtual .build-deps \
|
||||
"postgresql-dev" \
|
||||
"libc-dev" \
|
||||
"linux-headers" \
|
||||
"xmlsec-dev"\
|
||||
&& \
|
||||
pip install -r requirements.txt --compile --no-cache-dir \
|
||||
&& \
|
||||
|
||||
+19
-36
@@ -1,30 +1,18 @@
|
||||
FROM python:3.12.10-alpine
|
||||
FROM python:3.12.5-alpine AS backend
|
||||
|
||||
# set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
|
||||
|
||||
ENV INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/f366114e9aba4cbfbe4265b8f2b74f65
|
||||
|
||||
|
||||
# Update system packages for security
|
||||
RUN apk update && apk upgrade
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
|
||||
RUN apk add --no-cache --upgrade \
|
||||
RUN apk --no-cache add \
|
||||
"bash~=5.2" \
|
||||
"libpq" \
|
||||
"libxslt" \
|
||||
"nodejs-current" \
|
||||
"xmlsec" \
|
||||
"ca-certificates" \
|
||||
"openssl"
|
||||
|
||||
COPY requirements.txt ./
|
||||
COPY requirements ./requirements
|
||||
RUN apk add --no-cache libffi-dev
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
"libffi-dev" \
|
||||
"bash~=5.2" \
|
||||
"g++" \
|
||||
"gcc" \
|
||||
@@ -33,23 +21,18 @@ RUN apk add --no-cache --virtual .build-deps \
|
||||
"make" \
|
||||
"postgresql-dev" \
|
||||
"libc-dev" \
|
||||
"linux-headers" \
|
||||
"xmlsec-dev"\
|
||||
&& \
|
||||
pip install -r requirements/local.txt --compile --no-cache-dir \
|
||||
&& \
|
||||
apk del .build-deps \
|
||||
&& \
|
||||
rm -rf /var/cache/apk/*
|
||||
"linux-headers"
|
||||
|
||||
# Add in Django deps and generate Django's static files
|
||||
COPY manage.py manage.py
|
||||
COPY plane plane/
|
||||
COPY templates templates/
|
||||
COPY package.json package.json
|
||||
WORKDIR /code
|
||||
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY ./bin ./bin/
|
||||
COPY requirements.txt ./requirements.txt
|
||||
ADD requirements ./requirements
|
||||
|
||||
# Install the local development settings
|
||||
RUN pip install -r requirements/local.txt --compile --no-cache-dir
|
||||
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /code/plane/logs
|
||||
RUN chmod -R +x /code/bin
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# API SERVER
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
export SKIP_ENV_VAR=0
|
||||
|
||||
python manage.py wait_for_db
|
||||
# Wait for migrations
|
||||
python manage.py wait_for_migrations
|
||||
|
||||
# Clear Cache before starting to remove stale values
|
||||
python manage.py clear_cache
|
||||
|
||||
# Register instance if INSTANCE_ADMIN_EMAIL is set
|
||||
if [ -n "$INSTANCE_ADMIN_EMAIL" ]; then
|
||||
python manage.py setup_instance $INSTANCE_ADMIN_EMAIL
|
||||
fi
|
||||
|
||||
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 -
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
python manage.py wait_for_db
|
||||
# Wait for migrations
|
||||
python manage.py wait_for_migrations
|
||||
|
||||
# Create the default bucket
|
||||
#!/bin/bash
|
||||
|
||||
# Collect system information
|
||||
HOSTNAME=$(hostname)
|
||||
MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1)
|
||||
CPU_INFO=$(cat /proc/cpuinfo)
|
||||
MEMORY_INFO=$(free -h)
|
||||
DISK_INFO=$(df -h)
|
||||
|
||||
# Concatenate information and compute SHA-256 hash
|
||||
SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}')
|
||||
|
||||
# Export the variables
|
||||
MACHINE_SIGNATURE=${MACHINE_SIGNATURE:-$SIGNATURE}
|
||||
export SKIP_ENV_VAR=1
|
||||
|
||||
# Register instance
|
||||
python manage.py register_instance_ee "$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
|
||||
|
||||
# Fetch latest information from monitor and update all licenses
|
||||
python manage.py update_licenses
|
||||
|
||||
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 -
|
||||
@@ -32,4 +32,4 @@ python manage.py create_bucket
|
||||
# Clear Cache before starting to remove stale values
|
||||
python manage.py clear_cache
|
||||
|
||||
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
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 -
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
python manage.py wait_for_db
|
||||
# Wait for migrations
|
||||
python manage.py wait_for_migrations
|
||||
|
||||
|
||||
# Run the processes
|
||||
python manage.py run_automation_consumer \
|
||||
--queue ${AUTOMATION_EVENT_STREAM_QUEUE_NAME:-plane.event_stream.automations} \
|
||||
--prefetch ${AUTOMATION_EVENT_STREAM_PREFETCH:-10}
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
python manage.py wait_for_db
|
||||
# Wait for migrations
|
||||
python manage.py wait_for_migrations
|
||||
|
||||
|
||||
# Run the processes
|
||||
python manage.py outbox_poller \
|
||||
--memory-limit ${OUTBOX_POLLER_MEMORY_LIMIT_MB:-512} \
|
||||
--interval-min ${OUTBOX_POLLER_INTERVAL_MIN:-0.25} \
|
||||
--interval-max ${OUTBOX_POLLER_INTERVAL_MAX:-10} \
|
||||
--batch-size ${OUTBOX_POLLER_BATCH_SIZE:-250} \
|
||||
--memory-check-interval ${OUTBOX_POLLER_MEMORY_CHECK_INTERVAL:-30}
|
||||
@@ -9,4 +9,4 @@ class ApiConfig(AppConfig):
|
||||
try:
|
||||
import plane.utils.openapi.auth # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
pass
|
||||
@@ -11,7 +11,6 @@ from .issue import (
|
||||
LabelCreateUpdateSerializer,
|
||||
LabelSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueDetailSerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueActivitySerializer,
|
||||
@@ -22,11 +21,6 @@ from .issue import (
|
||||
IssueCommentCreateSerializer,
|
||||
IssueLinkCreateSerializer,
|
||||
IssueLinkUpdateSerializer,
|
||||
IssueRelationSerializer,
|
||||
IssueRelationCreateSerializer,
|
||||
IssueRelationRemoveSerializer,
|
||||
IssueRelationResponseSerializer,
|
||||
RelatedIssueSerializer,
|
||||
)
|
||||
from .state import StateLiteSerializer, StateSerializer
|
||||
from .cycle import (
|
||||
@@ -52,7 +46,6 @@ from .intake import (
|
||||
IntakeIssueUpdateSerializer,
|
||||
)
|
||||
from .estimate import EstimatePointSerializer
|
||||
from .issue_type import IssueTypeAPISerializer, ProjectIssueTypeAPISerializer
|
||||
from .asset import (
|
||||
UserAssetUploadSerializer,
|
||||
AssetUpdateSerializer,
|
||||
|
||||
@@ -46,7 +46,9 @@ class AssetUpdateSerializer(serializers.Serializer):
|
||||
and upload confirmation for S3-based file storage workflows.
|
||||
"""
|
||||
|
||||
attributes = serializers.JSONField(required=False, help_text="Additional attributes to update for the asset")
|
||||
attributes = serializers.JSONField(
|
||||
required=False, help_text="Additional attributes to update for the asset"
|
||||
)
|
||||
|
||||
|
||||
class GenericAssetUploadSerializer(serializers.Serializer):
|
||||
@@ -83,7 +85,9 @@ class GenericAssetUpdateSerializer(serializers.Serializer):
|
||||
upload completion marking and metadata finalization.
|
||||
"""
|
||||
|
||||
is_uploaded = serializers.BooleanField(default=True, help_text="Whether the asset has been successfully uploaded")
|
||||
is_uploaded = serializers.BooleanField(
|
||||
default=True, help_text="Whether the asset has been successfully uploaded"
|
||||
)
|
||||
|
||||
|
||||
class FileAssetSerializer(BaseSerializer):
|
||||
|
||||
@@ -29,8 +29,7 @@ class BaseSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Adjust the serializer's fields based on the provided 'fields' list.
|
||||
|
||||
:param fields: List or dictionary specifying which
|
||||
fields to include in the serializer.
|
||||
:param fields: List or dictionary specifying which fields to include in the serializer.
|
||||
:return: The updated fields for the serializer.
|
||||
"""
|
||||
# Check each field_name in the provided fields.
|
||||
@@ -103,9 +102,13 @@ class BaseSerializer(serializers.ModelSerializer):
|
||||
# Check if field in expansion then expand the field
|
||||
if expand in expansion:
|
||||
if isinstance(response.get(expand), list):
|
||||
exp_serializer = expansion[expand](getattr(instance, expand), many=True)
|
||||
exp_serializer = expansion[expand](
|
||||
getattr(instance, expand), many=True
|
||||
)
|
||||
else:
|
||||
exp_serializer = expansion[expand](getattr(instance, expand))
|
||||
exp_serializer = expansion[expand](
|
||||
getattr(instance, expand)
|
||||
)
|
||||
response[expand] = exp_serializer.data
|
||||
else:
|
||||
# You might need to handle this case differently
|
||||
|
||||
@@ -55,9 +55,14 @@ class CycleCreateSerializer(BaseSerializer):
|
||||
):
|
||||
raise serializers.ValidationError("Start date cannot exceed end date")
|
||||
|
||||
if data.get("start_date", None) is not None and data.get("end_date", None) is not None:
|
||||
if (
|
||||
data.get("start_date", None) is not None
|
||||
and data.get("end_date", None) is not None
|
||||
):
|
||||
project_id = self.initial_data.get("project_id") or (
|
||||
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
|
||||
self.instance.project_id
|
||||
if self.instance and hasattr(self.instance, "project_id")
|
||||
else None
|
||||
)
|
||||
|
||||
if not project_id:
|
||||
@@ -161,7 +166,9 @@ class CycleIssueRequestSerializer(serializers.Serializer):
|
||||
cycle assignment and sprint planning workflows.
|
||||
"""
|
||||
|
||||
issues = serializers.ListField(child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle")
|
||||
issues = serializers.ListField(
|
||||
child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle"
|
||||
)
|
||||
|
||||
|
||||
class TransferCycleIssueRequestSerializer(serializers.Serializer):
|
||||
@@ -172,4 +179,6 @@ class TransferCycleIssueRequestSerializer(serializers.Serializer):
|
||||
and relationship updates for sprint reallocation workflows.
|
||||
"""
|
||||
|
||||
new_cycle_id = serializers.UUIDField(help_text="ID of the target cycle to transfer issues to")
|
||||
new_cycle_id = serializers.UUIDField(
|
||||
help_text="ID of the target cycle to transfer issues to"
|
||||
)
|
||||
|
||||
@@ -98,7 +98,9 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
|
||||
and embedded issue updates for issue queue processing workflows.
|
||||
"""
|
||||
|
||||
issue = IssueForIntakeSerializer(required=False, help_text="Issue data to update in the intake issue")
|
||||
issue = IssueForIntakeSerializer(
|
||||
required=False, help_text="Issue data to update in the intake issue"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IntakeIssue
|
||||
@@ -130,5 +132,9 @@ class IssueDataSerializer(serializers.Serializer):
|
||||
"""
|
||||
|
||||
name = serializers.CharField(max_length=255, help_text="Issue name")
|
||||
description_html = serializers.CharField(required=False, allow_null=True, help_text="Issue description HTML")
|
||||
priority = serializers.ChoiceField(choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority")
|
||||
description_html = serializers.CharField(
|
||||
required=False, allow_null=True, help_text="Issue description HTML"
|
||||
)
|
||||
priority = serializers.ChoiceField(
|
||||
choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority"
|
||||
)
|
||||
|
||||
@@ -12,7 +12,6 @@ from plane.db.models import (
|
||||
IssueType,
|
||||
IssueActivity,
|
||||
IssueAssignee,
|
||||
IssueRelation,
|
||||
FileAsset,
|
||||
IssueComment,
|
||||
IssueLabel,
|
||||
@@ -33,7 +32,6 @@ from .cycle import CycleLiteSerializer, CycleSerializer
|
||||
from .module import ModuleLiteSerializer, ModuleSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .issue_type import IssueTypeAPISerializer
|
||||
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -45,18 +43,21 @@ class IssueSerializer(BaseSerializer):
|
||||
Comprehensive work item serializer with full relationship management.
|
||||
|
||||
Handles complete work item lifecycle including assignees, labels, validation,
|
||||
and related model updates. Supports dynamic field expansion and HTML content
|
||||
processing.
|
||||
and related model updates. Supports dynamic field expansion and HTML content processing.
|
||||
"""
|
||||
|
||||
assignees = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.values_list("id", flat=True)),
|
||||
child=serializers.PrimaryKeyRelatedField(
|
||||
queryset=User.objects.values_list("id", flat=True)
|
||||
),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
labels = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.values_list("id", flat=True)),
|
||||
child=serializers.PrimaryKeyRelatedField(
|
||||
queryset=Label.objects.values_list("id", flat=True)
|
||||
),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
@@ -67,7 +68,7 @@ class IssueSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Issue
|
||||
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"]
|
||||
exclude = ["description"]
|
||||
exclude = ["description", "description_stripped"]
|
||||
|
||||
def validate(self, data):
|
||||
if (
|
||||
@@ -88,9 +89,13 @@ class IssueSerializer(BaseSerializer):
|
||||
|
||||
# Validate description content for security
|
||||
if data.get("description_html"):
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
data["description_html"]
|
||||
)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"error": "html content is not valid"})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
@@ -98,7 +103,9 @@ class IssueSerializer(BaseSerializer):
|
||||
if data.get("description_binary"):
|
||||
is_valid, error_msg = validate_binary_data(data["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
|
||||
raise serializers.ValidationError(
|
||||
{"description_binary": "Invalid binary data"}
|
||||
)
|
||||
|
||||
# Validate assignees are from project
|
||||
if data.get("assignees", []):
|
||||
@@ -118,9 +125,13 @@ class IssueSerializer(BaseSerializer):
|
||||
# Check state is from the project only else raise validation error
|
||||
if (
|
||||
data.get("state")
|
||||
and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state").id).exists()
|
||||
and not State.objects.filter(
|
||||
project_id=self.context.get("project_id"), pk=data.get("state").id
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError("State is not valid please pass a valid state_id")
|
||||
raise serializers.ValidationError(
|
||||
"State is not valid please pass a valid state_id"
|
||||
)
|
||||
|
||||
# Check parent issue is from workspace as it can be cross workspace
|
||||
if (
|
||||
@@ -131,7 +142,9 @@ class IssueSerializer(BaseSerializer):
|
||||
pk=data.get("parent").id,
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id")
|
||||
raise serializers.ValidationError(
|
||||
"Parent is not valid issue_id please pass a valid issue_id"
|
||||
)
|
||||
|
||||
if (
|
||||
data.get("estimate_point")
|
||||
@@ -141,7 +154,9 @@ class IssueSerializer(BaseSerializer):
|
||||
pk=data.get("estimate_point").id,
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id")
|
||||
raise serializers.ValidationError(
|
||||
"Estimate point is not valid please pass a valid estimate_point_id"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@@ -157,10 +172,14 @@ class IssueSerializer(BaseSerializer):
|
||||
|
||||
if not issue_type:
|
||||
# Get default issue type
|
||||
issue_type = IssueType.objects.filter(project_issue_types__project_id=project_id, is_default=True).first()
|
||||
issue_type = IssueType.objects.filter(
|
||||
project_issue_types__project_id=project_id, is_default=True
|
||||
).first()
|
||||
issue_type = issue_type
|
||||
|
||||
issue = Issue.objects.create(**validated_data, project_id=project_id, type=issue_type)
|
||||
issue = Issue.objects.create(
|
||||
**validated_data, project_id=project_id, type=issue_type
|
||||
)
|
||||
|
||||
# Issue Audit Users
|
||||
created_by_id = issue.created_by_id
|
||||
@@ -239,22 +258,7 @@ class IssueSerializer(BaseSerializer):
|
||||
updated_by_id = instance.updated_by_id
|
||||
|
||||
if assignees is not None:
|
||||
# Get the current assignees
|
||||
current_assignees = IssueAssignee.objects.filter(
|
||||
issue=instance
|
||||
).values_list("assignee_id", flat=True)
|
||||
|
||||
# Get the assignees to add
|
||||
assignees_to_add = list(set(assignees) - set(current_assignees))
|
||||
|
||||
# Get the assignees to remove
|
||||
assignees_to_remove = list(set(current_assignees) - set(assignees))
|
||||
|
||||
# Delete the assignees to remove
|
||||
IssueAssignee.objects.filter(
|
||||
issue=instance, assignee_id__in=assignees_to_remove
|
||||
).delete()
|
||||
|
||||
IssueAssignee.objects.filter(issue=instance).delete()
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
@@ -266,7 +270,7 @@ class IssueSerializer(BaseSerializer):
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees_to_add
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
@@ -275,23 +279,7 @@ class IssueSerializer(BaseSerializer):
|
||||
pass
|
||||
|
||||
if labels is not None:
|
||||
# Get the current labels
|
||||
current_labels = IssueLabel.objects.filter(issue=instance).values_list(
|
||||
"label_id", flat=True
|
||||
)
|
||||
|
||||
# Get the labels to add
|
||||
labels_to_add = list(set(labels) - set(current_labels))
|
||||
|
||||
# Get the labels to remove
|
||||
labels_to_remove = list(set(current_labels) - set(labels))
|
||||
|
||||
# Delete the labels to remove
|
||||
IssueLabel.objects.filter(
|
||||
issue=instance, label_id__in=labels_to_remove
|
||||
).delete()
|
||||
|
||||
# Create the labels to add
|
||||
IssueLabel.objects.filter(issue=instance).delete()
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
@@ -303,7 +291,7 @@ class IssueSerializer(BaseSerializer):
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels_to_add
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
@@ -323,32 +311,37 @@ class IssueSerializer(BaseSerializer):
|
||||
|
||||
data["assignees"] = UserLiteSerializer(
|
||||
User.objects.filter(
|
||||
pk__in=IssueAssignee.objects.filter(issue=instance).values_list("assignee_id", flat=True)
|
||||
pk__in=IssueAssignee.objects.filter(issue=instance).values_list(
|
||||
"assignee_id", flat=True
|
||||
)
|
||||
),
|
||||
many=True,
|
||||
).data
|
||||
else:
|
||||
data["assignees"] = [
|
||||
str(assignee)
|
||||
for assignee in IssueAssignee.objects.filter(issue=instance).values_list("assignee_id", flat=True)
|
||||
for assignee in IssueAssignee.objects.filter(
|
||||
issue=instance
|
||||
).values_list("assignee_id", flat=True)
|
||||
]
|
||||
if "labels" in self.fields:
|
||||
if "labels" in self.expand:
|
||||
data["labels"] = LabelSerializer(
|
||||
Label.objects.filter(
|
||||
pk__in=IssueLabel.objects.filter(issue=instance).values_list("label_id", flat=True)
|
||||
pk__in=IssueLabel.objects.filter(issue=instance).values_list(
|
||||
"label_id", flat=True
|
||||
)
|
||||
),
|
||||
many=True,
|
||||
).data
|
||||
else:
|
||||
data["labels"] = [
|
||||
str(label) for label in IssueLabel.objects.filter(issue=instance).values_list("label_id", flat=True)
|
||||
str(label)
|
||||
for label in IssueLabel.objects.filter(issue=instance).values_list(
|
||||
"label_id", flat=True
|
||||
)
|
||||
]
|
||||
|
||||
if "type" in self.fields:
|
||||
if "type" in self.expand:
|
||||
data["type"] = IssueTypeAPISerializer(instance.type).data
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -458,8 +451,12 @@ class IssueLinkCreateSerializer(BaseSerializer):
|
||||
|
||||
# Validation if url already exists
|
||||
def create(self, validated_data):
|
||||
if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists():
|
||||
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
|
||||
if IssueLink.objects.filter(
|
||||
url=validated_data.get("url"), issue_id=validated_data.get("issue_id")
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
@@ -480,11 +477,15 @@ class IssueLinkUpdateSerializer(IssueLinkCreateSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if (
|
||||
IssueLink.objects.filter(url=validated_data.get("url"), issue_id=instance.issue_id)
|
||||
IssueLink.objects.filter(
|
||||
url=validated_data.get("url"), issue_id=instance.issue_id
|
||||
)
|
||||
.exclude(pk=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
@@ -588,7 +589,7 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
exclude = ["comment_json"]
|
||||
exclude = ["comment_stripped", "comment_json"]
|
||||
|
||||
def validate(self, data):
|
||||
try:
|
||||
@@ -675,13 +676,17 @@ class IssueExpandSerializer(BaseSerializer):
|
||||
expand = self.context.get("expand", [])
|
||||
if "labels" in expand:
|
||||
# Use prefetched data
|
||||
return LabelLiteSerializer([il.label for il in obj.label_issue.all()], many=True).data
|
||||
return LabelLiteSerializer(
|
||||
[il.label for il in obj.label_issue.all()], many=True
|
||||
).data
|
||||
return [il.label_id for il in obj.label_issue.all()]
|
||||
|
||||
def get_assignees(self, obj):
|
||||
expand = self.context.get("expand", [])
|
||||
if "assignees" in expand:
|
||||
return UserLiteSerializer([ia.assignee for ia in obj.issue_assignee.all()], many=True).data
|
||||
return UserLiteSerializer(
|
||||
[ia.assignee for ia in obj.issue_assignee.all()], many=True
|
||||
).data
|
||||
return [ia.assignee_id for ia in obj.issue_assignee.all()]
|
||||
|
||||
class Meta:
|
||||
@@ -718,9 +723,9 @@ class IssueAttachmentUploadSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class IssueSearchItemSerializer(serializers.Serializer):
|
||||
class IssueSearchSerializer(serializers.Serializer):
|
||||
"""
|
||||
Individual issue component for search results.
|
||||
Serializer for work item search result data formatting.
|
||||
|
||||
Provides standardized search result structure including work item identifiers,
|
||||
project context, and workspace information for search API responses.
|
||||
@@ -729,228 +734,8 @@ class IssueSearchItemSerializer(serializers.Serializer):
|
||||
id = serializers.CharField(required=True, help_text="Issue ID")
|
||||
name = serializers.CharField(required=True, help_text="Issue name")
|
||||
sequence_id = serializers.CharField(required=True, help_text="Issue sequence ID")
|
||||
project__identifier = serializers.CharField(required=True, help_text="Project identifier")
|
||||
project__identifier = serializers.CharField(
|
||||
required=True, help_text="Project identifier"
|
||||
)
|
||||
project_id = serializers.CharField(required=True, help_text="Project ID")
|
||||
workspace__slug = serializers.CharField(required=True, help_text="Workspace slug")
|
||||
|
||||
|
||||
class IssueSearchSerializer(serializers.Serializer):
|
||||
"""
|
||||
Search results for work items.
|
||||
|
||||
Provides list of issues with their identifiers, names, and project context.
|
||||
"""
|
||||
|
||||
issues = IssueSearchItemSerializer(many=True)
|
||||
|
||||
|
||||
class IssueRelationResponseSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for issue relations response showing grouped relation types.
|
||||
|
||||
Returns issue IDs organized by relation type for efficient client-side processing.
|
||||
"""
|
||||
|
||||
blocking = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
help_text="List of issue IDs that are blocking this issue",
|
||||
)
|
||||
blocked_by = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
help_text="List of issue IDs that this issue is blocked by",
|
||||
)
|
||||
duplicate = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
help_text="List of issue IDs that are duplicates of this issue",
|
||||
)
|
||||
relates_to = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
help_text="List of issue IDs that relate to this issue",
|
||||
)
|
||||
start_after = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
help_text="List of issue IDs that start after this issue",
|
||||
)
|
||||
start_before = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
help_text="List of issue IDs that start before this issue",
|
||||
)
|
||||
finish_after = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
help_text="List of issue IDs that finish after this issue",
|
||||
)
|
||||
finish_before = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
help_text="List of issue IDs that finish before this issue",
|
||||
)
|
||||
|
||||
|
||||
class IssueRelationCreateSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for creating issue relations.
|
||||
|
||||
Creates issue relations with the specified relation type and issues.
|
||||
Validates relation types and ensures proper issue ID format.
|
||||
"""
|
||||
|
||||
RELATION_TYPE_CHOICES = [
|
||||
("blocking", "Blocking"),
|
||||
("blocked_by", "Blocked By"),
|
||||
("duplicate", "Duplicate"),
|
||||
("relates_to", "Relates To"),
|
||||
("start_before", "Start Before"),
|
||||
("start_after", "Start After"),
|
||||
("finish_before", "Finish Before"),
|
||||
("finish_after", "Finish After"),
|
||||
]
|
||||
|
||||
relation_type = serializers.ChoiceField(
|
||||
choices=RELATION_TYPE_CHOICES,
|
||||
required=True,
|
||||
help_text="Type of relationship between work items",
|
||||
)
|
||||
issues = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=True,
|
||||
min_length=1,
|
||||
help_text="Array of work item IDs to create relations with",
|
||||
)
|
||||
|
||||
def validate_issues(self, value):
|
||||
"""Validate that issues list is not empty and contains valid UUIDs."""
|
||||
if not value:
|
||||
raise serializers.ValidationError("At least one issue ID is required.")
|
||||
return value
|
||||
|
||||
|
||||
class IssueRelationRemoveSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for removing issue relations.
|
||||
|
||||
Removes existing relationships between work items by specifying
|
||||
the related issue ID.
|
||||
"""
|
||||
|
||||
related_issue = serializers.UUIDField(
|
||||
required=True, help_text="ID of the related work item to remove relation with"
|
||||
)
|
||||
|
||||
|
||||
class IssueRelationSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for issue relationships showing related issue details.
|
||||
|
||||
Provides comprehensive information about related issues including
|
||||
project context, sequence ID, and relationship type.
|
||||
"""
|
||||
|
||||
id = serializers.UUIDField(source="related_issue.id", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(
|
||||
source="related_issue.project_id", read_only=True
|
||||
)
|
||||
sequence_id = serializers.IntegerField(
|
||||
source="related_issue.sequence_id", read_only=True
|
||||
)
|
||||
name = serializers.CharField(source="related_issue.name", read_only=True)
|
||||
type_id = serializers.UUIDField(source="related_issue.type.id", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
is_epic = serializers.BooleanField(
|
||||
source="related_issue.type.is_epic", read_only=True
|
||||
)
|
||||
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="related_issue.priority", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"type_id",
|
||||
"is_epic",
|
||||
"state_id",
|
||||
"priority",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class RelatedIssueSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for reverse issue relationships showing issue details.
|
||||
|
||||
Provides comprehensive information about the source issue in a relationship
|
||||
including project context, sequence ID, and relationship type.
|
||||
"""
|
||||
|
||||
id = serializers.UUIDField(source="issue.id", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(
|
||||
source="issue.project_id", read_only=True
|
||||
)
|
||||
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
|
||||
name = serializers.CharField(source="issue.name", read_only=True)
|
||||
type_id = serializers.UUIDField(source="issue.type.id", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
is_epic = serializers.BooleanField(source="issue.type.is_epic", read_only=True)
|
||||
state_id = serializers.UUIDField(source="issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="issue.priority", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"type_id",
|
||||
"is_epic",
|
||||
"state_id",
|
||||
"priority",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IssueDetailSerializer(IssueSerializer):
|
||||
"""
|
||||
Comprehensive work item serializer with full relationship management.
|
||||
|
||||
Handles complete work item lifecycle including assignees, labels, validation,
|
||||
and related model updates. Supports dynamic field expansion and HTML content processing.
|
||||
"""
|
||||
|
||||
assignees = UserLiteSerializer(many=True)
|
||||
|
||||
labels = LabelSerializer(many=True)
|
||||
|
||||
type_id = serializers.PrimaryKeyRelatedField(
|
||||
source="type", queryset=IssueType.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"]
|
||||
exclude = ["description"]
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from plane.ee.serializers import BaseSerializer
|
||||
from plane.db.models import IssueType, ProjectIssueType
|
||||
|
||||
|
||||
class IssueTypeAPISerializer(BaseSerializer):
|
||||
project_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||
|
||||
class Meta:
|
||||
model = IssueType
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"logo_props",
|
||||
"is_default",
|
||||
"level",
|
||||
"deleted_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
|
||||
|
||||
class ProjectIssueTypeAPISerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = ProjectIssueType
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"level",
|
||||
"is_default",
|
||||
"deleted_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
@@ -17,9 +17,8 @@ class ModuleCreateSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for creating modules with member validation and date checking.
|
||||
|
||||
Handles module creation including member assignment validation, date range
|
||||
verification, and duplicate name prevention for feature-based
|
||||
project organization setup.
|
||||
Handles module creation including member assignment validation, date range verification,
|
||||
and duplicate name prevention for feature-based project organization setup.
|
||||
"""
|
||||
|
||||
members = serializers.ListField(
|
||||
@@ -76,15 +75,9 @@ class ModuleCreateSerializer(BaseSerializer):
|
||||
module_name = validated_data.get("name")
|
||||
if module_name:
|
||||
# Lookup for the module name in the module table for that project
|
||||
module = Module.objects.filter(name=module_name, project_id=project_id).first()
|
||||
if module:
|
||||
if Module.objects.filter(name=module_name, project_id=project_id).exists():
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"id": str(module.id),
|
||||
"code": "MODULE_NAME_ALREADY_EXISTS",
|
||||
"error": "Module with this name already exists",
|
||||
"message": "Module with this name already exists",
|
||||
}
|
||||
{"error": "Module with this name already exists"}
|
||||
)
|
||||
|
||||
module = Module.objects.create(**validated_data, project_id=project_id)
|
||||
@@ -112,9 +105,8 @@ class ModuleUpdateSerializer(ModuleCreateSerializer):
|
||||
"""
|
||||
Serializer for updating modules with enhanced validation and member management.
|
||||
|
||||
Extends module creation with update-specific validations including
|
||||
member reassignment, name conflict checking,
|
||||
and relationship management for module modifications.
|
||||
Extends module creation with update-specific validations including member reassignment,
|
||||
name conflict checking, and relationship management for module modifications.
|
||||
"""
|
||||
|
||||
class Meta(ModuleCreateSerializer.Meta):
|
||||
@@ -129,8 +121,14 @@ class ModuleUpdateSerializer(ModuleCreateSerializer):
|
||||
module_name = validated_data.get("name")
|
||||
if module_name:
|
||||
# Lookup for the module name in the module table for that project
|
||||
if Module.objects.filter(name=module_name, project=instance.project).exclude(id=instance.id).exists():
|
||||
raise serializers.ValidationError({"error": "Module with this name already exists"})
|
||||
if (
|
||||
Module.objects.filter(name=module_name, project=instance.project)
|
||||
.exclude(id=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Module with this name already exists"}
|
||||
)
|
||||
|
||||
if members is not None:
|
||||
ModuleMember.objects.filter(module=instance).delete()
|
||||
@@ -157,8 +155,8 @@ class ModuleSerializer(BaseSerializer):
|
||||
"""
|
||||
Comprehensive module serializer with work item metrics and member management.
|
||||
|
||||
Provides complete module data including work item counts by status, member
|
||||
relationships, and progress tracking for feature-based project organization.
|
||||
Provides complete module data including work item counts by status, member relationships,
|
||||
and progress tracking for feature-based project organization.
|
||||
"""
|
||||
|
||||
members = serializers.ListField(
|
||||
@@ -240,8 +238,12 @@ class ModuleLinkSerializer(BaseSerializer):
|
||||
|
||||
# Validation if url already exists
|
||||
def create(self, validated_data):
|
||||
if ModuleLink.objects.filter(url=validated_data.get("url"), module_id=validated_data.get("module_id")).exists():
|
||||
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
|
||||
if ModuleLink.objects.filter(
|
||||
url=validated_data.get("url"), module_id=validated_data.get("module_id")
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
return ModuleLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ class ProjectCreateSerializer(BaseSerializer):
|
||||
workspace_id=self.context["workspace_id"],
|
||||
member_id=data.get("project_lead"),
|
||||
).exists():
|
||||
raise serializers.ValidationError("Project lead should be a user in the workspace")
|
||||
raise serializers.ValidationError(
|
||||
"Project lead should be a user in the workspace"
|
||||
)
|
||||
|
||||
if data.get("default_assignee", None) is not None:
|
||||
# Check if the default assignee is a member of the workspace
|
||||
@@ -74,7 +76,9 @@ class ProjectCreateSerializer(BaseSerializer):
|
||||
workspace_id=self.context["workspace_id"],
|
||||
member_id=data.get("default_assignee"),
|
||||
).exists():
|
||||
raise serializers.ValidationError("Default assignee should be a user in the workspace")
|
||||
raise serializers.ValidationError(
|
||||
"Default assignee should be a user in the workspace"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@@ -83,10 +87,14 @@ class ProjectCreateSerializer(BaseSerializer):
|
||||
if identifier == "":
|
||||
raise serializers.ValidationError(detail="Project Identifier is required")
|
||||
|
||||
if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists():
|
||||
if ProjectIdentifier.objects.filter(
|
||||
name=identifier, workspace_id=self.context["workspace_id"]
|
||||
).exists():
|
||||
raise serializers.ValidationError(detail="Project Identifier is taken")
|
||||
|
||||
project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"])
|
||||
project = Project.objects.create(
|
||||
**validated_data, workspace_id=self.context["workspace_id"]
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@@ -111,17 +119,25 @@ class ProjectUpdateSerializer(ProjectCreateSerializer):
|
||||
"""Update a project"""
|
||||
if (
|
||||
validated_data.get("default_state", None) is not None
|
||||
and not State.objects.filter(project=instance, id=validated_data.get("default_state")).exists()
|
||||
and not State.objects.filter(
|
||||
project=instance, id=validated_data.get("default_state")
|
||||
).exists()
|
||||
):
|
||||
# Check if the default state is a state in the project
|
||||
raise serializers.ValidationError("Default state should be a state in the project")
|
||||
raise serializers.ValidationError(
|
||||
"Default state should be a state in the project"
|
||||
)
|
||||
|
||||
if (
|
||||
validated_data.get("estimate", None) is not None
|
||||
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate")).exists()
|
||||
and not Estimate.objects.filter(
|
||||
project=instance, id=validated_data.get("estimate")
|
||||
).exists()
|
||||
):
|
||||
# Check if the estimate is a estimate in the project
|
||||
raise serializers.ValidationError("Estimate should be a estimate in the project")
|
||||
raise serializers.ValidationError(
|
||||
"Estimate should be a estimate in the project"
|
||||
)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
@@ -166,7 +182,9 @@ class ProjectSerializer(BaseSerializer):
|
||||
member_id=data.get("project_lead"),
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError("Project lead should be a user in the workspace")
|
||||
raise serializers.ValidationError(
|
||||
"Project lead should be a user in the workspace"
|
||||
)
|
||||
|
||||
# Check default assignee should be a member of the workspace
|
||||
if (
|
||||
@@ -176,17 +194,23 @@ class ProjectSerializer(BaseSerializer):
|
||||
member_id=data.get("default_assignee"),
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError("Default assignee should be a user in the workspace")
|
||||
raise serializers.ValidationError(
|
||||
"Default assignee should be a user in the workspace"
|
||||
)
|
||||
|
||||
# Validate description content for security
|
||||
if "description_html" in data and data["description_html"]:
|
||||
if isinstance(data["description_html"], dict):
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(str(data["description_html"]))
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
str(data["description_html"])
|
||||
)
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"error": "html content is not valid"})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@@ -195,10 +219,14 @@ class ProjectSerializer(BaseSerializer):
|
||||
if identifier == "":
|
||||
raise serializers.ValidationError(detail="Project Identifier is required")
|
||||
|
||||
if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists():
|
||||
if ProjectIdentifier.objects.filter(
|
||||
name=identifier, workspace_id=self.context["workspace_id"]
|
||||
).exists():
|
||||
raise serializers.ValidationError(detail="Project Identifier is taken")
|
||||
|
||||
project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"])
|
||||
project = Project.objects.create(
|
||||
**validated_data, workspace_id=self.context["workspace_id"]
|
||||
)
|
||||
_ = ProjectIdentifier.objects.create(
|
||||
name=project.identifier,
|
||||
project=project,
|
||||
|
||||
@@ -14,12 +14,14 @@ class StateSerializer(BaseSerializer):
|
||||
def validate(self, data):
|
||||
# If the default is being provided then make all other states default False
|
||||
if data.get("default", False):
|
||||
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
|
||||
State.objects.filter(project_id=self.context.get("project_id")).update(
|
||||
default=False
|
||||
)
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = State
|
||||
exclude = ["slug"]
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_by",
|
||||
@@ -29,6 +31,7 @@ class StateSerializer(BaseSerializer):
|
||||
"workspace",
|
||||
"project",
|
||||
"deleted_at",
|
||||
"slug",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -5,13 +5,8 @@ from .cycle import urlpatterns as cycle_patterns
|
||||
from .module import urlpatterns as module_patterns
|
||||
from .intake import urlpatterns as intake_patterns
|
||||
from .member import urlpatterns as member_patterns
|
||||
from .user import urlpatterns as user_patterns
|
||||
from .asset import urlpatterns as asset_patterns
|
||||
from .customer import urlpatterns as customer_patterns
|
||||
from .issue_type import urlpatterns as issue_type_patterns
|
||||
|
||||
# ee imports
|
||||
from plane.ee.urls.api import urlpatterns as ee_api_urls
|
||||
from .user import urlpatterns as user_patterns
|
||||
|
||||
urlpatterns = [
|
||||
*asset_patterns,
|
||||
@@ -20,11 +15,7 @@ urlpatterns = [
|
||||
*issue_patterns,
|
||||
*cycle_patterns,
|
||||
*module_patterns,
|
||||
*customer_patterns,
|
||||
*user_patterns,
|
||||
*intake_patterns,
|
||||
*member_patterns,
|
||||
*issue_type_patterns,
|
||||
*asset_patterns,
|
||||
*ee_api_urls,
|
||||
*user_patterns,
|
||||
]
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
CustomerAPIEndpoint,
|
||||
CustomerDetailAPIEndpoint,
|
||||
CustomerRequestAPIEndpoint,
|
||||
CustomerRequestDetailAPIEndpoint,
|
||||
CustomerIssuesAPIEndpoint,
|
||||
CustomerIssueDetailAPIEndpoint,
|
||||
CustomerPropertiesAPIEndpoint,
|
||||
CustomerPropertyDetailAPIEndpoint,
|
||||
CustomerPropertyValuesAPIEndpoint,
|
||||
CustomerPropertyValueDetailAPIEndpoint,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/customers/",
|
||||
CustomerAPIEndpoint.as_view(),
|
||||
name="customer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/customers/<uuid:pk>/",
|
||||
CustomerDetailAPIEndpoint.as_view(),
|
||||
name="customer-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/customers/<uuid:customer_id>/requests/",
|
||||
CustomerRequestAPIEndpoint.as_view(),
|
||||
name="customer-requests",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/customers/<uuid:customer_id>/requests/<uuid:pk>/",
|
||||
CustomerRequestDetailAPIEndpoint.as_view(),
|
||||
name="customer-requests-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/customers/<uuid:customer_id>/issues/",
|
||||
CustomerIssuesAPIEndpoint.as_view(),
|
||||
name="customer-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/customers/<uuid:customer_id>/issues/<uuid:issue_id>/",
|
||||
CustomerIssueDetailAPIEndpoint.as_view(),
|
||||
name="customer-issues-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/customer-properties/",
|
||||
CustomerPropertiesAPIEndpoint.as_view(),
|
||||
name="customer-properties",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/customer-properties/<uuid:pk>/",
|
||||
CustomerPropertyDetailAPIEndpoint.as_view(),
|
||||
name="customer-properties-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/customers/<uuid:customer_id>/property-values/",
|
||||
CustomerPropertyValuesAPIEndpoint.as_view(),
|
||||
name="customer-property-values",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/customers/<uuid:customer_id>/property-values/<uuid:property_id>/",
|
||||
CustomerPropertyValueDetailAPIEndpoint.as_view(),
|
||||
name="customer-property-values-detail",
|
||||
),
|
||||
]
|
||||
@@ -14,7 +14,9 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/<uuid:issue_id>/",
|
||||
IntakeIssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
IntakeIssueDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="intake-issue",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -13,10 +13,7 @@ from plane.api.views import (
|
||||
IssueActivityDetailAPIEndpoint,
|
||||
IssueAttachmentListCreateAPIEndpoint,
|
||||
IssueAttachmentDetailAPIEndpoint,
|
||||
IssueRelationListCreateAPIEndpoint,
|
||||
IssueRelationRemoveAPIEndpoint,
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueAttachmentServerEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
)
|
||||
|
||||
@@ -58,7 +55,9 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
|
||||
IssueLinkDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
IssueLinkDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="link",
|
||||
),
|
||||
path(
|
||||
@@ -68,7 +67,9 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
|
||||
IssueCommentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
IssueCommentDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="comment",
|
||||
),
|
||||
path(
|
||||
@@ -88,27 +89,9 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
|
||||
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
IssueAttachmentDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="issue-attachment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/server/",
|
||||
IssueAttachmentServerEndpoint.as_view(),
|
||||
name="attachment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/server/",
|
||||
IssueAttachmentServerEndpoint.as_view(),
|
||||
name="attachment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/relations/",
|
||||
IssueRelationListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="relation",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/relations/remove/",
|
||||
IssueRelationRemoveAPIEndpoint.as_view(http_method_names=["post"]),
|
||||
name="relation",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import IssueTypeListCreateAPIEndpoint, IssueTypeDetailAPIEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
# ======================== issue types start ========================
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-types/",
|
||||
IssueTypeListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="external-issue-type",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-types/<uuid:type_id>/",
|
||||
IssueTypeDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="external-issue-type-detail",
|
||||
),
|
||||
# ======================== issue types end ========================
|
||||
]
|
||||
@@ -5,7 +5,7 @@ from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
|
||||
ProjectMemberAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="project-members",
|
||||
),
|
||||
path(
|
||||
|
||||
@@ -19,7 +19,9 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archive/",
|
||||
ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]),
|
||||
ProjectArchiveUnarchiveAPIEndpoint.as_view(
|
||||
http_method_names=["post", "delete"]
|
||||
),
|
||||
name="project-archive-unarchive",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -11,7 +11,6 @@ from .state import (
|
||||
|
||||
from .issue import (
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueAttachmentServerEndpoint,
|
||||
IssueListCreateAPIEndpoint,
|
||||
IssueDetailAPIEndpoint,
|
||||
LabelListCreateAPIEndpoint,
|
||||
@@ -24,8 +23,6 @@ from .issue import (
|
||||
IssueActivityDetailAPIEndpoint,
|
||||
IssueAttachmentListCreateAPIEndpoint,
|
||||
IssueAttachmentDetailAPIEndpoint,
|
||||
IssueRelationListCreateAPIEndpoint,
|
||||
IssueRelationRemoveAPIEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
)
|
||||
|
||||
@@ -47,20 +44,6 @@ from .module import (
|
||||
)
|
||||
|
||||
from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
|
||||
from .user import UserEndpoint
|
||||
|
||||
from .customer import (
|
||||
CustomerAPIEndpoint,
|
||||
CustomerDetailAPIEndpoint,
|
||||
CustomerRequestAPIEndpoint,
|
||||
CustomerRequestDetailAPIEndpoint,
|
||||
CustomerIssuesAPIEndpoint,
|
||||
CustomerIssueDetailAPIEndpoint,
|
||||
CustomerPropertiesAPIEndpoint,
|
||||
CustomerPropertyDetailAPIEndpoint,
|
||||
CustomerPropertyValuesAPIEndpoint,
|
||||
CustomerPropertyValueDetailAPIEndpoint,
|
||||
)
|
||||
|
||||
from .intake import (
|
||||
IntakeIssueListCreateAPIEndpoint,
|
||||
@@ -69,4 +52,4 @@ from .intake import (
|
||||
|
||||
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
|
||||
|
||||
from .issue_type import IssueTypeListCreateAPIEndpoint, IssueTypeDetailAPIEndpoint
|
||||
from .user import UserEndpoint
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.conf import settings
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiRequest
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiRequest, OpenApiTypes
|
||||
|
||||
# Module Imports
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
@@ -158,7 +158,9 @@ class UserAssetEndpoint(BaseAPIView):
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
@@ -234,7 +236,9 @@ class UserAssetEndpoint(BaseAPIView):
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
self.entity_asset_delete(
|
||||
entity_type=asset.entity_type, asset=asset, request=request
|
||||
)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -278,9 +282,8 @@ class UserServerAssetEndpoint(BaseAPIView):
|
||||
def post(self, request):
|
||||
"""Generate presigned URL for user server asset upload.
|
||||
|
||||
Create a presigned URL for uploading user profile assets
|
||||
(avatar or cover image) using server credentials. This endpoint generates the
|
||||
necessary credentials for direct S3 upload with server-side authentication.
|
||||
Create a presigned URL for uploading user profile assets (avatar or cover image) using server credentials.
|
||||
This endpoint generates the necessary credentials for direct S3 upload with server-side authentication.
|
||||
"""
|
||||
# get the asset key
|
||||
name = request.data.get("name")
|
||||
@@ -331,7 +334,9 @@ class UserServerAssetEndpoint(BaseAPIView):
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request, is_server=True)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
@@ -383,15 +388,16 @@ class UserServerAssetEndpoint(BaseAPIView):
|
||||
def delete(self, request, asset_id):
|
||||
"""Delete user server asset.
|
||||
|
||||
Delete a user profile asset (avatar or cover image) using server credentials and
|
||||
remove its reference from the user profile. This performs a soft delete by marking the
|
||||
asset as deleted and updating the user's profile.
|
||||
Delete a user profile asset (avatar or cover image) using server credentials and remove its reference from the user profile.
|
||||
This performs a soft delete by marking the asset as deleted and updating the user's profile.
|
||||
"""
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
self.entity_asset_delete(
|
||||
entity_type=asset.entity_type, asset=asset, request=request
|
||||
)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -423,7 +429,9 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace_id=workspace.id, is_deleted=False)
|
||||
asset = FileAsset.objects.get(
|
||||
id=asset_id, workspace_id=workspace.id, is_deleted=False
|
||||
)
|
||||
|
||||
# Check if the asset exists and is uploaded
|
||||
if not asset.is_uploaded:
|
||||
@@ -432,8 +440,6 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
size_limit = settings.FILE_SIZE_LIMIT
|
||||
|
||||
# Generate presigned URL for GET
|
||||
storage = S3Storage(request=request, is_server=True)
|
||||
presigned_url = storage.generate_presigned_url(
|
||||
@@ -451,9 +457,13 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
except Workspace.DoesNotExist:
|
||||
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(
|
||||
{"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(
|
||||
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return Response(
|
||||
@@ -555,12 +565,14 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||
created_by=request.user,
|
||||
external_id=external_id,
|
||||
external_source=external_source,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues # noqa: E501
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request, is_server=True)
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
@@ -599,7 +611,9 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||
and trigger metadata extraction.
|
||||
"""
|
||||
try:
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug, is_deleted=False)
|
||||
asset = FileAsset.objects.get(
|
||||
id=asset_id, workspace__slug=slug, is_deleted=False
|
||||
)
|
||||
|
||||
# Update is_uploaded status
|
||||
asset.is_uploaded = request.data.get("is_uploaded", asset.is_uploaded)
|
||||
@@ -612,4 +626,6 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(
|
||||
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@@ -13,18 +13,11 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Third party imports
|
||||
from oauth2_provider.contrib.rest_framework import (
|
||||
OAuth2Authentication,
|
||||
IsAuthenticatedOrTokenHasScope,
|
||||
)
|
||||
|
||||
from rest_framework.generics import GenericAPIView
|
||||
|
||||
# Module imports
|
||||
from plane.api.middleware.api_authentication import APIKeyAuthentication
|
||||
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
|
||||
from plane.authentication.rate_limit import OAuthTokenRateThrottle
|
||||
from plane.authentication.permissions.oauth import OauthApplicationWorkspacePermission
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.paginator import BasePaginator
|
||||
from plane.utils.core.mixins import ReadReplicaControlMixin
|
||||
@@ -44,14 +37,12 @@ class TimezoneMixin:
|
||||
timezone.deactivate()
|
||||
|
||||
|
||||
class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator):
|
||||
authentication_classes = [APIKeyAuthentication, OAuth2Authentication]
|
||||
permission_classes = [
|
||||
IsAuthenticated,
|
||||
IsAuthenticatedOrTokenHasScope,
|
||||
OauthApplicationWorkspacePermission,
|
||||
]
|
||||
required_scopes = ["read", "write"]
|
||||
class BaseAPIView(
|
||||
TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator
|
||||
):
|
||||
authentication_classes = [APIKeyAuthentication]
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
use_read_replica = False
|
||||
|
||||
@@ -61,18 +52,19 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
|
||||
return queryset
|
||||
|
||||
def get_throttles(self):
|
||||
throttle_classes = super().get_throttles()
|
||||
throttle_classes = []
|
||||
api_key = self.request.headers.get("X-Api-Key")
|
||||
|
||||
if api_key:
|
||||
service_token = APIToken.objects.filter(token=api_key, is_service=True).first()
|
||||
service_token = APIToken.objects.filter(
|
||||
token=api_key, is_service=True
|
||||
).first()
|
||||
|
||||
if service_token:
|
||||
throttle_classes.append(ServiceTokenRateThrottle())
|
||||
return throttle_classes
|
||||
|
||||
throttle_classes.append(ApiKeyRateThrottle())
|
||||
throttle_classes.append(OAuthTokenRateThrottle())
|
||||
|
||||
return throttle_classes
|
||||
|
||||
@@ -121,7 +113,9 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
|
||||
if settings.DEBUG:
|
||||
from django.db import connection
|
||||
|
||||
print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}")
|
||||
print(
|
||||
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
|
||||
)
|
||||
return response
|
||||
except Exception as exc:
|
||||
response = self.handle_exception(exc)
|
||||
@@ -157,10 +151,14 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
|
||||
|
||||
@property
|
||||
def fields(self):
|
||||
fields = [field for field in self.request.GET.get("fields", "").split(",") if field]
|
||||
fields = [
|
||||
field for field in self.request.GET.get("fields", "").split(",") if field
|
||||
]
|
||||
return fields if fields else None
|
||||
|
||||
@property
|
||||
def expand(self):
|
||||
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
|
||||
expand = [
|
||||
expand for expand in self.request.GET.get("expand", "").split(",") if expand
|
||||
]
|
||||
return expand if expand else None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+113
-198
@@ -1,6 +1,5 @@
|
||||
# Python imports
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
# Django imports
|
||||
from django.core import serializers
|
||||
@@ -20,7 +19,6 @@ from django.db.models import (
|
||||
)
|
||||
from django.db.models.functions import Cast, Concat
|
||||
from django.db import models
|
||||
from django.utils.dateparse import parse_date
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -53,9 +51,6 @@ from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.host import base_host
|
||||
from .base import BaseAPIView
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.ee.bgtasks.entity_issue_state_progress_task import (
|
||||
entity_issue_state_activity_task,
|
||||
)
|
||||
from plane.utils.openapi.decorators import cycle_docs
|
||||
from plane.utils.openapi import (
|
||||
CURSOR_PARAMETER,
|
||||
@@ -82,7 +77,6 @@ from plane.utils.openapi import (
|
||||
UNARCHIVED_RESPONSE,
|
||||
REQUIRED_FIELDS_RESPONSE,
|
||||
)
|
||||
from plane.ee.models import EntityProgress
|
||||
|
||||
|
||||
class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
@@ -177,7 +171,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
@cycle_docs(
|
||||
operation_id="list_cycles",
|
||||
summary="List cycles",
|
||||
description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.", # noqa: E501
|
||||
description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.",
|
||||
parameters=[
|
||||
CURSOR_PARAMETER,
|
||||
PER_PAGE_PARAMETER,
|
||||
@@ -207,7 +201,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
# Current Cycle
|
||||
if cycle_view == "current":
|
||||
queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now())
|
||||
queryset = queryset.filter(
|
||||
start_date__lte=timezone.now(), end_date__gte=timezone.now()
|
||||
)
|
||||
data = CycleSerializer(
|
||||
queryset,
|
||||
many=True,
|
||||
@@ -264,7 +260,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
# Incomplete Cycles
|
||||
if cycle_view == "incomplete":
|
||||
queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True))
|
||||
queryset = queryset.filter(
|
||||
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)
|
||||
)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
@@ -291,7 +289,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
@cycle_docs(
|
||||
operation_id="create_cycle",
|
||||
summary="Create cycle",
|
||||
description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.", # noqa: E501
|
||||
description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.",
|
||||
request=OpenApiRequest(
|
||||
request=CycleCreateSerializer,
|
||||
examples=[CYCLE_CREATE_EXAMPLE],
|
||||
@@ -310,8 +308,12 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
Create a new development cycle with specified name, description, and date range.
|
||||
Supports external ID tracking for integration purposes.
|
||||
"""
|
||||
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
|
||||
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
|
||||
if (
|
||||
request.data.get("start_date", None) is None
|
||||
and request.data.get("end_date", None) is None
|
||||
) or (
|
||||
request.data.get("start_date", None) is not None
|
||||
and request.data.get("end_date", None) is not None
|
||||
):
|
||||
serializer = CycleCreateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
@@ -339,7 +341,6 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save(project_id=project_id, owned_by=request.user)
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="cycle",
|
||||
@@ -357,7 +358,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Both start date and end date are either required or are to be null"},
|
||||
{
|
||||
"error": "Both start date and end date are either required or are to be null"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -484,7 +487,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
@cycle_docs(
|
||||
operation_id="update_cycle",
|
||||
summary="Update cycle",
|
||||
description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.", # noqa: E501
|
||||
description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.",
|
||||
request=OpenApiRequest(
|
||||
request=CycleUpdateSerializer,
|
||||
examples=[CYCLE_UPDATE_EXAMPLE],
|
||||
@@ -505,7 +508,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
|
||||
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
|
||||
current_instance = json.dumps(
|
||||
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
if cycle.archived_at:
|
||||
return Response(
|
||||
@@ -518,71 +523,18 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order
|
||||
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
|
||||
request_data = {
|
||||
"sort_order": request_data.get("sort_order", cycle.sort_order)
|
||||
}
|
||||
else:
|
||||
return Response(
|
||||
{"error": "The Cycle has already been completed so it cannot be edited"},
|
||||
{
|
||||
"error": "The Cycle has already been completed so it cannot be edited"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True)
|
||||
if cycle.start_date is not None and cycle.end_date is not None:
|
||||
if cycle.start_date.date() <= date.today() and cycle.end_date.date() >= date.today():
|
||||
# Convert the given string date
|
||||
parsed_request_start_date = parse_date(request_data["start_date"])
|
||||
|
||||
last_entity_progress = (
|
||||
EntityProgress.objects.filter(cycle_id=pk, workspace__slug=slug, entity_type="CYCLE")
|
||||
.order_by("progress_date")
|
||||
.first()
|
||||
)
|
||||
if parsed_request_start_date < cycle.start_date.date():
|
||||
# Find all the dates between the requested start date and the cycle's start date
|
||||
start_date = parsed_request_start_date
|
||||
end_date = cycle.start_date.date()
|
||||
|
||||
delta = (end_date - start_date).days
|
||||
|
||||
dates = []
|
||||
|
||||
for i in range(delta):
|
||||
day = start_date + timedelta(days=i)
|
||||
dates.append(day)
|
||||
|
||||
if last_entity_progress:
|
||||
EntityProgress.objects.bulk_create(
|
||||
[
|
||||
EntityProgress(
|
||||
cycle_id=pk,
|
||||
progress_date=date,
|
||||
entity_type="CYCLE",
|
||||
total_issues=last_entity_progress.total_issues,
|
||||
total_estimate_points=last_entity_progress.total_estimate_points,
|
||||
backlog_issues=last_entity_progress.backlog_issues,
|
||||
unstarted_issues=last_entity_progress.unstarted_issues,
|
||||
started_issues=last_entity_progress.started_issues,
|
||||
completed_issues=last_entity_progress.completed_issues,
|
||||
cancelled_issues=last_entity_progress.cancelled_issues,
|
||||
backlog_estimate_points=last_entity_progress.backlog_estimate_points,
|
||||
unstarted_estimate_points=last_entity_progress.unstarted_estimate_points,
|
||||
started_estimate_points=last_entity_progress.started_estimate_points,
|
||||
completed_estimate_points=last_entity_progress.completed_estimate_points,
|
||||
cancelled_estimate_points=last_entity_progress.cancelled_estimate_points,
|
||||
created_at=timezone.now(),
|
||||
updated_at=timezone.now(),
|
||||
workspace_id=last_entity_progress.workspace.id,
|
||||
)
|
||||
for date in dates
|
||||
]
|
||||
)
|
||||
elif parsed_request_start_date > cycle.start_date.date():
|
||||
EntityProgress.objects.filter(
|
||||
progress_date__lte=(parsed_request_start_date - timedelta(days=1)),
|
||||
workspace__slug=slug,
|
||||
entity_type="CYCLE",
|
||||
).delete()
|
||||
last_entity_progress.delete()
|
||||
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -590,7 +542,9 @@ class CycleDetailAPIEndpoint(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()
|
||||
):
|
||||
@@ -647,7 +601,11 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
@@ -667,7 +625,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
# Delete the cycle
|
||||
cycle.delete()
|
||||
# Delete the user favorite cycle
|
||||
UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete()
|
||||
UserFavorite.objects.filter(
|
||||
entity_type="cycle", entity_identifier=pk, project_id=project_id
|
||||
).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -805,13 +765,15 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data,
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@cycle_docs(
|
||||
operation_id="archive_cycle",
|
||||
summary="Archive cycle",
|
||||
description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.", # noqa: E501
|
||||
description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.",
|
||||
request={},
|
||||
responses={
|
||||
204: ARCHIVED_RESPONSE,
|
||||
@@ -824,7 +786,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
Move a completed cycle to archived status for historical tracking.
|
||||
Only cycles that have ended can be archived.
|
||||
"""
|
||||
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
if cycle.end_date >= timezone.now():
|
||||
return Response(
|
||||
{"error": "Only completed cycles can be archived"},
|
||||
@@ -855,7 +819,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
Restore an archived cycle to active status, making it available for regular use.
|
||||
The cycle will reappear in active cycle lists.
|
||||
"""
|
||||
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
cycle.archived_at = None
|
||||
cycle.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -918,7 +884,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
# List
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True)
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -955,13 +923,15 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issues),
|
||||
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
|
||||
on_results=lambda issues: IssueSerializer(
|
||||
issues, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@cycle_docs(
|
||||
operation_id="add_cycle_work_items",
|
||||
summary="Add Work Items to Cycle",
|
||||
description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.", # noqa: E501
|
||||
description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.",
|
||||
request=OpenApiRequest(
|
||||
request=CycleIssueRequestSerializer,
|
||||
examples=[CYCLE_ISSUE_REQUEST_EXAMPLE],
|
||||
@@ -969,7 +939,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Cycle work items added",
|
||||
response=CycleIssueSerializer(many=True),
|
||||
response=CycleIssueSerializer,
|
||||
examples=[CYCLE_ISSUE_EXAMPLE],
|
||||
),
|
||||
400: REQUIRED_FIELDS_RESPONSE,
|
||||
@@ -985,56 +955,31 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
if not issues:
|
||||
return Response(
|
||||
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, status=status.HTTP_400_BAD_REQUEST
|
||||
{"error": "Work items are required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
{
|
||||
"code": "CYCLE_COMPLETED",
|
||||
"message": "The Cycle has already been completed so no new issues can be added",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
|
||||
# Get all CycleWorkItems already created
|
||||
cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues))
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
|
||||
)
|
||||
|
||||
existing_issues = [
|
||||
str(cycle_issue.issue_id) for cycle_issue in cycle_issues if str(cycle_issue.issue_id) in issues
|
||||
str(cycle_issue.issue_id)
|
||||
for cycle_issue in cycle_issues
|
||||
if str(cycle_issue.issue_id) in issues
|
||||
]
|
||||
new_issues = list(set(issues) - set(existing_issues))
|
||||
|
||||
issue_cycle_data_added = [
|
||||
{
|
||||
"issue_id": str(issue_id),
|
||||
"cycle_id": str(cycle_id),
|
||||
}
|
||||
for issue_id in issues
|
||||
]
|
||||
|
||||
issues_removed = CycleIssue.objects.filter(
|
||||
issue_id__in=existing_issues,
|
||||
workspace__slug=slug,
|
||||
).values("issue_id", "cycle_id")
|
||||
|
||||
issue_cycle_data_removed = [
|
||||
{
|
||||
"issue_id": str(issue["issue_id"]),
|
||||
"cycle_id": str(issue["cycle_id"]),
|
||||
}
|
||||
for issue in issues_removed
|
||||
]
|
||||
|
||||
# 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,
|
||||
)
|
||||
@@ -1066,22 +1011,6 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
# Update the cycle issues
|
||||
CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100)
|
||||
|
||||
# For REMOVED
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=issue_cycle_data_removed,
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="REMOVED",
|
||||
)
|
||||
|
||||
# For ADDED
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=issue_cycle_data_added,
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="ADDED",
|
||||
)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
@@ -1092,7 +1021,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": serializers.serialize("json", created_records),
|
||||
"created_cycle_issues": serializers.serialize(
|
||||
"json", created_records
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
@@ -1168,7 +1099,9 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView):
|
||||
cycle_id=cycle_id,
|
||||
issue_id=issue_id,
|
||||
)
|
||||
serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand)
|
||||
serializer = CycleIssueSerializer(
|
||||
cycle_issue, fields=self.fields, expand=self.expand
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@cycle_docs(
|
||||
@@ -1207,18 +1140,6 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
# Trigger the entity issue state activity task
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{
|
||||
"issue_id": str(issue_id),
|
||||
"cycle_id": str(cycle_id),
|
||||
}
|
||||
],
|
||||
user_id=str(self.request.user.id),
|
||||
slug=slug,
|
||||
action="REMOVED",
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -1233,7 +1154,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
@cycle_docs(
|
||||
operation_id="transfer_cycle_work_items",
|
||||
summary="Transfer cycle work items",
|
||||
description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.", # noqa: E501
|
||||
description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.",
|
||||
request=OpenApiRequest(
|
||||
request=TransferCycleIssueRequestSerializer,
|
||||
examples=[TRANSFER_CYCLE_ISSUE_EXAMPLE],
|
||||
@@ -1286,10 +1207,14 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
|
||||
new_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
||||
).first()
|
||||
|
||||
old_cycle = (
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
@@ -1399,7 +1324,9 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar", "avatar_url")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
@@ -1426,7 +1353,9 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
assignee_estimate_distribution = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"assignee_id": (
|
||||
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||
),
|
||||
"avatar": item.get("avatar", None),
|
||||
"avatar_url": item.get("avatar_url", None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
@@ -1447,7 +1376,9 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
@@ -1514,13 +1445,19 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True, then="assignees__avatar"
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id", filter=Q(archived_at__isnull=True, is_draft=False)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
@@ -1547,7 +1484,9 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"assignee_id": (
|
||||
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||
),
|
||||
"avatar": item.get("avatar", None),
|
||||
"avatar_url": item.get("avatar_url", None),
|
||||
"total_issues": item["total_issues"],
|
||||
@@ -1569,7 +1508,11 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id", filter=Q(archived_at__isnull=True, is_draft=False)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
@@ -1615,7 +1558,9 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
|
||||
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
|
||||
current_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
@@ -1643,7 +1588,9 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
|
||||
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
{"error": "The cycle where the issues are transferred is already completed"},
|
||||
{
|
||||
"error": "The cycle where the issues are transferred is already completed"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -1652,7 +1599,6 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
issue__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
updated_cycles = []
|
||||
@@ -1668,41 +1614,10 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
}
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
|
||||
|
||||
# EE code
|
||||
# Extract issue IDs from cycle_issues
|
||||
issue_ids = [ci.issue_id for ci in updated_cycles]
|
||||
|
||||
# Trigger Celery task for REMOVED
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{
|
||||
"issue_id": str(issue_id),
|
||||
"cycle_id": str(cycle_id),
|
||||
}
|
||||
for issue_id in issue_ids
|
||||
],
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="REMOVED",
|
||||
cycle_issues = CycleIssue.objects.bulk_update(
|
||||
updated_cycles, ["cycle_id"], batch_size=100
|
||||
)
|
||||
|
||||
# Trigger Celery task for ADDED
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{
|
||||
"issue_id": str(issue_id),
|
||||
"cycle_id": str(new_cycle_id),
|
||||
}
|
||||
for issue_id in issue_ids
|
||||
],
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="ADDED",
|
||||
)
|
||||
|
||||
# EE code end here
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q, Value, UUIDField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Q, UUIDField, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -23,18 +23,8 @@ from plane.api.serializers import (
|
||||
)
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Intake,
|
||||
IntakeIssue,
|
||||
Issue,
|
||||
Project,
|
||||
ProjectMember,
|
||||
State,
|
||||
IssueType,
|
||||
)
|
||||
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
|
||||
from plane.utils.host import base_host
|
||||
from plane.ee.models import IntakeSetting
|
||||
from plane.ee.utils.workflow import WorkflowStateManager
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models.intake import SourceType
|
||||
from plane.utils.openapi import (
|
||||
@@ -72,9 +62,11 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
).first()
|
||||
|
||||
project = Project.objects.get(workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id"))
|
||||
project = Project.objects.get(
|
||||
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
|
||||
)
|
||||
|
||||
if intake is None or not project.intake_view:
|
||||
if intake is None and not project.intake_view:
|
||||
return IntakeIssue.objects.none()
|
||||
|
||||
return (
|
||||
@@ -91,7 +83,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
@intake_docs(
|
||||
operation_id="get_intake_work_items_list",
|
||||
summary="List intake work items",
|
||||
description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.", # noqa: E501
|
||||
description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.",
|
||||
parameters=[
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
@@ -127,7 +119,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
@intake_docs(
|
||||
operation_id="create_intake_work_item",
|
||||
summary="Create intake work item",
|
||||
description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.", # noqa: E501
|
||||
description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.",
|
||||
parameters=[
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
@@ -152,16 +144,22 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
Automatically creates the work item with default triage state and tracks activity.
|
||||
"""
|
||||
if not request.data.get("issue", {}).get("name", False):
|
||||
return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
|
||||
intake = Intake.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
|
||||
# Intake view
|
||||
if intake is None and not project.intake_view:
|
||||
return Response(
|
||||
{"error": "Intake is not enabled for this project enable it through the project's api"},
|
||||
{
|
||||
"error": "Intake is not enabled for this project enable it through the project's api"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -173,21 +171,19 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
"urgent",
|
||||
"none",
|
||||
]:
|
||||
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get the issue type
|
||||
issue_type = IssueType.objects.filter(
|
||||
project_issue_types__project_id=project_id, is_epic=False, is_default=True
|
||||
).first()
|
||||
return Response(
|
||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# create an issue
|
||||
issue = Issue.objects.create(
|
||||
name=request.data.get("issue", {}).get("name"),
|
||||
description=request.data.get("issue", {}).get("description", {}),
|
||||
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
|
||||
description_html=request.data.get("issue", {}).get(
|
||||
"description_html", "<p></p>"
|
||||
),
|
||||
priority=request.data.get("issue", {}).get("priority", "none"),
|
||||
project_id=project_id,
|
||||
type=issue_type,
|
||||
)
|
||||
|
||||
# create an intake issue
|
||||
@@ -230,9 +226,11 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
).first()
|
||||
|
||||
project = Project.objects.get(workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id"))
|
||||
project = Project.objects.get(
|
||||
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
|
||||
)
|
||||
|
||||
if intake is None or not project.intake_view:
|
||||
if intake is None and not project.intake_view:
|
||||
return IntakeIssue.objects.none()
|
||||
|
||||
return (
|
||||
@@ -269,13 +267,15 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
Retrieve details of a specific intake work item.
|
||||
"""
|
||||
intake_issue_queryset = self.get_queryset().get(issue_id=issue_id)
|
||||
intake_issue_data = IntakeIssueSerializer(intake_issue_queryset, fields=self.fields, expand=self.expand).data
|
||||
intake_issue_data = IntakeIssueSerializer(
|
||||
intake_issue_queryset, fields=self.fields, expand=self.expand
|
||||
).data
|
||||
return Response(intake_issue_data, status=status.HTTP_200_OK)
|
||||
|
||||
@intake_docs(
|
||||
operation_id="update_intake_work_item",
|
||||
summary="Update intake work item",
|
||||
description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.", # noqa: E501
|
||||
description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.",
|
||||
parameters=[
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
@@ -300,24 +300,18 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
Modify an existing intake work item's properties or status for triage processing.
|
||||
Supports status changes like accept, reject, or mark as duplicate.
|
||||
"""
|
||||
intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
|
||||
intake = Intake.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
|
||||
# Intake view
|
||||
if intake is None and not project.intake_view:
|
||||
return Response(
|
||||
{"error": "Intake is not enabled for this project enable it through the project's api"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
intake_settings = IntakeSetting.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, intake=intake
|
||||
).first()
|
||||
|
||||
if intake_settings is not None and not intake_settings.is_in_app_enabled:
|
||||
return Response(
|
||||
{"error": "Creating intake issues is disabled"},
|
||||
{
|
||||
"error": "Intake is not enabled for this project enable it through the project's api"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -338,7 +332,9 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
# Only project members admins and created_by users can access this endpoint
|
||||
if project_member.role <= 5 and str(intake_issue.created_by_id) != str(request.user.id):
|
||||
if project_member.role <= 5 and str(intake_issue.created_by_id) != str(
|
||||
request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot edit intake work items"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -353,7 +349,10 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)),
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
@@ -370,24 +369,13 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
).get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
# Check if state is updated then is the transition allowed
|
||||
workflow_state_manager = WorkflowStateManager(project_id=project_id, slug=slug)
|
||||
if request.data.get("state_id") and not workflow_state_manager.validate_state_transition(
|
||||
issue=issue,
|
||||
new_state_id=request.data.get("state_id"),
|
||||
user_id=request.user.id,
|
||||
):
|
||||
return Response(
|
||||
{"error": "State transition is not allowed"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Only allow guests to edit name and description
|
||||
if project_member.role <= 5:
|
||||
issue_data = {
|
||||
"name": issue_data.get("name", issue.name),
|
||||
"description_html": issue_data.get("description_html", issue.description_html),
|
||||
"description_html": issue_data.get(
|
||||
"description_html", issue.description_html
|
||||
),
|
||||
"description": issue_data.get("description", issue.description),
|
||||
}
|
||||
|
||||
@@ -413,31 +401,45 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Only project admins and members can edit intake issue attributes
|
||||
if project_member.role > 15:
|
||||
serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
|
||||
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
|
||||
serializer = IntakeIssueUpdateSerializer(
|
||||
intake_issue, data=request.data, partial=True
|
||||
)
|
||||
current_instance = json.dumps(
|
||||
IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Update the issue state if the issue is rejected or marked as duplicate
|
||||
if serializer.data["status"] in [-1, 2]:
|
||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
state = State.objects.filter(
|
||||
group="cancelled", workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
|
||||
# Update the issue state if it is accepted
|
||||
if serializer.data["status"] in [1]:
|
||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
# Update the issue state only if it is in triage state
|
||||
if issue.state.is_triage:
|
||||
# Move to default state
|
||||
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
|
||||
state = State.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, default=True
|
||||
).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
@@ -459,12 +461,14 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@intake_docs(
|
||||
operation_id="delete_intake_work_item",
|
||||
summary="Delete intake work item",
|
||||
description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.", # noqa: E501
|
||||
description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.",
|
||||
parameters=[
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
@@ -480,14 +484,18 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
Permanently remove an intake work item from the triage queue.
|
||||
Also deletes the underlying work item if it hasn't been accepted yet.
|
||||
"""
|
||||
intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
|
||||
intake = Intake.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
|
||||
# Intake view
|
||||
if intake is None and not project.intake_view:
|
||||
return Response(
|
||||
{"error": "Intake is not enabled for this project enable it through the project's api"},
|
||||
{
|
||||
"error": "Intake is not enabled for this project enable it through the project's api"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -502,7 +510,9 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
# Check the issue status
|
||||
if intake_issue.status in [-2, -1, 0, 2]:
|
||||
# Delete the issue also
|
||||
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=issue_id).first()
|
||||
issue = Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=issue_id
|
||||
).first()
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
|
||||
+167
-689
File diff suppressed because it is too large
Load Diff
@@ -1,414 +0,0 @@
|
||||
# Python imports
|
||||
import random
|
||||
|
||||
# Django imports
|
||||
from django.db import transaction
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest, OpenApiExample
|
||||
|
||||
# Module imports
|
||||
from plane.api.views.base import BaseAPIView
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Workspace, Project, IssueType, ProjectIssueType, Issue
|
||||
from plane.api.serializers import IssueTypeAPISerializer, ProjectIssueTypeAPISerializer
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
from plane.utils.helpers import get_boolean_value
|
||||
from plane.utils.openapi.decorators import issue_type_docs
|
||||
|
||||
|
||||
class IssueTypeListCreateAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
This viewset automatically provides `list` and `create` actions related to issue types.
|
||||
"""
|
||||
|
||||
model = IssueType
|
||||
serializer_class = IssueTypeAPISerializer
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
webhook_event = "issue_type"
|
||||
|
||||
logo_icons = [
|
||||
"Activity",
|
||||
"AlertCircle",
|
||||
"Archive",
|
||||
"Bell",
|
||||
"Calendar",
|
||||
"Camera",
|
||||
"Check",
|
||||
"Clock",
|
||||
"Code",
|
||||
"Database",
|
||||
"Download",
|
||||
"Edit",
|
||||
"File",
|
||||
"Folder",
|
||||
"Globe",
|
||||
"Heart",
|
||||
"Home",
|
||||
"Mail",
|
||||
"Search",
|
||||
"User",
|
||||
]
|
||||
|
||||
logo_backgrounds = [
|
||||
"#EF5974",
|
||||
"#FF7474",
|
||||
"#FC964D",
|
||||
"#1FA191",
|
||||
"#6DBCF5",
|
||||
"#748AFF",
|
||||
"#4C49F8",
|
||||
"#5D407A",
|
||||
"#999AA0",
|
||||
]
|
||||
|
||||
model = IssueType
|
||||
serializer_class = IssueTypeAPISerializer
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
webhook_event = "issue_type"
|
||||
|
||||
@property
|
||||
def workspace_slug(self):
|
||||
return self.kwargs.get("slug", None)
|
||||
|
||||
@property
|
||||
def project_id(self):
|
||||
return self.kwargs.get("project_id", None)
|
||||
|
||||
@property
|
||||
def type_id(self):
|
||||
return self.kwargs.get("type_id", None)
|
||||
|
||||
def generate_logo_prop(self):
|
||||
return {
|
||||
"in_use": "icon",
|
||||
"icon": {
|
||||
"name": self.logo_icons[random.randint(0, len(self.logo_icons) - 1)],
|
||||
"background_color": self.logo_backgrounds[
|
||||
random.randint(0, len(self.logo_backgrounds) - 1)
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
return self.model.objects.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_issue_types__project_id=self.kwargs.get("project_id"),
|
||||
is_epic=False,
|
||||
)
|
||||
|
||||
# list issue types and get issue type by id
|
||||
@check_feature_flag(FeatureFlag.ISSUE_TYPES)
|
||||
@issue_type_docs(
|
||||
operation_id="list_issue_types",
|
||||
description="List all issue types for a project",
|
||||
summary="List issue types",
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Issue types",
|
||||
response=IssueTypeAPISerializer(many=True),
|
||||
),
|
||||
404: OpenApiResponse(description="Issue type not found"),
|
||||
},
|
||||
)
|
||||
def get(self, request, slug, project_id):
|
||||
# list of issue types
|
||||
issue_types = self.get_queryset()
|
||||
serializer = self.serializer_class(issue_types, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# create issue type
|
||||
@issue_type_docs(
|
||||
operation_id="create_issue_type",
|
||||
description="Create a new issue type for a project",
|
||||
summary="Create a new issue type",
|
||||
request=OpenApiRequest(
|
||||
request=IssueTypeAPISerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"IssueTypeAPISerializer",
|
||||
value={
|
||||
"name": "Bug",
|
||||
"description": "A bug is a problem with the software that prevents it from working as expected.",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for creating an issue type",
|
||||
),
|
||||
],
|
||||
),
|
||||
responses={
|
||||
201: OpenApiResponse(
|
||||
description="Issue type created", response=IssueTypeAPISerializer
|
||||
),
|
||||
409: OpenApiResponse(
|
||||
description="Issue type with the same external id and external source already exists"
|
||||
),
|
||||
},
|
||||
)
|
||||
@check_feature_flag(FeatureFlag.ISSUE_TYPES)
|
||||
def post(self, request, slug, project_id):
|
||||
with transaction.atomic():
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
# check if issue type with the same external id and external source already exists
|
||||
# return the issue type id if it exists
|
||||
external_id = request.data.get("external_id")
|
||||
external_source = request.data.get("external_source")
|
||||
external_existing_issue_type = (
|
||||
self.get_queryset()
|
||||
.filter(external_id=external_id, external_source=external_source)
|
||||
.first()
|
||||
)
|
||||
|
||||
if external_id and external_source and external_existing_issue_type:
|
||||
return Response(
|
||||
{
|
||||
"error": "Work item type with the same external id and external source already exists",
|
||||
"id": str(external_existing_issue_type.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
# creating issue type
|
||||
issue_type_serializer = self.serializer_class(data=request.data)
|
||||
issue_type_serializer.is_valid(raise_exception=True)
|
||||
issue_type_serializer.save(
|
||||
workspace=workspace, logo_props=self.generate_logo_prop()
|
||||
)
|
||||
|
||||
# adding the issue type to the project
|
||||
project_issue_type_serializer = ProjectIssueTypeAPISerializer(
|
||||
data={"issue_type": issue_type_serializer.data["id"]}
|
||||
)
|
||||
project_issue_type_serializer.is_valid(raise_exception=True)
|
||||
project_issue_type_serializer.save(project=project, level=0)
|
||||
|
||||
# getting the issue type
|
||||
issue_type = self.get_queryset().get(pk=issue_type_serializer.data["id"])
|
||||
serializer = self.serializer_class(issue_type)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class IssueTypeDetailAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
This viewset automatically provides `list`, `create`, `retrieve`,
|
||||
`update` and `destroy` actions related to issue types.
|
||||
|
||||
"""
|
||||
|
||||
logo_icons = [
|
||||
"Activity",
|
||||
"AlertCircle",
|
||||
"Archive",
|
||||
"Bell",
|
||||
"Calendar",
|
||||
"Camera",
|
||||
"Check",
|
||||
"Clock",
|
||||
"Code",
|
||||
"Database",
|
||||
"Download",
|
||||
"Edit",
|
||||
"File",
|
||||
"Folder",
|
||||
"Globe",
|
||||
"Heart",
|
||||
"Home",
|
||||
"Mail",
|
||||
"Search",
|
||||
"User",
|
||||
]
|
||||
|
||||
logo_backgrounds = [
|
||||
"#EF5974",
|
||||
"#FF7474",
|
||||
"#FC964D",
|
||||
"#1FA191",
|
||||
"#6DBCF5",
|
||||
"#748AFF",
|
||||
"#4C49F8",
|
||||
"#5D407A",
|
||||
"#999AA0",
|
||||
]
|
||||
|
||||
model = IssueType
|
||||
serializer_class = IssueTypeAPISerializer
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
webhook_event = "issue_type"
|
||||
|
||||
@property
|
||||
def workspace_slug(self):
|
||||
return self.kwargs.get("slug", None)
|
||||
|
||||
@property
|
||||
def project_id(self):
|
||||
return self.kwargs.get("project_id", None)
|
||||
|
||||
@property
|
||||
def type_id(self):
|
||||
return self.kwargs.get("type_id", None)
|
||||
|
||||
def generate_logo_prop(self):
|
||||
return {
|
||||
"in_use": "icon",
|
||||
"icon": {
|
||||
"name": self.logo_icons[random.randint(0, len(self.logo_icons) - 1)],
|
||||
"background_color": self.logo_backgrounds[
|
||||
random.randint(0, len(self.logo_backgrounds) - 1)
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
return self.model.objects.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_issue_types__project_id=self.kwargs.get("project_id"),
|
||||
is_epic=False,
|
||||
)
|
||||
|
||||
# list issue types and get issue type by id
|
||||
@check_feature_flag(FeatureFlag.ISSUE_TYPES)
|
||||
@issue_type_docs(
|
||||
operation_id="retrieve_issue_type",
|
||||
summary="Retrieve an issue type by id",
|
||||
description="Retrieve an issue type by id",
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Issue types", response=IssueTypeAPISerializer
|
||||
),
|
||||
404: OpenApiResponse(description="Issue type not found"),
|
||||
},
|
||||
)
|
||||
def get(self, request, slug, project_id, type_id):
|
||||
issue_type = self.get_queryset().get(pk=type_id)
|
||||
issue_type = self.get_queryset().annotate(
|
||||
project_ids=Coalesce(
|
||||
Subquery(
|
||||
ProjectIssueType.objects.filter(
|
||||
issue_type=OuterRef("pk"), workspace__slug=slug
|
||||
)
|
||||
.values("issue_type")
|
||||
.annotate(project_ids=ArrayAgg("project_id", distinct=True))
|
||||
.values("project_ids")
|
||||
),
|
||||
[],
|
||||
)
|
||||
)
|
||||
serializer = self.serializer_class(issue_type, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# update issue type by id
|
||||
@issue_type_docs(
|
||||
operation_id="update_issue_type",
|
||||
summary="Update an issue type",
|
||||
description="Update an issue type",
|
||||
request=OpenApiRequest(
|
||||
request=IssueTypeAPISerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"IssueTypeAPISerializer",
|
||||
value={
|
||||
"name": "Bug",
|
||||
"description": "A bug is a problem with the software that prevents it from working as expected.",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for updating an issue type",
|
||||
),
|
||||
],
|
||||
),
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Issue type updated", response=IssueTypeAPISerializer
|
||||
),
|
||||
409: OpenApiResponse(
|
||||
description="Issue type with the same external id and external source already exists"
|
||||
),
|
||||
},
|
||||
)
|
||||
@check_feature_flag(FeatureFlag.ISSUE_TYPES)
|
||||
def patch(self, request, slug, project_id, type_id):
|
||||
with transaction.atomic():
|
||||
issue_type = self.get_queryset().get(pk=type_id)
|
||||
data = request.data
|
||||
|
||||
# check if the issue type is the default issue type and if the is_active field is being updated to false
|
||||
if (
|
||||
issue_type.is_default
|
||||
and get_boolean_value(request.data.get("is_active")) is False
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Default work item type's is_active field cannot be false"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
issue_type_serializer = self.serializer_class(
|
||||
issue_type, data=data, partial=True
|
||||
)
|
||||
issue_type_serializer.is_valid(raise_exception=True)
|
||||
|
||||
# check if issue type with the same external id and external source already exists
|
||||
external_id = request.data.get("external_id")
|
||||
external_source = request.data.get("external_source")
|
||||
external_existing_issue_type = (
|
||||
self.get_queryset()
|
||||
.filter(external_id=external_id, external_source=external_source)
|
||||
.first()
|
||||
)
|
||||
|
||||
# don't allow updating the external id if it already exists in another issue type
|
||||
# checking if the external id is being updated to a different issue type
|
||||
if external_id and external_existing_issue_type.id != issue_type.id:
|
||||
return Response(
|
||||
{
|
||||
"error": "Work item type with the same external id and external source already exists",
|
||||
"id": str(issue_type.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
issue_type_serializer.save()
|
||||
return Response(issue_type_serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# delete issue type by id
|
||||
@issue_type_docs(
|
||||
operation_id="delete_issue_type",
|
||||
summary="Delete an issue type",
|
||||
description="Delete an issue type",
|
||||
responses={
|
||||
204: OpenApiResponse(description="Issue type deleted"),
|
||||
400: OpenApiResponse(
|
||||
description="Default work item type cannot be deleted"
|
||||
),
|
||||
},
|
||||
)
|
||||
@check_feature_flag(FeatureFlag.ISSUE_TYPES)
|
||||
def delete(self, request, slug, project_id, type_id):
|
||||
issue_type = self.get_queryset().get(pk=type_id)
|
||||
|
||||
# check if the issue type is the default issue type
|
||||
if issue_type.is_default:
|
||||
return Response(
|
||||
{"error": "Default work item type cannot be deleted"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# check if the issue type is being used in any issues
|
||||
if Issue.objects.filter(project_id=project_id, type_id=type_id).exists():
|
||||
return Response(
|
||||
{"error": "Work item type with existing issues cannot be deleted"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
issue_type.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1,12 +1,3 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import validate_email
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
@@ -18,7 +9,7 @@ from drf_spectacular.utils import (
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.api.serializers import UserLiteSerializer
|
||||
from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember, Project
|
||||
from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember
|
||||
from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission
|
||||
from plane.utils.openapi import (
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
@@ -31,8 +22,6 @@ from plane.utils.openapi import (
|
||||
PROJECT_MEMBER_EXAMPLE,
|
||||
)
|
||||
|
||||
from plane.payment.bgtasks.member_sync_task import member_sync_task
|
||||
|
||||
|
||||
class WorkspaceMemberAPIEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkSpaceAdminPermission]
|
||||
@@ -85,7 +74,9 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace_members = WorkspaceMember.objects.filter(workspace__slug=slug).select_related("member")
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug
|
||||
).select_related("member")
|
||||
|
||||
# Get all the users with their roles
|
||||
users_with_roles = []
|
||||
@@ -111,7 +102,7 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="List of project members with their roles",
|
||||
response=UserLiteSerializer(many=True),
|
||||
response=UserLiteSerializer,
|
||||
examples=[PROJECT_MEMBER_EXAMPLE],
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
@@ -132,101 +123,15 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
|
||||
{"error": "Provided workspace does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the workspace members that are present inside the workspace
|
||||
project_members = ProjectMember.objects.filter(project_id=project_id, workspace__slug=slug).values_list(
|
||||
"member_id", flat=True
|
||||
)
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).values_list("member_id", flat=True)
|
||||
|
||||
# Get all the users that are present inside the workspace
|
||||
users = UserLiteSerializer(User.objects.filter(id__in=project_members), many=True).data
|
||||
users = UserLiteSerializer(
|
||||
User.objects.filter(id__in=project_members), many=True
|
||||
).data
|
||||
|
||||
return Response(users, status=status.HTTP_200_OK)
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
# ------------------- Validation -------------------
|
||||
if (
|
||||
request.data.get("email") is None
|
||||
or request.data.get("display_name") is None
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Expected email, display_name, workspace_slug, project_id, one or more of the fields are missing."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
email = request.data.get("email")
|
||||
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"error": "Invalid email provided"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.filter(slug=slug).first()
|
||||
project = Project.objects.filter(pk=project_id).first()
|
||||
|
||||
if not all([workspace, project]):
|
||||
return Response(
|
||||
{"error": "Provided workspace or project does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
workspace_member = None
|
||||
project_member = None
|
||||
|
||||
if user:
|
||||
# Check if user is part of the workspace
|
||||
workspace_member = WorkspaceMember.objects.filter(
|
||||
workspace=workspace, member=user
|
||||
).first()
|
||||
if workspace_member:
|
||||
# Check if user is part of the project
|
||||
project_member = ProjectMember.objects.filter(
|
||||
project=project, member=user
|
||||
).first()
|
||||
if project_member:
|
||||
return Response(
|
||||
{"error": "User is already part of the workspace and project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# If user does not exist, create the user
|
||||
if not user:
|
||||
user = User.objects.create(
|
||||
email=email,
|
||||
display_name=request.data.get("display_name"),
|
||||
first_name=request.data.get("first_name", ""),
|
||||
last_name=request.data.get("last_name", ""),
|
||||
username=uuid.uuid4().hex,
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
is_active=False,
|
||||
avatar_asset_id=request.data.get("avatar_asset_id", None),
|
||||
)
|
||||
user.save()
|
||||
|
||||
# Create a workspace member for the user if not already a member
|
||||
if not workspace_member:
|
||||
workspace_member = WorkspaceMember.objects.create(
|
||||
workspace=workspace, member=user, role=request.data.get("role", 5)
|
||||
)
|
||||
workspace_member.save()
|
||||
|
||||
# Create a project member for the user if not already a member
|
||||
if not project_member:
|
||||
project_member = ProjectMember.objects.create(
|
||||
project=project, member=user, role=request.data.get("role", 5)
|
||||
)
|
||||
project_member.save()
|
||||
|
||||
# Run the member sync task for the workspace
|
||||
member_sync_task.delay(workspace.slug)
|
||||
|
||||
# Serialize the user and return the response
|
||||
user_data = UserLiteSerializer(user).data
|
||||
|
||||
return Response(user_data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import (
|
||||
@@ -41,6 +41,8 @@ from plane.utils.host import base_host
|
||||
from plane.utils.openapi import (
|
||||
module_docs,
|
||||
module_issue_docs,
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
MODULE_ID_PARAMETER,
|
||||
MODULE_PK_PARAMETER,
|
||||
ISSUE_ID_PARAMETER,
|
||||
@@ -265,21 +267,6 @@ class ModuleListCreateAPIEndpoint(BaseAPIView):
|
||||
Retrieve all modules in a project or get details of a specific module.
|
||||
Returns paginated results with module statistics and member information.
|
||||
"""
|
||||
external_id = request.GET.get("external_id")
|
||||
external_source = request.GET.get("external_source")
|
||||
|
||||
if external_id and external_source:
|
||||
module = Module.objects.get(
|
||||
external_id=external_id,
|
||||
external_source=external_source,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
return Response(
|
||||
ModuleSerializer(module, fields=self.fields, expand=self.expand).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset().filter(archived_at__isnull=True)),
|
||||
@@ -409,7 +396,9 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
|
||||
examples=[MODULE_UPDATE_EXAMPLE],
|
||||
),
|
||||
404: OpenApiResponse(description="Module not found"),
|
||||
409: OpenApiResponse(description="Module with same external ID already exists"),
|
||||
409: OpenApiResponse(
|
||||
description="Module with same external ID already exists"
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, request, slug, project_id, pk):
|
||||
@@ -420,14 +409,18 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
|
||||
|
||||
current_instance = json.dumps(ModuleSerializer(module).data, cls=DjangoJSONEncoder)
|
||||
current_instance = json.dumps(
|
||||
ModuleSerializer(module).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
if module.archived_at:
|
||||
return Response(
|
||||
{"error": "Archived module cannot be edited"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True)
|
||||
serializer = ModuleSerializer(
|
||||
module, data=request.data, context={"project_id": project_id}, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -435,7 +428,9 @@ class ModuleDetailAPIEndpoint(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()
|
||||
):
|
||||
@@ -521,7 +516,9 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
module_issues = list(ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True))
|
||||
module_issues = list(
|
||||
ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
@@ -542,7 +539,9 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
|
||||
# Delete the module issues
|
||||
ModuleIssue.objects.filter(module=pk, project_id=project_id).delete()
|
||||
# Delete the user favorite module
|
||||
UserFavorite.objects.filter(entity_type="module", entity_identifier=pk, project_id=project_id).delete()
|
||||
UserFavorite.objects.filter(
|
||||
entity_type="module", entity_identifier=pk, project_id=project_id
|
||||
).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -612,7 +611,9 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(issue_module__module_id=module_id, issue_module__deleted_at__isnull=True)
|
||||
Issue.issue_objects.filter(
|
||||
issue_module__module_id=module_id, issue_module__deleted_at__isnull=True
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -648,13 +649,15 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issues),
|
||||
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
|
||||
on_results=lambda issues: IssueSerializer(
|
||||
issues, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@module_issue_docs(
|
||||
operation_id="add_module_work_items",
|
||||
summary="Add Work Items to Module",
|
||||
description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.", # noqa: E501
|
||||
description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.",
|
||||
parameters=[
|
||||
MODULE_ID_PARAMETER,
|
||||
],
|
||||
@@ -665,7 +668,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Module issues added",
|
||||
response=ModuleIssueSerializer(many=True),
|
||||
response=ModuleIssueSerializer,
|
||||
examples=[MODULE_ISSUE_EXAMPLE],
|
||||
),
|
||||
400: REQUIRED_FIELDS_RESPONSE,
|
||||
@@ -680,13 +683,17 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
issues = request.data.get("issues", [])
|
||||
if not len(issues):
|
||||
return Response({"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=module_id)
|
||||
|
||||
issues = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issues).values_list(
|
||||
"id", flat=True
|
||||
return Response(
|
||||
{"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
module = Module.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=module_id
|
||||
)
|
||||
|
||||
issues = Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk__in=issues
|
||||
).values_list("id", flat=True)
|
||||
|
||||
module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
|
||||
|
||||
update_module_issue_activity = []
|
||||
@@ -694,7 +701,11 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
record_to_create = []
|
||||
|
||||
for issue in issues:
|
||||
module_issue = [module_issue for module_issue in module_issues if str(module_issue.issue_id) in issues]
|
||||
module_issue = [
|
||||
module_issue
|
||||
for module_issue in module_issues
|
||||
if str(module_issue.issue_id) in issues
|
||||
]
|
||||
|
||||
if len(module_issue):
|
||||
if module_issue[0].module_id != module_id:
|
||||
@@ -719,7 +730,9 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
|
||||
ModuleIssue.objects.bulk_create(record_to_create, batch_size=10, ignore_conflicts=True)
|
||||
ModuleIssue.objects.bulk_create(
|
||||
record_to_create, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
ModuleIssue.objects.bulk_update(records_to_update, ["module"], batch_size=10)
|
||||
|
||||
@@ -733,7 +746,9 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_module_issues": update_module_issue_activity,
|
||||
"created_module_issues": serializers.serialize("json", record_to_create),
|
||||
"created_module_issues": serializers.serialize(
|
||||
"json", record_to_create
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
@@ -858,7 +873,9 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issues),
|
||||
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
|
||||
on_results=lambda issues: IssueSerializer(
|
||||
issues, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@module_issue_docs(
|
||||
@@ -889,7 +906,9 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
|
||||
module_issue.delete()
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps({"module_id": str(module_id), "issues": [str(module_issue.issue_id)]}),
|
||||
requested_data=json.dumps(
|
||||
{"module_id": str(module_id), "issues": [str(module_issue.issue_id)]}
|
||||
),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
|
||||
|
||||
|
||||
# Module imports
|
||||
@@ -79,7 +79,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
| Q(network=2)
|
||||
)
|
||||
.select_related("workspace", "workspace__owner", "default_assignee", "project_lead")
|
||||
.select_related(
|
||||
"workspace", "workspace__owner", "default_assignee", "project_lead"
|
||||
)
|
||||
.annotate(
|
||||
is_member=Exists(
|
||||
ProjectMember.objects.filter(
|
||||
@@ -156,20 +158,6 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
Retrieve all projects in a workspace or get details of a specific project.
|
||||
Returns projects ordered by user's custom sort order with member information.
|
||||
"""
|
||||
external_id = request.GET.get("external_id")
|
||||
external_source = request.GET.get("external_source")
|
||||
|
||||
if external_id and external_source:
|
||||
project = Project.objects.get(
|
||||
external_id=external_id,
|
||||
external_source=external_source,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
return Response(
|
||||
ProjectSerializer(project, fields=self.fields, expand=self.expand).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
sort_order_query = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
@@ -182,9 +170,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"project_projectmember",
|
||||
queryset=ProjectMember.objects.filter(workspace__slug=slug, is_active=True).select_related(
|
||||
"member"
|
||||
),
|
||||
queryset=ProjectMember.objects.filter(
|
||||
workspace__slug=slug, is_active=True
|
||||
).select_related("member"),
|
||||
)
|
||||
)
|
||||
.order_by(request.GET.get("order_by", "sort_order"))
|
||||
@@ -223,39 +211,24 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id})
|
||||
serializer = ProjectCreateSerializer(
|
||||
data={**request.data}, context={"workspace_id": workspace.id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and Project.objects.filter(
|
||||
external_id=request.data.get("external_id"),
|
||||
external_source=request.data.get("external_source"),
|
||||
workspace__slug=slug,
|
||||
).exists()
|
||||
):
|
||||
project = Project.objects.filter(
|
||||
external_id=request.data.get("external_id"),
|
||||
external_source=request.data.get("external_source"),
|
||||
workspace__slug=slug,
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Project with the same external id and external source already exists",
|
||||
"id": str(project.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
|
||||
# Add the user as Administrator to the project
|
||||
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)
|
||||
_ = ProjectMember.objects.create(
|
||||
project_id=serializer.instance.id, member=request.user, role=20
|
||||
)
|
||||
# Also create the issue property for the user
|
||||
_ = IssueUserProperty.objects.create(project_id=serializer.instance.id, user=request.user)
|
||||
_ = IssueUserProperty.objects.create(
|
||||
project_id=serializer.instance.id, user=request.user
|
||||
)
|
||||
|
||||
if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str(
|
||||
request.user.id
|
||||
):
|
||||
if serializer.instance.project_lead is not None and str(
|
||||
serializer.instance.project_lead
|
||||
) != str(request.user.id):
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.instance.id,
|
||||
member_id=serializer.instance.project_lead,
|
||||
@@ -341,7 +314,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response({"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(
|
||||
{"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
@@ -369,7 +344,9 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
| Q(network=2)
|
||||
)
|
||||
.select_related("workspace", "workspace__owner", "default_assignee", "project_lead")
|
||||
.select_related(
|
||||
"workspace", "workspace__owner", "default_assignee", "project_lead"
|
||||
)
|
||||
.annotate(
|
||||
is_member=Exists(
|
||||
ProjectMember.objects.filter(
|
||||
@@ -474,7 +451,9 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
project = Project.objects.get(pk=pk)
|
||||
current_instance = json.dumps(ProjectSerializer(project).data, cls=DjangoJSONEncoder)
|
||||
current_instance = json.dumps(
|
||||
ProjectSerializer(project).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
intake_view = request.data.get("intake_view", project.intake_view)
|
||||
|
||||
@@ -492,27 +471,11 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
# don't allow external id and external source to be changed
|
||||
# if they are already set for an existing project
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and Project.objects.filter(
|
||||
external_id=request.data.get("external_id"),
|
||||
external_source=request.data.get("external_source"),
|
||||
workspace__slug=slug,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Project with the same external id and external source already exists",
|
||||
"id": str(project.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
if serializer.data["intake_view"]:
|
||||
intake = Intake.objects.filter(project=project, is_default=True).first()
|
||||
intake = Intake.objects.filter(
|
||||
project=project, is_default=True
|
||||
).first()
|
||||
if not intake:
|
||||
Intake.objects.create(
|
||||
name=f"{project.name} Intake",
|
||||
@@ -542,7 +505,9 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except (Project.DoesNotExist, Workspace.DoesNotExist):
|
||||
return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(
|
||||
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
@@ -568,7 +533,9 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
project = Project.objects.get(pk=pk, workspace__slug=slug)
|
||||
# Delete the user favorite cycle
|
||||
UserFavorite.objects.filter(entity_type="project", entity_identifier=pk, project_id=pk).delete()
|
||||
UserFavorite.objects.filter(
|
||||
entity_type="project", entity_identifier=pk, project_id=pk
|
||||
).delete()
|
||||
project.delete()
|
||||
webhook_activity.delay(
|
||||
event="project",
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.db import IntegrityError
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import StateSerializer
|
||||
@@ -80,7 +80,9 @@ class StateListCreateAPIEndpoint(BaseAPIView):
|
||||
Supports external ID tracking for integration purposes.
|
||||
"""
|
||||
try:
|
||||
serializer = StateSerializer(data=request.data, context={"project_id": project_id})
|
||||
serializer = StateSerializer(
|
||||
data=request.data, context={"project_id": project_id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -148,25 +150,12 @@ class StateListCreateAPIEndpoint(BaseAPIView):
|
||||
Retrieve all workflow states for a project.
|
||||
Returns paginated results when listing all states.
|
||||
"""
|
||||
|
||||
external_id = request.GET.get("external_id")
|
||||
external_source = request.GET.get("external_source")
|
||||
|
||||
if external_id and external_source:
|
||||
state = State.objects.get(
|
||||
external_id=external_id,
|
||||
external_source=external_source,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
return Response(
|
||||
StateSerializer(state, fields=self.fields, expand=self.expand).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda states: StateSerializer(states, many=True, fields=self.fields, expand=self.expand).data,
|
||||
on_results=lambda states: StateSerializer(
|
||||
states, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
|
||||
@@ -224,7 +213,7 @@ class StateDetailAPIEndpoint(BaseAPIView):
|
||||
@state_docs(
|
||||
operation_id="delete_state",
|
||||
summary="Delete state",
|
||||
description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.", # noqa: E501
|
||||
description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.",
|
||||
parameters=[
|
||||
STATE_ID_PARAMETER,
|
||||
],
|
||||
@@ -239,7 +228,9 @@ class StateDetailAPIEndpoint(BaseAPIView):
|
||||
Permanently remove a workflow state from a project.
|
||||
Default states and states with existing work items cannot be deleted.
|
||||
"""
|
||||
state = State.objects.get(is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug)
|
||||
state = State.objects.get(
|
||||
is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
if state.default:
|
||||
return Response(
|
||||
@@ -286,7 +277,9 @@ class StateDetailAPIEndpoint(BaseAPIView):
|
||||
Partially update an existing workflow state's properties like name, color, or group.
|
||||
Validates external ID uniqueness if provided.
|
||||
"""
|
||||
state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id)
|
||||
state = State.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=state_id
|
||||
)
|
||||
serializer = StateSerializer(state, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
@@ -295,7 +288,9 @@ class StateDetailAPIEndpoint(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()
|
||||
):
|
||||
|
||||
@@ -10,6 +10,7 @@ from plane.db.models import User
|
||||
from plane.utils.openapi.decorators import user_docs
|
||||
from plane.utils.openapi import USER_EXAMPLE
|
||||
|
||||
|
||||
class UserEndpoint(BaseAPIView):
|
||||
serializer_class = UserLiteSerializer
|
||||
model = User
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
|
||||
|
||||
class BaseSessionAuthentication(SessionAuthentication):
|
||||
# Disable csrf for the rest apis
|
||||
def enforce_csrf(self, request):
|
||||
return
|
||||
@@ -13,4 +13,3 @@ from .project import (
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from .base import allow_permission, ROLE
|
||||
from .page import ProjectPagePermission
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
# Python imports
|
||||
from enum import Enum
|
||||
from plane.db.models import WorkspaceMember, ProjectMember
|
||||
from functools import wraps
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import WorkspaceMember, ProjectMember
|
||||
from plane.ee.models import TeamspaceProject, TeamspaceMember
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ROLE(Enum):
|
||||
@@ -19,24 +12,22 @@ class ROLE(Enum):
|
||||
GUEST = 5
|
||||
|
||||
|
||||
def allow_permission(
|
||||
allowed_roles,
|
||||
level="PROJECT",
|
||||
creator=False,
|
||||
field="created_by",
|
||||
model=None,
|
||||
):
|
||||
def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(instance, request, *args, **kwargs):
|
||||
# Check for ownership if required
|
||||
# Check for creator if required
|
||||
if creator and model:
|
||||
obj = model.objects.filter(id=kwargs["pk"], **{field: request.user}).exists()
|
||||
obj = model.objects.filter(
|
||||
id=kwargs["pk"], created_by=request.user
|
||||
).exists()
|
||||
if obj:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
# Convert allowed_roles to their values if they are enum members
|
||||
allowed_role_values = [role.value if isinstance(role, ROLE) else role for role in allowed_roles]
|
||||
allowed_role_values = [
|
||||
role.value if isinstance(role, ROLE) else role for role in allowed_roles
|
||||
]
|
||||
|
||||
# Check role permissions
|
||||
if level == "WORKSPACE":
|
||||
@@ -48,55 +39,14 @@ def allow_permission(
|
||||
).exists():
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
else:
|
||||
is_user_has_allowed_role = ProjectMember.objects.filter(
|
||||
if ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
role__in=allowed_role_values,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
# Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role # noqa: E501
|
||||
if is_user_has_allowed_role:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
# Return if the user is workspace admin and is also part of the project or a teamspace member
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists():
|
||||
teamspace_ids = TeamspaceProject.objects.filter(
|
||||
workspace__slug=kwargs["slug"], project_id=kwargs["project_id"]
|
||||
).values_list("team_space_id", flat=True)
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
is_active=True,
|
||||
).exists()
|
||||
or TeamspaceMember.objects.filter(member=request.user, team_space_id__in=teamspace_ids).exists()
|
||||
):
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
#
|
||||
# Check if the user is member of the team space
|
||||
# if scope is project further check if user is member of the team space
|
||||
# only if member is present in allowed roles
|
||||
#
|
||||
if ROLE.MEMBER.value in allowed_role_values and check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.TEAMSPACES,
|
||||
slug=kwargs["slug"],
|
||||
user_id=request.user.id,
|
||||
):
|
||||
teamspace_ids = TeamspaceProject.objects.filter(
|
||||
workspace__slug=kwargs["slug"], project_id=kwargs["project_id"]
|
||||
).values_list("team_space_id", flat=True)
|
||||
|
||||
if TeamspaceMember.objects.filter(member=request.user, team_space_id__in=teamspace_ids).exists():
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
# Return permission denied if no conditions are met
|
||||
return Response(
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
from plane.db.models import ProjectMember, Page
|
||||
from plane.app.permissions import ROLE
|
||||
|
||||
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
|
||||
|
||||
# Permission Mappings for workspace members
|
||||
ADMIN = ROLE.ADMIN.value
|
||||
MEMBER = ROLE.MEMBER.value
|
||||
GUEST = ROLE.GUEST.value
|
||||
|
||||
|
||||
class ProjectPagePermission(BasePermission):
|
||||
"""
|
||||
Custom permission to control access to pages within a workspace
|
||||
based on user roles, page visibility (public/private), and feature flags.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Check basic project-level permissions before checking object-level permissions.
|
||||
"""
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
user_id = request.user.id
|
||||
slug = view.kwargs.get("slug")
|
||||
page_id = view.kwargs.get("page_id")
|
||||
project_id = view.kwargs.get("project_id")
|
||||
|
||||
# Hook for extended validation
|
||||
extended_access, role = self._check_access_and_get_role(request, slug, project_id)
|
||||
if extended_access is False:
|
||||
return False
|
||||
|
||||
if page_id:
|
||||
page = Page.objects.get(id=page_id, workspace__slug=slug)
|
||||
|
||||
# Allow access if the user is the owner of the page
|
||||
if page.owned_by_id == user_id:
|
||||
return True
|
||||
|
||||
# Handle private page access
|
||||
if page.access == Page.PRIVATE_ACCESS:
|
||||
return self._has_private_page_action_access(request, slug, page, project_id)
|
||||
|
||||
# Handle public page access
|
||||
return self._has_public_page_action_access(request, role)
|
||||
|
||||
def _check_project_member_access(self, request, slug, project_id):
|
||||
"""
|
||||
Check if the user is a project member.
|
||||
"""
|
||||
return (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
project_id=project_id,
|
||||
)
|
||||
.values_list("role", flat=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
def _check_access_and_get_role(self, request, slug, project_id):
|
||||
"""
|
||||
Hook for extended access checking
|
||||
Returns: True (allow), False (deny), None (continue with normal flow)
|
||||
"""
|
||||
role = self._check_project_member_access(request, slug, project_id)
|
||||
if not role:
|
||||
return False, None
|
||||
return True, role
|
||||
|
||||
def _has_private_page_action_access(self, request, slug, page, project_id):
|
||||
"""
|
||||
Check access to private pages. Override for feature flag logic.
|
||||
"""
|
||||
# Base implementation: only owner can access private pages
|
||||
return False
|
||||
|
||||
def _check_project_action_access(self, request, role):
|
||||
method = request.method
|
||||
|
||||
# Only admins can create (POST) pages
|
||||
if method == "POST":
|
||||
if role in [ADMIN, MEMBER]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Safe methods (GET, HEAD, OPTIONS) allowed for all active roles
|
||||
if method in SAFE_METHODS:
|
||||
if role in [ADMIN, MEMBER, GUEST]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# PUT/PATCH: Admins and members can update
|
||||
if method in ["PUT", "PATCH"]:
|
||||
if role in [ADMIN, MEMBER]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# DELETE: Only admins can delete
|
||||
if method == "DELETE":
|
||||
if role in [ADMIN]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Deny by default
|
||||
return False
|
||||
|
||||
def _has_public_page_action_access(self, request, role):
|
||||
"""
|
||||
Check if the user has permission to access a public page
|
||||
and can perform operations on the page.
|
||||
"""
|
||||
project_member_exists = self._check_project_action_access(request, role)
|
||||
if not project_member_exists:
|
||||
return False
|
||||
return True
|
||||
@@ -1,60 +1,17 @@
|
||||
# Third Party imports
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
from rest_framework.request import Request
|
||||
|
||||
# Module import
|
||||
from plane.db.models import ProjectMember, WorkspaceMember
|
||||
from plane.ee.models import TeamspaceProject, TeamspaceMember
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
from plane.db.models.project import ROLE
|
||||
|
||||
|
||||
def check_teamspace_membership(view, request: Request) -> bool:
|
||||
"""
|
||||
Check if the user is a member of any teamspace associated with the project.
|
||||
|
||||
Args:
|
||||
view: The view instance containing workspace_slug and project_id
|
||||
request (Request): The incoming request object containing user information
|
||||
|
||||
Returns:
|
||||
bool: True if user is a member of any associated teamspace, False otherwise
|
||||
|
||||
Note:
|
||||
This function first checks if teamspaces feature is enabled for the workspace
|
||||
before performing the membership check.
|
||||
"""
|
||||
# check the user is part of the teamspace if the project is attached to any.
|
||||
if check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.TEAMSPACES,
|
||||
slug=view.workspace_slug,
|
||||
user_id=request.user.id,
|
||||
):
|
||||
## Get all the teamspace ids that the project is attached to.
|
||||
teamspace_ids = TeamspaceProject.objects.filter(
|
||||
workspace__slug=view.workspace_slug, project_id=view.project_id
|
||||
).values_list("team_space_id", flat=True)
|
||||
|
||||
# return True if the user is a member of any of the teamspace
|
||||
return TeamspaceMember.objects.filter(
|
||||
member=request.user, team_space_id__in=teamspace_ids
|
||||
).exists()
|
||||
return False
|
||||
# Permission Mappings
|
||||
Admin = 20
|
||||
Member = 15
|
||||
Guest = 5
|
||||
|
||||
|
||||
class ProjectBasePermission(BasePermission):
|
||||
"""
|
||||
Base permission class for project-related operations.
|
||||
|
||||
This class implements basic permission checks for project access:
|
||||
- Anonymous users are denied access
|
||||
- READ operations require workspace membership
|
||||
- CREATE operations require workspace admin/member role
|
||||
- UPDATE operations require project admin role
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
@@ -69,43 +26,22 @@ class ProjectBasePermission(BasePermission):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
role__in=[Admin, Member],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
project_member_qs = ProjectMember.objects.filter(
|
||||
## Only Project Admins can update project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role=Admin,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
## Only project admins or workspace admin who is part of the project can access
|
||||
|
||||
if project_member_qs.filter(role=ROLE.ADMIN.value).exists():
|
||||
return True
|
||||
else:
|
||||
return (
|
||||
project_member_qs.exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
)
|
||||
).exists()
|
||||
|
||||
|
||||
class ProjectMemberPermission(BasePermission):
|
||||
"""
|
||||
Permission class for project member operations.
|
||||
|
||||
Extends the base permissions with additional checks:
|
||||
- Allows teamspace members access if the project is associated with their teamspace
|
||||
- Provides different permission levels for different operations
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
@@ -119,106 +55,62 @@ class ProjectMemberPermission(BasePermission):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
role__in=[Admin, Member],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Only Project Admins can update project attributes
|
||||
is_project_member = ProjectMember.objects.filter(
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
role__in=[Admin, Member],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
# If the user is already an admin or member return True
|
||||
if is_project_member:
|
||||
return True
|
||||
|
||||
# check the user is part of the teamspace if the project is attached to any.
|
||||
return check_teamspace_membership(view=view, request=request)
|
||||
|
||||
|
||||
class ProjectEntityPermission(BasePermission):
|
||||
"""
|
||||
Permission class for project entity operations.
|
||||
|
||||
Handles permissions for project-related entities with additional features:
|
||||
- Supports project identification by identifier
|
||||
- Implements teamspace-based access control
|
||||
- Different permission levels for read/write operations
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
# Handle requests based on project__identifier
|
||||
if hasattr(view, "project_identifier") and view.project_identifier:
|
||||
if request.method in SAFE_METHODS:
|
||||
is_project_member = ProjectMember.objects.filter(
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project__identifier=view.project_identifier,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
if is_project_member:
|
||||
return True
|
||||
else:
|
||||
return check_teamspace_membership(view=view, request=request)
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
is_project_member = ProjectMember.objects.filter(
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
if is_project_member:
|
||||
return True
|
||||
else:
|
||||
return check_teamspace_membership(view=view, request=request)
|
||||
|
||||
## Only project members or admins can create and edit the project attributes
|
||||
is_project_member = ProjectMember.objects.filter(
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
role__in=[Admin, Member],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
if is_project_member:
|
||||
return True
|
||||
else:
|
||||
return check_teamspace_membership(view=view, request=request)
|
||||
|
||||
|
||||
class ProjectLitePermission(BasePermission):
|
||||
"""
|
||||
Lightweight permission class for basic project access control.
|
||||
|
||||
Implements simplified permission checks:
|
||||
- Verifies project membership
|
||||
- Falls back to teamspace membership check
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
is_project_member = ProjectMember.objects.filter(
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
if is_project_member:
|
||||
return True
|
||||
else:
|
||||
return check_teamspace_membership(view=view, request=request)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .base import BaseSerializer
|
||||
from .user import (
|
||||
UserSerializer,
|
||||
UserLiteSerializer,
|
||||
@@ -23,7 +23,6 @@ from .workspace import (
|
||||
WorkspaceRecentVisitSerializer,
|
||||
WorkspaceHomePreferenceSerializer,
|
||||
StickySerializer,
|
||||
WorkspaceUserMeSerializer,
|
||||
)
|
||||
from .project import (
|
||||
ProjectSerializer,
|
||||
@@ -34,6 +33,7 @@ from .project import (
|
||||
ProjectIdentifierSerializer,
|
||||
ProjectLiteSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
DeployBoardSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
ProjectPublicMemberSerializer,
|
||||
ProjectMemberRoleSerializer,
|
||||
@@ -45,7 +45,6 @@ from .cycle import (
|
||||
CycleIssueSerializer,
|
||||
CycleWriteSerializer,
|
||||
CycleUserPropertiesSerializer,
|
||||
EntityProgressSerializer,
|
||||
)
|
||||
from .asset import FileAssetSerializer
|
||||
from .issue import (
|
||||
@@ -76,7 +75,6 @@ from .issue import (
|
||||
IssueVersionDetailSerializer,
|
||||
IssueDescriptionVersionDetailSerializer,
|
||||
IssueListDetailSerializer,
|
||||
IssueDuplicateSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
@@ -94,12 +92,12 @@ from .importer import ImporterSerializer
|
||||
|
||||
from .page import (
|
||||
PageSerializer,
|
||||
PageLiteSerializer,
|
||||
PageLogSerializer,
|
||||
SubPageSerializer,
|
||||
PageDetailSerializer,
|
||||
PageVersionSerializer,
|
||||
PageBinaryUpdateSerializer,
|
||||
PageVersionDetailSerializer,
|
||||
PageUserSerializer,
|
||||
)
|
||||
|
||||
from .estimate import (
|
||||
@@ -132,14 +130,3 @@ from .draft import (
|
||||
DraftIssueSerializer,
|
||||
DraftIssueDetailSerializer,
|
||||
)
|
||||
from .integration import (
|
||||
IntegrationSerializer,
|
||||
WorkspaceIntegrationSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
SlackProjectSyncSerializer,
|
||||
)
|
||||
|
||||
from .deploy_board import DeployBoardSerializer
|
||||
|
||||
@@ -3,7 +3,6 @@ from rest_framework import serializers
|
||||
|
||||
class BaseSerializer(serializers.ModelSerializer):
|
||||
id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
deleted_at = serializers.ReadOnlyField()
|
||||
|
||||
|
||||
class DynamicBaseSerializer(BaseSerializer):
|
||||
@@ -169,9 +168,13 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
# Check if field in expansion then expand the field
|
||||
if expand in expansion:
|
||||
if isinstance(response.get(expand), list):
|
||||
exp_serializer = expansion[expand](getattr(instance, expand), many=True)
|
||||
exp_serializer = expansion[expand](
|
||||
getattr(instance, expand), many=True
|
||||
)
|
||||
else:
|
||||
exp_serializer = expansion[expand](getattr(instance, expand))
|
||||
exp_serializer = expansion[expand](
|
||||
getattr(instance, expand)
|
||||
)
|
||||
response[expand] = exp_serializer.data
|
||||
else:
|
||||
# You might need to handle this case differently
|
||||
@@ -191,7 +194,9 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
# Serialize issue_attachments and add them to the response
|
||||
response["issue_attachments"] = IssueAttachmentLiteSerializer(issue_attachments, many=True).data
|
||||
response["issue_attachments"] = IssueAttachmentLiteSerializer(
|
||||
issue_attachments, many=True
|
||||
).data
|
||||
else:
|
||||
response["issue_attachments"] = []
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@ from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
|
||||
from .issue import IssueStateSerializer
|
||||
from plane.db.models import Cycle, CycleIssue, CycleUserProperties
|
||||
from plane.utils.timezone_converter import convert_to_utc
|
||||
from plane.ee.models import EntityProgress
|
||||
|
||||
|
||||
class CycleWriteSerializer(BaseSerializer):
|
||||
@@ -18,7 +16,10 @@ class CycleWriteSerializer(BaseSerializer):
|
||||
and data.get("start_date", None) > data.get("end_date", None)
|
||||
):
|
||||
raise serializers.ValidationError("Start date cannot exceed end date")
|
||||
if data.get("start_date", None) is not None and data.get("end_date", None) is not None:
|
||||
if (
|
||||
data.get("start_date", None) is not None
|
||||
and data.get("end_date", None) is not None
|
||||
):
|
||||
project_id = (
|
||||
self.initial_data.get("project_id", None)
|
||||
or (self.instance and self.instance.project_id)
|
||||
@@ -38,13 +39,7 @@ class CycleWriteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Cycle
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"owned_by",
|
||||
"archived_at",
|
||||
"version",
|
||||
]
|
||||
read_only_fields = ["workspace", "project", "owned_by", "archived_at"]
|
||||
|
||||
|
||||
class CycleSerializer(BaseSerializer):
|
||||
@@ -108,9 +103,3 @@ class CycleUserPropertiesSerializer(BaseSerializer):
|
||||
model = CycleUserProperties
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "cycle", "user"]
|
||||
|
||||
|
||||
class EntityProgressSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = EntityProgress
|
||||
fields = "__all__"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.app.serializers.project import ProjectLiteSerializer
|
||||
from plane.app.serializers.workspace import WorkspaceLiteSerializer
|
||||
from plane.db.models import DeployBoard
|
||||
|
||||
|
||||
class DeployBoardSerializer(BaseSerializer):
|
||||
project_details = ProjectLiteSerializer(read_only=True, source="project")
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
|
||||
class Meta:
|
||||
model = DeployBoard
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "anchor"]
|
||||
@@ -1,3 +1,5 @@
|
||||
from lxml import html
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -12,7 +14,6 @@ from plane.db.models import (
|
||||
Label,
|
||||
State,
|
||||
DraftIssue,
|
||||
IssueType,
|
||||
DraftIssueAssignee,
|
||||
DraftIssueLabel,
|
||||
DraftIssueCycle,
|
||||
@@ -35,9 +36,6 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
parent_id = serializers.PrimaryKeyRelatedField(
|
||||
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
type_id = serializers.PrimaryKeyRelatedField(
|
||||
source="type", queryset=IssueType.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
label_ids = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
|
||||
write_only=True,
|
||||
@@ -78,9 +76,13 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
|
||||
# Validate description content for security
|
||||
if "description_html" in attrs and attrs["description_html"]:
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(attrs["description_html"])
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
attrs["description_html"]
|
||||
)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"error": "html content is not valid"})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
# Update the attrs with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
attrs["description_html"] = sanitized_html
|
||||
@@ -88,7 +90,9 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
if "description_binary" in attrs and attrs["description_binary"]:
|
||||
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
|
||||
raise serializers.ValidationError(
|
||||
{"description_binary": "Invalid binary data"}
|
||||
)
|
||||
|
||||
# Validate assignees are from project
|
||||
if attrs.get("assignee_ids", []):
|
||||
@@ -103,9 +107,9 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
if attrs.get("label_ids"):
|
||||
label_ids = [label.id for label in attrs["label_ids"]]
|
||||
attrs["label_ids"] = list(
|
||||
Label.objects.filter(project_id=self.context.get("project_id"), id__in=label_ids).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
Label.objects.filter(
|
||||
project_id=self.context.get("project_id"), id__in=label_ids
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
# # Check state is from the project only else raise validation error
|
||||
@@ -116,7 +120,9 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
pk=attrs.get("state").id,
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError("State is not valid please pass a valid state_id")
|
||||
raise serializers.ValidationError(
|
||||
"State is not valid please pass a valid state_id"
|
||||
)
|
||||
|
||||
# # Check parent issue is from workspace as it can be cross workspace
|
||||
if (
|
||||
@@ -126,7 +132,9 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
pk=attrs.get("parent").id,
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id")
|
||||
raise serializers.ValidationError(
|
||||
"Parent is not valid issue_id please pass a valid issue_id"
|
||||
)
|
||||
|
||||
if (
|
||||
attrs.get("estimate_point")
|
||||
@@ -135,7 +143,9 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
pk=attrs.get("estimate_point").id,
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id")
|
||||
raise serializers.ValidationError(
|
||||
"Estimate point is not valid please pass a valid estimate_point_id"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -149,20 +159,9 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
workspace_id = self.context["workspace_id"]
|
||||
project_id = self.context["project_id"]
|
||||
|
||||
issue_type = validated_data.pop("type", None)
|
||||
|
||||
if not issue_type:
|
||||
# Get default issue type
|
||||
issue_type = IssueType.objects.filter(
|
||||
project_issue_types__project_id=project_id,
|
||||
is_epic=False,
|
||||
is_default=True,
|
||||
).first()
|
||||
issue_type = issue_type
|
||||
|
||||
# Create Issue
|
||||
issue = DraftIssue.objects.create(
|
||||
**validated_data, workspace_id=workspace_id, project_id=project_id, type=issue_type
|
||||
**validated_data, workspace_id=workspace_id, project_id=project_id
|
||||
)
|
||||
|
||||
# Issue Audit Users
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from plane.db.models import UserFavorite, Cycle, Module, Issue, IssueView, Page, Project
|
||||
from plane.ee.models import Dashboard
|
||||
|
||||
|
||||
class ProjectFavoriteLiteSerializer(serializers.ModelSerializer):
|
||||
@@ -18,7 +17,9 @@ class PageFavoriteLiteSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "name", "logo_props", "project_id"]
|
||||
|
||||
def get_project_id(self, obj):
|
||||
project = obj.projects.first() # This gets the first project related to the Page
|
||||
project = (
|
||||
obj.projects.first()
|
||||
) # This gets the first project related to the Page
|
||||
return project.id if project else None
|
||||
|
||||
|
||||
@@ -40,12 +41,6 @@ class ViewFavoriteSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "name", "logo_props", "project_id"]
|
||||
|
||||
|
||||
class DashboardFavoriteLiteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Dashboard
|
||||
fields = ["id", "name"]
|
||||
|
||||
|
||||
def get_entity_model_and_serializer(entity_type):
|
||||
entity_map = {
|
||||
"cycle": (Cycle, CycleFavoriteLiteSerializer),
|
||||
@@ -54,7 +49,6 @@ def get_entity_model_and_serializer(entity_type):
|
||||
"view": (IssueView, ViewFavoriteSerializer),
|
||||
"page": (Page, PageFavoriteLiteSerializer),
|
||||
"project": (Project, ProjectFavoriteLiteSerializer),
|
||||
"workspace_dashboard": (Dashboard, DashboardFavoriteLiteSerializer),
|
||||
"folder": (None, None),
|
||||
}
|
||||
return entity_map.get(entity_type, (None, None))
|
||||
|
||||
@@ -45,7 +45,9 @@ class IntakeIssueSerializer(BaseSerializer):
|
||||
|
||||
class IntakeIssueDetailSerializer(BaseSerializer):
|
||||
issue = IssueDetailSerializer(read_only=True)
|
||||
duplicate_issue_detail = IssueIntakeSerializer(read_only=True, source="duplicate_to")
|
||||
duplicate_issue_detail = IssueIntakeSerializer(
|
||||
read_only=True, source="duplicate_to"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IntakeIssue
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
|
||||
from .github import (
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
from .slack import SlackProjectSyncSerializer
|
||||
@@ -1,18 +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,36 +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,10 +0,0 @@
|
||||
# Module imports
|
||||
from plane.app.serializers import BaseSerializer
|
||||
from plane.db.models import SlackProjectSync
|
||||
|
||||
|
||||
class SlackProjectSyncSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = SlackProjectSync
|
||||
fields = "__all__"
|
||||
read_only_fields = ["project", "workspace", "workspace_integration"]
|
||||
@@ -1,3 +1,5 @@
|
||||
from lxml import html
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.validators import URLValidator
|
||||
@@ -38,15 +40,11 @@ from plane.db.models import (
|
||||
IssueDescriptionVersion,
|
||||
ProjectMember,
|
||||
EstimatePoint,
|
||||
IssueType,
|
||||
)
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_binary_data,
|
||||
)
|
||||
from plane.ee.models import Customer, TeamspaceProject, TeamspaceMember
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
class IssueFlatSerializer(BaseSerializer):
|
||||
@@ -65,7 +63,6 @@ class IssueFlatSerializer(BaseSerializer):
|
||||
"sequence_id",
|
||||
"sort_order",
|
||||
"is_draft",
|
||||
"type_id",
|
||||
]
|
||||
|
||||
|
||||
@@ -85,9 +82,6 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
state_id = serializers.PrimaryKeyRelatedField(
|
||||
source="state", queryset=State.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
type_id = serializers.PrimaryKeyRelatedField(
|
||||
source="type", queryset=IssueType.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
parent_id = serializers.PrimaryKeyRelatedField(
|
||||
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
@@ -134,9 +128,13 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
|
||||
# Validate description content for security
|
||||
if "description_html" in attrs and attrs["description_html"]:
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(attrs["description_html"])
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
attrs["description_html"]
|
||||
)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"error": "html content is not valid"})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
# Update the attrs with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
attrs["description_html"] = sanitized_html
|
||||
@@ -144,49 +142,18 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
if "description_binary" in attrs and attrs["description_binary"]:
|
||||
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
|
||||
raise serializers.ValidationError(
|
||||
{"description_binary": "Invalid binary data"}
|
||||
)
|
||||
|
||||
# Validate assignees are from project
|
||||
if attrs.get("assignee_ids", []):
|
||||
assignee_ids = attrs["assignee_ids"]
|
||||
if check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.TEAMSPACES,
|
||||
user_id=self.context["user_id"],
|
||||
slug=self.context["slug"],
|
||||
):
|
||||
# Then get all the teamspace members for the project with project member
|
||||
teamspace_ids = TeamspaceProject.objects.filter(
|
||||
project_id=self.context["project_id"],
|
||||
).values_list("team_space_id", flat=True)
|
||||
|
||||
teamspace_member_ids = list(
|
||||
TeamspaceMember.objects.filter(
|
||||
team_space_id__in=teamspace_ids,
|
||||
member_id__in=assignee_ids,
|
||||
).values_list("member_id", flat=True)
|
||||
)
|
||||
|
||||
project_member_ids = list(
|
||||
ProjectMember.objects.filter(
|
||||
project_id=self.context["project_id"],
|
||||
role__gte=15,
|
||||
is_active=True,
|
||||
member_id__in=assignee_ids,
|
||||
).values_list("member_id", flat=True)
|
||||
)
|
||||
|
||||
# Then get all the teamspace members for the project with project member
|
||||
attrs["assignee_ids"] = list(
|
||||
set(teamspace_member_ids + project_member_ids)
|
||||
)
|
||||
|
||||
else:
|
||||
attrs["assignee_ids"] = ProjectMember.objects.filter(
|
||||
project_id=self.context["project_id"],
|
||||
role__gte=15,
|
||||
is_active=True,
|
||||
member_id__in=assignee_ids,
|
||||
).values_list("member_id", flat=True)
|
||||
attrs["assignee_ids"] = ProjectMember.objects.filter(
|
||||
project_id=self.context["project_id"],
|
||||
role__gte=15,
|
||||
is_active=True,
|
||||
member_id__in=attrs["assignee_ids"],
|
||||
).values_list("member_id", flat=True)
|
||||
|
||||
# Validate labels are from project
|
||||
if attrs.get("label_ids"):
|
||||
@@ -206,7 +173,9 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
pk=attrs.get("state").id,
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError("State is not valid please pass a valid state_id")
|
||||
raise serializers.ValidationError(
|
||||
"State is not valid please pass a valid state_id"
|
||||
)
|
||||
|
||||
# Check parent issue is from workspace as it can be cross workspace
|
||||
if (
|
||||
@@ -216,7 +185,9 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
pk=attrs.get("parent").id,
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id")
|
||||
raise serializers.ValidationError(
|
||||
"Parent is not valid issue_id please pass a valid issue_id"
|
||||
)
|
||||
|
||||
if (
|
||||
attrs.get("estimate_point")
|
||||
@@ -225,43 +196,12 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
pk=attrs.get("estimate_point").id,
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id")
|
||||
raise serializers.ValidationError(
|
||||
"Estimate point is not valid please pass a valid estimate_point_id"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def _is_valid_assignee(self, assignee_id, project_id):
|
||||
"""
|
||||
Check if an assignee is valid for the project.
|
||||
Returns True if the assignee is either a project member or teamspace member (if enabled).
|
||||
""" # noqa: E501
|
||||
# Check if assignee is a valid project member
|
||||
is_project_member = ProjectMember.objects.filter(
|
||||
member_id=assignee_id,
|
||||
project_id=project_id,
|
||||
role__gte=15,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
if is_project_member:
|
||||
return True
|
||||
|
||||
# Check teamspace membership if feature is enabled
|
||||
if check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.TEAMSPACES,
|
||||
user_id=self.context["user_id"],
|
||||
slug=self.context["slug"],
|
||||
):
|
||||
teamspace_ids = TeamspaceProject.objects.filter(
|
||||
project_id=project_id,
|
||||
).values_list("team_space_id", flat=True)
|
||||
|
||||
return TeamspaceMember.objects.filter(
|
||||
member_id=assignee_id,
|
||||
team_space_id__in=teamspace_ids,
|
||||
).exists()
|
||||
|
||||
return False
|
||||
|
||||
def create(self, validated_data):
|
||||
assignees = validated_data.pop("assignee_ids", None)
|
||||
labels = validated_data.pop("label_ids", None)
|
||||
@@ -270,21 +210,8 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
workspace_id = self.context["workspace_id"]
|
||||
default_assignee_id = self.context["default_assignee_id"]
|
||||
|
||||
issue_type = validated_data.pop("type", None)
|
||||
|
||||
if not issue_type:
|
||||
# Get default issue type
|
||||
issue_type = IssueType.objects.filter(
|
||||
project_issue_types__project_id=project_id,
|
||||
is_epic=False,
|
||||
is_default=True,
|
||||
).first()
|
||||
issue_type = issue_type
|
||||
|
||||
# Create Issue
|
||||
issue = Issue.objects.create(
|
||||
**validated_data, project_id=project_id, type=issue_type
|
||||
)
|
||||
issue = Issue.objects.create(**validated_data, project_id=project_id)
|
||||
|
||||
# Issue Audit Users
|
||||
created_by_id = issue.created_by_id
|
||||
@@ -309,9 +236,15 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
# Assign to default assignee if valid
|
||||
if default_assignee_id is not None and self._is_valid_assignee(
|
||||
default_assignee_id, project_id
|
||||
# Then assign it to default assignee, if it is a valid assignee
|
||||
if (
|
||||
default_assignee_id is not None
|
||||
and ProjectMember.objects.filter(
|
||||
member_id=default_assignee_id,
|
||||
project_id=project_id,
|
||||
role__gte=15,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
try:
|
||||
IssueAssignee.objects.create(
|
||||
@@ -357,21 +290,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
updated_by_id = instance.updated_by_id
|
||||
|
||||
if assignees is not None:
|
||||
# Here because of validate the assignee_ids are list of uuids
|
||||
current_assignees = IssueAssignee.objects.filter(
|
||||
issue=instance
|
||||
).values_list("assignee_id", flat=True)
|
||||
|
||||
# Get the assignees to add and assignees to remove from both the current and the new assignees #noqa: E501
|
||||
assignees_to_add = list(set(assignees) - set(current_assignees))
|
||||
assignees_to_remove = list(set(current_assignees) - set(assignees))
|
||||
|
||||
# Delete the assignees to remove
|
||||
IssueAssignee.objects.filter(
|
||||
issue=instance, assignee_id__in=assignees_to_remove
|
||||
).delete()
|
||||
|
||||
# Create the assignees to add
|
||||
IssueAssignee.objects.filter(issue=instance).delete()
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
@@ -383,7 +302,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees_to_add
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
@@ -392,37 +311,19 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
pass
|
||||
|
||||
if labels is not None:
|
||||
# Here we get label instances as a list
|
||||
current_label_ids = list(
|
||||
IssueLabel.objects.filter(issue=instance).values_list(
|
||||
"label_id", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
requested_label_ids = labels
|
||||
|
||||
# Get the labels to add and labels to remove from both the current and the new labels #noqa: E501
|
||||
labels_to_add = list(set(requested_label_ids) - set(current_label_ids))
|
||||
labels_to_remove = list(set(current_label_ids) - set(requested_label_ids))
|
||||
|
||||
# Delete the labels to remove
|
||||
IssueLabel.objects.filter(
|
||||
issue=instance, label_id__in=labels_to_remove
|
||||
).delete()
|
||||
|
||||
# Create the labels to add
|
||||
IssueLabel.objects.filter(issue=instance).delete()
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label,
|
||||
label_id=label_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels_to_add
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
@@ -430,7 +331,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
# Time related updates occurs even when other related models are updated
|
||||
# Time updation occues even when other related models are updated
|
||||
instance.updated_at = timezone.now()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
@@ -443,7 +344,11 @@ class IssueActivitySerializer(BaseSerializer):
|
||||
source_data = serializers.SerializerMethodField()
|
||||
|
||||
def get_source_data(self, obj):
|
||||
if hasattr(obj, "issue") and hasattr(obj.issue, "source_data") and obj.issue.source_data:
|
||||
if (
|
||||
hasattr(obj, "issue")
|
||||
and hasattr(obj.issue, "source_data")
|
||||
and obj.issue.source_data
|
||||
):
|
||||
return {
|
||||
"source": obj.issue.source_data[0].source,
|
||||
"source_email": obj.issue.source_data[0].source_email,
|
||||
@@ -493,14 +398,14 @@ class IssueLabelSerializer(BaseSerializer):
|
||||
|
||||
class IssueRelationSerializer(BaseSerializer):
|
||||
id = serializers.UUIDField(source="related_issue.id", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(source="related_issue.project_id", read_only=True)
|
||||
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
|
||||
name = serializers.CharField(source="related_issue.name", read_only=True)
|
||||
type_id = serializers.UUIDField(source="related_issue.type.id", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
is_epic = serializers.BooleanField(
|
||||
source="related_issue.type.is_epic", read_only=True
|
||||
project_id = serializers.PrimaryKeyRelatedField(
|
||||
source="related_issue.project_id", read_only=True
|
||||
)
|
||||
sequence_id = serializers.IntegerField(
|
||||
source="related_issue.sequence_id", read_only=True
|
||||
)
|
||||
name = serializers.CharField(source="related_issue.name", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="related_issue.priority", read_only=True)
|
||||
assignee_ids = serializers.ListField(
|
||||
@@ -517,8 +422,6 @@ class IssueRelationSerializer(BaseSerializer):
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"type_id",
|
||||
"is_epic",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
@@ -539,12 +442,12 @@ class IssueRelationSerializer(BaseSerializer):
|
||||
|
||||
class RelatedIssueSerializer(BaseSerializer):
|
||||
id = serializers.UUIDField(source="issue.id", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(
|
||||
source="issue.project_id", read_only=True
|
||||
)
|
||||
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
|
||||
name = serializers.CharField(source="issue.name", read_only=True)
|
||||
type_id = serializers.UUIDField(source="issue.type.id", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
is_epic = serializers.BooleanField(source="issue.type.is_epic", read_only=True)
|
||||
state_id = serializers.UUIDField(source="issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="issue.priority", read_only=True)
|
||||
assignee_ids = serializers.ListField(
|
||||
@@ -561,8 +464,6 @@ class RelatedIssueSerializer(BaseSerializer):
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"type_id",
|
||||
"is_epic",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
@@ -581,19 +482,6 @@ class RelatedIssueSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class IssueDuplicateSerializer(BaseSerializer):
|
||||
sequence_id = serializers.IntegerField(
|
||||
source="related_issue.sequence_id", read_only=True
|
||||
)
|
||||
project_identifier = serializers.CharField(
|
||||
source="related_issue.project.identifier", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = ["id", "project_id", "sequence_id", "project_identifier"]
|
||||
|
||||
|
||||
class IssueAssigneeSerializer(BaseSerializer):
|
||||
assignee_details = UserLiteSerializer(read_only=True, source="assignee")
|
||||
|
||||
@@ -698,17 +586,25 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
|
||||
# Validation if url already exists
|
||||
def create(self, validated_data):
|
||||
if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists():
|
||||
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
|
||||
if IssueLink.objects.filter(
|
||||
url=validated_data.get("url"), issue_id=validated_data.get("issue_id")
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if (
|
||||
IssueLink.objects.filter(url=validated_data.get("url"), issue_id=instance.issue_id)
|
||||
IssueLink.objects.filter(
|
||||
url=validated_data.get("url"), issue_id=instance.issue_id
|
||||
)
|
||||
.exclude(pk=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
@@ -771,41 +667,16 @@ class IssueReactionSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueReactionLiteSerializer(DynamicBaseSerializer):
|
||||
display_name = serializers.CharField(source="actor.display_name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueReaction
|
||||
fields = ["id", "actor", "issue", "reaction", "display_name"]
|
||||
fields = ["id", "actor", "issue", "reaction"]
|
||||
|
||||
|
||||
class CommentReactionSerializer(BaseSerializer):
|
||||
display_name = serializers.CharField(source="actor.display_name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CommentReaction
|
||||
fields = [
|
||||
"id",
|
||||
"actor",
|
||||
"comment",
|
||||
"reaction",
|
||||
"display_name",
|
||||
"deleted_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"comment",
|
||||
"actor",
|
||||
"deleted_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
|
||||
|
||||
|
||||
class IssueVoteSerializer(BaseSerializer):
|
||||
@@ -877,28 +748,10 @@ class IssueIntakeSerializer(DynamicBaseSerializer):
|
||||
"created_at",
|
||||
"label_ids",
|
||||
"created_by",
|
||||
"type_id",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class CustomerSerializer(DynamicBaseSerializer):
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"website_url",
|
||||
"domain",
|
||||
"contract_status",
|
||||
"stage",
|
||||
"employees",
|
||||
"revenue",
|
||||
"created_by",
|
||||
]
|
||||
|
||||
|
||||
class IssueSerializer(DynamicBaseSerializer):
|
||||
# ids
|
||||
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
@@ -941,7 +794,6 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
"type_id",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -969,7 +821,6 @@ class IssueListDetailSerializer(serializers.Serializer):
|
||||
"id": instance.id,
|
||||
"name": instance.name,
|
||||
"state_id": instance.state_id,
|
||||
"type_id": instance.type_id,
|
||||
"sort_order": instance.sort_order,
|
||||
"completed_at": instance.completed_at,
|
||||
"estimate_point": instance.estimate_point_id,
|
||||
@@ -985,23 +836,14 @@ class IssueListDetailSerializer(serializers.Serializer):
|
||||
"updated_by": instance.updated_by_id,
|
||||
"is_draft": instance.is_draft,
|
||||
"archived_at": instance.archived_at,
|
||||
"cycle_id": instance.cycle_id if hasattr(instance, "cycle_id") else None,
|
||||
# Computed fields
|
||||
"cycle_id": instance.cycle_id,
|
||||
"module_ids": self.get_module_ids(instance),
|
||||
"label_ids": self.get_label_ids(instance),
|
||||
"assignee_ids": self.get_assignee_ids(instance),
|
||||
"sub_issues_count": (
|
||||
instance.sub_issues_count
|
||||
if hasattr(instance, "sub_issues_count")
|
||||
else None
|
||||
),
|
||||
"attachment_count": (
|
||||
instance.attachment_count
|
||||
if hasattr(instance, "attachment_count")
|
||||
else None
|
||||
),
|
||||
"link_count": (
|
||||
instance.link_count if hasattr(instance, "link_count") else None
|
||||
),
|
||||
"sub_issues_count": instance.sub_issues_count,
|
||||
"attachment_count": instance.attachment_count,
|
||||
"link_count": instance.link_count,
|
||||
}
|
||||
|
||||
# Handle expanded fields only when requested - using direct field access
|
||||
@@ -1060,16 +902,9 @@ class IssueListDetailSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||
is_epic = serializers.SerializerMethodField()
|
||||
|
||||
def get_is_epic(self, obj):
|
||||
if hasattr(obj, "type") and obj.type:
|
||||
return obj.type.is_epic
|
||||
return False
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = ["id", "sequence_id", "project_id", "type_id", "is_epic"]
|
||||
fields = ["id", "sequence_id", "project_id"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -1077,37 +912,22 @@ class IssueDetailSerializer(IssueSerializer):
|
||||
description_html = serializers.CharField()
|
||||
is_subscribed = serializers.BooleanField(read_only=True)
|
||||
is_intake = serializers.BooleanField(read_only=True)
|
||||
is_epic = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta(IssueSerializer.Meta):
|
||||
fields = IssueSerializer.Meta.fields + [
|
||||
"description_html",
|
||||
"is_subscribed",
|
||||
"is_intake",
|
||||
"is_epic",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
slug = self.context.get("slug", None)
|
||||
user_id = self.context.get("user_id", None)
|
||||
|
||||
# Check if the user has access to the customer request count
|
||||
if slug and user_id:
|
||||
if check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.CUSTOMERS, slug=slug, user_id=user_id
|
||||
):
|
||||
self.fields["customer_request_ids"] = serializers.ListField(
|
||||
read_only=True
|
||||
)
|
||||
self.fields["initiative_ids"] = serializers.ListField(read_only=True)
|
||||
|
||||
|
||||
class IssuePublicSerializer(BaseSerializer):
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
state_detail = StateLiteSerializer(read_only=True, source="state")
|
||||
reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions")
|
||||
reactions = IssueReactionSerializer(
|
||||
read_only=True, many=True, source="issue_reactions"
|
||||
)
|
||||
votes = IssueVoteSerializer(read_only=True, many=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user