fix: merge conflicts

This commit is contained in:
sriramveeraghanta
2025-11-27 16:39:44 +05:30
6177 changed files with 633719 additions and 12409 deletions
+13 -1
View File
@@ -29,9 +29,10 @@ 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-3.5-turbo" # deprecated
GPT_ENGINE="gpt-4o-mini" # deprecated
# Settings related to Docker
DOCKERIZED=1 # deprecated
@@ -52,5 +53,16 @@ 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
+305
View File
@@ -0,0 +1,305 @@
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
@@ -0,0 +1,180 @@
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
+394
View File
@@ -0,0 +1,394 @@
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 }}
dh_img_pi: ${{ steps.set_env_variables.outputs.dh_img_pi }}
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 "dh_img_pi=plane-pi-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 "VITE_API_BASE_URL" "${{ env.VITE_API_BASE_URL }}"
add_build_arg "VITE_API_BASE_PATH" "${{ env.VITE_API_BASE_PATH }}"
add_build_arg "VITE_ADMIN_BASE_URL" "${{ env.VITE_ADMIN_BASE_URL }}"
add_build_arg "VITE_ADMIN_BASE_PATH" "${{ env.VITE_ADMIN_BASE_PATH }}"
add_build_arg "VITE_SPACE_BASE_URL" "${{ env.VITE_SPACE_BASE_URL }}"
add_build_arg "VITE_SPACE_BASE_PATH" "${{ env.VITE_SPACE_BASE_PATH }}"
add_build_arg "VITE_LIVE_BASE_URL" "${{ env.VITE_LIVE_BASE_URL }}"
add_build_arg "VITE_LIVE_BASE_PATH" "${{ env.VITE_LIVE_BASE_PATH }}"
add_build_arg "VITE_SILO_BASE_URL" "${{ env.VITE_SILO_BASE_URL }}"
add_build_arg "VITE_SILO_BASE_PATH" "${{ env.VITE_SILO_BASE_PATH }}"
add_build_arg "VITE_WEB_BASE_URL" "${{ env.VITE_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/[email protected]
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/[email protected]
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/[email protected]
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/[email protected]
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/[email protected]
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/[email protected]
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/[email protected]
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 }}
branch_build_push_plane_pi:
name: Build-Push Plane PI Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Plane PI Build and Push
uses: makeplane/actions/[email protected]
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_pi }}
build-context: ./apps/pi
dockerfile-path: ./apps/pi/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 }}
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,
branch_build_push_plane_pi,
]
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/[email protected]
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
@@ -43,11 +43,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint Affected
run: pnpm turbo run check:lint --affected
# - name: Lint Affected
# run: pnpm turbo run check:lint --affected
- name: Check Affected format
run: pnpm turbo run check:format --affected
# - name: Check Affected format
# run: pnpm turbo run check:format --affected
- name: Check Affected types
run: pnpm turbo run check:types --affected
+55
View File
@@ -0,0 +1,55 @@
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 [email protected]
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
+5 -1
View File
@@ -41,12 +41,16 @@ 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 "${{ vars.SYNC_PR_TITLE }}" --body "")
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "$PR_TITLE" --body "")
echo "Pull Request created: $PR_URL"
fi
+1 -1
View File
@@ -36,8 +36,8 @@ jobs:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target-origin-a "https://[email protected]/$TARGET_REPO.git"
+10 -4
View File
@@ -87,6 +87,7 @@ tmp/
## packages
dist
.flatfile
.temp/
deploy/selfhost/plane-app/
@@ -100,14 +101,19 @@ dev-editor
*.rdb.gz
storybook-static
# Monitor
monitor/prime.key
monitor/prime.key.pub
monitor.db
.cursorrules
.cursor
claude.md
agents.md
CLAUDE.md
build/
.react-router/
AGENTS.md
build/
.react-router/
AGENTS.md
temp/
@@ -19,6 +19,8 @@ import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
// local components
import { GithubMobileForm } from "./mobile-form";
type Props = {
config: IFormattedInstanceConfiguration;
@@ -237,6 +239,9 @@ export function InstanceGithubConfigForm(props: Props) {
))}
</div>
</div>
{/* mobile service details */}
<GithubMobileForm />
</div>
</div>
</div>
@@ -0,0 +1,51 @@
"use client";
import type { FC } from "react";
import { isEmpty } from "lodash-es";
import { Smartphone } from "lucide-react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// components
import { CodeBlock } from "@/components/common/code-block";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
export const GithubMobileForm: FC = () => {
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITHUB_MOBILE_SERVICE_DETAILS: TCopyField[] = [
{
key: "mobile_callback_uri",
label: "Callback URI",
url: `${originURL}/auth/mobile/github/callback/`,
description: (
<p>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "}
field. For this OAuth client{" "}
<a
href="https://github.com/settings/applications/new"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
];
return (
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
<Smartphone className="w-3 h-3" />
Mobile
</div>
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
{GITHUB_MOBILE_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
);
};
@@ -18,6 +18,8 @@ import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
// local components
import { GoogleMobileForm } from "./mobile-form";
type Props = {
config: IFormattedInstanceConfiguration;
@@ -224,6 +226,9 @@ export function InstanceGoogleConfigForm(props: Props) {
))}
</div>
</div>
{/* mobile service details */}
<GoogleMobileForm />
</div>
</div>
</div>
@@ -0,0 +1,51 @@
"use client";
import type { FC } from "react";
import { isEmpty } from "lodash-es";
import { Smartphone } from "lucide-react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// components
import { CodeBlock } from "@/components/common/code-block";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
export const GoogleMobileForm: FC = () => {
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GOOGLE_MOBILE_SERVICE_DETAILS: TCopyField[] = [
{
key: "mobile_callback_uri",
label: "Callback URI",
url: `${originURL}/auth/mobile/google/callback/`,
description: (
<p>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "}
field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
];
return (
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
<Smartphone className="w-3 h-3" />
Mobile
</div>
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
{GOOGLE_MOBILE_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
);
};
@@ -0,0 +1,296 @@
import type { FC } from "react";
import { useState } from "react";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Monitor, Smartphone } from "lucide-react";
// plane internal packages
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceOIDCAuthenticationConfigurationKeys } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import type { TCopyField } from "@/components/common/copy-field";
import { 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 OIDC_MOBILE_SERVICE_DETAILS: TCopyField[] = [
{
key: "mobile_origin_uri",
label: "Origin URI",
url: `${originURL}/auth/mobile/oidc/`,
description:
"We will generate this for this Plane app. Add this as a trusted origin on your IdP's corresponding field.",
},
{
key: "mobile_callback_uri",
label: "Callback URI",
url: `${originURL}/auth/mobile/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: "mobile_logout_uri",
label: "Logout URI",
url: `${originURL}/auth/mobile/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("neutral-primary", "md"), "font-medium")}
onClick={handleGoBack}
>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-xl font-medium">Plane-provided details for your IdP</div>
<div className="flex flex-col gap-y-4">
{/* web service details */}
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
<Monitor className="w-3 h-3" />
Web
</div>
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
{OIDC_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
{/* mobile service details */}
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
<Smartphone className="w-3 h-3" />
Mobile
</div>
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
{OIDC_MOBILE_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
};
@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// ui
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// assets
import OIDCLogo from "@/app/assets/logos/oidc-logo.svg";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageHeader } from "@/components/common/page-header";
// hooks
import { useInstance } from "@/hooks/store";
// plane admin hooks
import { useInstanceFlag } from "@/plane-admin/hooks/store/use-instance-flag";
// local components
import type { Route } from "./+types/page";
import { InstanceOIDCConfigForm } from "./form";
const InstanceOIDCAuthenticationPage = observer<React.FC<Route.ComponentProps>>(() => {
// 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 === "1" ? "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={<img src={OIDCLogo} height={24} width={24} alt="OIDC Logo" />}
config={
<ToggleSwitch
value={Boolean(parseInt(enableOIDCConfig))}
onChange={() => {
if (Boolean(parseInt(enableOIDCConfig)) === true) {
updateConfig("IS_OIDC_ENABLED", "0");
} else {
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 const meta: Route.MetaFunction = () => [{ title: "OIDC Authentication - God Mode" }];
export default InstanceOIDCAuthenticationPage;
@@ -0,0 +1,304 @@
import type { FC } from "react";
import { useState } from "react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { Monitor, Smartphone, Cable } from "lucide-react";
// plane internal packages
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceSAMLAuthenticationConfigurationKeys } from "@plane/types";
import { TextArea } 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 type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } 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_WEB_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 SAML_MOBILE_SERVICE_DETAILS: TCopyField[] = [
{
key: "mobile_metadata_information",
label: "Entity ID | Audience | Metadata information",
url: `${originURL}/auth/mobile/saml/metadata/`,
description:
"We will generate this bit of the metadata that identifies this Plane app as an authorized service on your IdP.",
},
{
key: "mobile_callback_uri",
label: "Callback URI",
url: `${originURL}/auth/mobile/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: "mobile_logout_uri",
label: "Logout URI",
url: `${originURL}/auth/mobile/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("neutral-primary", "md"), "font-medium")}
onClick={handleGoBack}
>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-xl font-medium">Plane-provided details for your IdP</div>
<div className="flex flex-col gap-y-4">
{/* web service details */}
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
<Monitor className="w-3 h-3" />
Web
</div>
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
{SAML_WEB_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
{/* mobile service details */}
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
<Smartphone className="w-3 h-3" />
Mobile
</div>
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
{SAML_MOBILE_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
{/* mapping details */}
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
<Cable className="w-3 h-3" />
Mapping
</div>
<div className="px-6 py-4 bg-custom-background-80">
<SAMLAttributeMappingTable />
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
};
@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// ui
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// assets
import SAMLLogo from "@/app/assets/logos/saml-logo.svg";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageHeader } from "@/components/common/page-header";
// hooks
import { useInstance } from "@/hooks/store";
// plane admin hooks
import { useInstanceFlag } from "@/plane-admin/hooks/store/use-instance-flag";
// local components
import type { Route } from "./+types/page";
import { InstanceSAMLConfigForm } from "./form";
const InstanceSAMLAuthenticationPage = observer<React.FC<Route.ComponentProps>>(() => {
// 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 === "1" ? "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={<img src={SAMLLogo} height={24} width={24} alt="SAML Logo" className="pl-0.5" />}
config={
<ToggleSwitch
value={Boolean(parseInt(enableSAMLConfig))}
onChange={() => {
if (Boolean(parseInt(enableSAMLConfig)) === true) {
updateConfig("IS_SAML_ENABLED", "0");
} else {
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 const meta: Route.MetaFunction = () => [{ title: "SAML Authentication - God Mode" }];
export default InstanceSAMLAuthenticationPage;
+1 -1
View File
@@ -68,7 +68,7 @@ export const AdminHeader = observer(function AdminHeader() {
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">
+12 -1
View File
@@ -2,11 +2,14 @@ import { useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { Outlet } from "react-router";
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 type { Route } from "./+types/layout";
import { AdminHeader } from "./header";
@@ -17,12 +20,20 @@ function AdminLayout(_props: Route.ComponentProps) {
const { replace } = 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) replace("/");
}, [replace, isUserLoggedIn]);
if (isUserLoggedIn === undefined) {
if ((flagsLoader && !flagsError) || isUserLoggedIn === undefined) {
return (
<div className="relative flex h-screen w-full items-center justify-center">
<LogoSpinner />
@@ -9,7 +9,7 @@ import { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
import { useInstance, useTheme } from "@/hooks/store";
// assets
// eslint-disable-next-line import/order
import packageJson from "package.json";
@@ -36,6 +36,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
// store
const { instance } = useInstance();
const { isSidebarCollapsed, toggleSidebar } = useTheme();
// refs
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
@@ -128,8 +129,20 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
</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{packageJson.version}</div>
<div className="px-2 pb-1 pt-2 text-[10px]">Version: v{instance?.current_version}</div>
</div>
</Transition>
</div>
@@ -63,7 +63,7 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
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>
-14
View File
@@ -1,14 +0,0 @@
import React from "react";
// Minimal shim so code using next/image compiles under React Router + Vite
// without changing call sites. It just renders a native img.
type NextImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
src: string;
};
function Image({ src, alt = "", ...rest }: NextImageProps) {
return <img src={src} alt={alt} {...rest} />;
}
export default Image;
+2
View File
@@ -13,6 +13,8 @@ export default [
route("authentication/gitlab", "./(all)/(dashboard)/authentication/gitlab/page.tsx"),
route("authentication/google", "./(all)/(dashboard)/authentication/google/page.tsx"),
route("authentication/gitea", "./(all)/(dashboard)/authentication/gitea/page.tsx"),
route("authentication/oidc", "./(all)/(dashboard)/authentication/oidc/page.tsx"),
route("authentication/saml", "./(all)/(dashboard)/authentication/saml/page.tsx"),
route("ai", "./(all)/(dashboard)/ai/page.tsx"),
route("image", "./(all)/(dashboard)/image/page.tsx"),
]),
@@ -54,13 +54,13 @@ const defaultFromData: TFormData = {
export function InstanceSetupForm() {
// 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 +1,143 @@
export * from "ce/components/authentication/authentication-modes";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import { KeyRound, Mails } from "lucide-react";
import type {
TInstanceAuthenticationMethodKeys as TBaseAuthenticationMethods,
TInstanceAuthenticationModes,
TInstanceEnterpriseAuthenticationMethodKeys,
} from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// assets
import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url";
import githubLightModeImage from "@/app/assets/logos/github-black.png?url";
import githubDarkModeImage from "@/app/assets/logos/github-white.png?url";
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
import OIDCLogo from "@/app/assets/logos/oidc-logo.svg?url";
import SAMLLogo from "@/app/assets/logos/saml-logo.svg?url";
// 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 { GiteaConfiguration } from "@/components/authentication/gitea-config";
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 imports
import { OIDCConfiguration, SAMLConfiguration } from "@/plane-admin/components/authentication";
import { useInstanceFlag } from "@/plane-admin/hooks/store/use-instance-flag";
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: <img 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: (
<img
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: <img src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "gitea",
name: "Gitea",
description: "Allow members to log in or sign up to plane with their Gitea accounts.",
icon: <img className="h-5 w-5" src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "oidc",
name: "OIDC",
description: "Authenticate your users via the OpenID Connect protocol.",
icon: <img 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: <img 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}
/>
))}
</>
);
});
@@ -1 +1,4 @@
export * from "./authentication-modes";
export * from "./oidc-config";
export * from "./saml-config";
export * from "./saml-attribute-mapping-table";
@@ -0,0 +1,58 @@
"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 { getButtonStyling } from "@plane/propel/button";
import type { TInstanceEnterpriseAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } 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>
)}
</>
);
});
@@ -0,0 +1,28 @@
export const SAMLAttributeMappingTable = () => (
<table className="table-auto border-collapse text-custom-text-200 text-sm w-full">
<thead>
<tr className="text-left border-b border-custom-border-200">
<th className="py-2">IdP</th>
<th className="py-2">Plane</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-custom-border-200">
<td className="py-2">Name ID format</td>
<td className="py-2">emailAddress</td>
</tr>
<tr className="border-b border-custom-border-200">
<td className="py-2">first_name</td>
<td className="py-2">user.firstName</td>
</tr>
<tr className="border-b border-custom-border-200">
<td className="py-2">last_name</td>
<td className="py-2">user.lastName</td>
</tr>
<tr className="border-b border-custom-border-200">
<td className="py-2">email</td>
<td className="py-2">user.email</td>
</tr>
</tbody>
</table>
);
@@ -0,0 +1,58 @@
"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 { getButtonStyling } from "@plane/propel/button";
import type { TInstanceEnterpriseAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } 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
View File
@@ -1 +1 @@
export * from "ce/components/common";
export * from "./upgrade-button";
@@ -0,0 +1,39 @@
"use client";
import React from "react";
// plane internal packages
import { WEB_BASE_URL } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { AlertModalCore } 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>
</>
);
};
@@ -0,0 +1,11 @@
import { useContext } from "react";
// context
import { StoreContext } from "@/app/(all)/store.provider";
// plane admin stores
import type { 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;
};
@@ -0,0 +1,13 @@
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;
};
@@ -0,0 +1,48 @@
import { set } from "lodash-es";
import { action, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { InstanceFeatureFlagService } from "@plane/services";
import type { 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;
}
};
}
+27 -1
View File
@@ -1 +1,27 @@
export * from "ce/store/root.store";
import { enableStaticRendering } from "mobx-react";
// stores
import type { IInstanceFeatureFlagsStore } from "@/plane-admin/store/instance-feature-flags.store";
import { 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();
}
}
+3 -2
View File
@@ -7,8 +7,9 @@
"paths": {
"@/app/*": ["app/*"],
"@/*": ["core/*"],
"@/plane-admin/*": ["ce/*"],
"@/styles/*": ["styles/*"]
"@/plane-admin/*": ["ee/*"],
"@/styles/*": ["styles/*"],
"@/ce/*": ["ce/*"]
},
"strictNullChecks": true
},
+4 -2
View File
@@ -39,8 +39,6 @@ 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"
@@ -60,7 +58,11 @@ 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
View File
@@ -0,0 +1 @@
dump.rdb
+2 -1
View File
@@ -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/691ef037bcfe416a902e48cb55f59891/
ENV INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/f366114e9aba4cbfbe4265b8f2b74f65/
# Update system packages for security
RUN apk update && apk upgrade
@@ -31,6 +31,7 @@ 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 \
&& \
+36 -19
View File
@@ -1,18 +1,30 @@
FROM python:3.12.5-alpine AS backend
FROM python:3.12.10-alpine
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apk --no-cache add \
"bash~=5.2" \
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 \
"libpq" \
"libxslt" \
"nodejs-current" \
"xmlsec" \
"libffi-dev" \
"ca-certificates" \
"openssl"
COPY requirements.txt ./
COPY requirements ./requirements
RUN apk add --no-cache libffi-dev
RUN apk add --no-cache --virtual .build-deps \
"bash~=5.2" \
"g++" \
"gcc" \
@@ -21,18 +33,23 @@ RUN apk --no-cache add \
"make" \
"postgresql-dev" \
"libc-dev" \
"linux-headers"
"linux-headers" \
"xmlsec-dev"\
&& \
pip install -r requirements/local.txt --compile --no-cache-dir \
&& \
apk del .build-deps \
&& \
rm -rf /var/cache/apk/*
WORKDIR /code
# 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
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 apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
RUN mkdir -p /code/plane/logs
RUN chmod -R +x /code/bin
+1
View File
@@ -0,0 +1 @@
# API SERVER
+18
View File
@@ -0,0 +1,18 @@
#!/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 -
+39
View File
@@ -0,0 +1,39 @@
#!/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 -
+1 -1
View File
@@ -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 -
+12
View File
@@ -0,0 +1,12 @@
#!/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}
+15
View File
@@ -0,0 +1,15 @@
#!/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}
@@ -0,0 +1 @@
from .feature_flag import FeatureFlagPermission, TeamspaceFeatureFlagPermission, InitiativesFeatureFlagPermission
@@ -0,0 +1,31 @@
from rest_framework.permissions import BasePermission
from plane.payment.flags.flag import FeatureFlag
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
class FeatureFlagPermission(BasePermission):
"""
Base permission class to check if a feature flag is enabled for the workspace.
Subclass this and set the feature_flag attribute.
"""
feature_flag = None
def has_permission(self, request, view):
if self.feature_flag is None:
return True
return check_workspace_feature_flag(self.feature_flag, view.kwargs.get("slug"))
class TeamspaceFeatureFlagPermission(FeatureFlagPermission):
"""Permission class for Teamspace feature flag"""
feature_flag = FeatureFlag.TEAMSPACES
message = "Payment required. Upgrade your plan to access Teamspaces"
class InitiativesFeatureFlagPermission(FeatureFlagPermission):
"""Permission class for Initiatives feature flag"""
feature_flag = FeatureFlag.INITIATIVES
message = "Payment required. Upgrade your plan to access Initiatives"
+18 -2
View File
@@ -1,5 +1,5 @@
from .user import UserLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .workspace import WorkspaceLiteSerializer, WorkspaceFeatureSerializer
from .project import (
ProjectSerializer,
ProjectLiteSerializer,
@@ -11,6 +11,7 @@ from .issue import (
LabelCreateUpdateSerializer,
LabelSerializer,
IssueLinkSerializer,
IssueDetailSerializer,
IssueCommentSerializer,
IssueAttachmentSerializer,
IssueActivitySerializer,
@@ -21,6 +22,11 @@ from .issue import (
IssueCommentCreateSerializer,
IssueLinkCreateSerializer,
IssueLinkUpdateSerializer,
IssueRelationSerializer,
IssueRelationCreateSerializer,
IssueRelationRemoveSerializer,
IssueRelationResponseSerializer,
RelatedIssueSerializer,
)
from .state import StateLiteSerializer, StateSerializer
from .cycle import (
@@ -46,6 +52,7 @@ from .intake import (
IntakeIssueUpdateSerializer,
)
from .estimate import EstimatePointSerializer
from .issue_type import IssueTypeAPISerializer, ProjectIssueTypeAPISerializer
from .asset import (
UserAssetUploadSerializer,
AssetUpdateSerializer,
@@ -54,4 +61,13 @@ from .asset import (
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .member import ProjectMemberSerializer
from .sticky import StickySerializer
from .project import ProjectFeatureSerializer
from .initiative import InitiativeSerializer, InitiativeLabelSerializer
from .teamspace import TeamspaceSerializer
from .work_item_search import (
WorkItemAdvancedSearchRequestSerializer,
WorkItemAdvancedSearchResponseSerializer,
)
+13 -8
View File
@@ -4,7 +4,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue, User
from plane.db.models import Cycle, CycleIssue, User, Project
from plane.utils.timezone_converter import convert_to_utc
@@ -55,6 +55,18 @@ class CycleCreateSerializer(BaseSerializer):
]
def validate(self, data):
project_id = self.initial_data.get("project_id") or (
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
)
if not project_id:
raise serializers.ValidationError("Project ID is required")
project = Project.objects.filter(id=project_id).first()
if not project:
raise serializers.ValidationError("Project not found")
if not project.cycle_view:
raise serializers.ValidationError("Cycles are not enabled for this project")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
@@ -63,13 +75,6 @@ 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:
project_id = self.initial_data.get("project_id") or (
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
)
if not project_id:
raise serializers.ValidationError("Project ID is required")
data["start_date"] = convert_to_utc(
date=str(data.get("start_date").date()),
project_id=project_id,
@@ -0,0 +1,45 @@
from plane.app.serializers import BaseSerializer
from plane.ee.models import Initiative, InitiativeLabel
from rest_framework import serializers
class InitiativeSerializer(BaseSerializer):
class Meta:
model = Initiative
fields = "__all__"
read_only_fields = ["workspace"]
class InitiativeLabelSerializer(BaseSerializer):
class Meta:
model = InitiativeLabel
fields = ["id", "name", "description", "color", "sort_order", "workspace"]
read_only_fields = [
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
def validate_name(self, name):
initiative_label_id = self.instance.id if self.instance else None
workspace_id = self.context["workspace_id"]
initiative_label = InitiativeLabel.objects.filter(name=name, workspace_id=workspace_id, deleted_at__isnull=True)
if initiative_label_id:
initiative_label = initiative_label.exclude(id=initiative_label_id)
if initiative_label.exists():
raise serializers.ValidationError(detail="INITIATIVE_LABEL_NAME_ALREADY_EXISTS")
return name
def create(self, validated_data):
validated_data["workspace_id"] = self.context["workspace_id"]
return super().create(validated_data)
def update(self, instance, validated_data):
validated_data["workspace_id"] = self.context["workspace_id"]
return super().update(instance, validated_data)
+14
View File
@@ -7,6 +7,7 @@ from rest_framework import serializers
from plane.db.models import WorkspaceMemberInvite
from .base import BaseSerializer
from plane.app.permissions.base import ROLE
from plane.payment.utils.member_payment_count import workspace_member_check
class WorkspaceInviteSerializer(BaseSerializer):
@@ -53,4 +54,17 @@ class WorkspaceInviteSerializer(BaseSerializer):
and WorkspaceMemberInvite.objects.filter(email=data["email"], workspace__slug=slug).exists()
):
raise serializers.ValidationError("Email already invited", code="EMAIL_ALREADY_INVITED")
allowed, _, _ = workspace_member_check(
slug=slug,
requested_invite_list=[{"email": data.get("email"), "role": data.get("role", 5)}],
requested_role=False,
current_role=False,
)
if not allowed:
raise serializers.ValidationError(
"Reached seat limit - Upgrade to add more members", code="REACHED_SEAT_LIMIT"
)
return data
+270 -8
View File
@@ -12,6 +12,7 @@ from plane.db.models import (
IssueType,
IssueActivity,
IssueAssignee,
IssueRelation,
FileAsset,
IssueComment,
IssueLabel,
@@ -32,6 +33,7 @@ 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
@@ -61,11 +63,14 @@ class IssueSerializer(BaseSerializer):
type_id = serializers.PrimaryKeyRelatedField(
source="type", queryset=IssueType.objects.all(), required=False, allow_null=True
)
parent = serializers.PrimaryKeyRelatedField(
queryset=Issue.objects.all(), required=False, allow_null=True
)
class Meta:
model = Issue
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"]
exclude = ["description", "description_stripped"]
exclude = ["description"]
def validate(self, data):
if (
@@ -237,7 +242,22 @@ class IssueSerializer(BaseSerializer):
updated_by_id = instance.updated_by_id
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete()
# 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()
try:
IssueAssignee.objects.bulk_create(
[
@@ -249,7 +269,7 @@ class IssueSerializer(BaseSerializer):
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
for assignee_id in assignees_to_add
],
batch_size=10,
ignore_conflicts=True,
@@ -258,7 +278,23 @@ class IssueSerializer(BaseSerializer):
pass
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete()
# 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
try:
IssueLabel.objects.bulk_create(
[
@@ -270,7 +306,7 @@ class IssueSerializer(BaseSerializer):
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
for label_id in labels_to_add
],
batch_size=10,
ignore_conflicts=True,
@@ -312,6 +348,10 @@ class IssueSerializer(BaseSerializer):
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
@@ -551,7 +591,7 @@ class IssueCommentSerializer(BaseSerializer):
"created_at",
"updated_at",
]
exclude = ["comment_stripped", "comment_json"]
exclude = ["comment_json"]
def validate(self, data):
try:
@@ -681,9 +721,9 @@ class IssueAttachmentUploadSerializer(serializers.Serializer):
)
class IssueSearchSerializer(serializers.Serializer):
class IssueSearchItemSerializer(serializers.Serializer):
"""
Serializer for work item search result data formatting.
Individual issue component for search results.
Provides standardized search result structure including work item identifiers,
project context, and workspace information for search API responses.
@@ -695,3 +735,225 @@ class IssueSearchSerializer(serializers.Serializer):
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"]
@@ -0,0 +1,42 @@
# 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, read_only=True)
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",
]
+9
View File
@@ -10,6 +10,7 @@ from plane.db.models import (
ModuleMember,
ModuleIssue,
ProjectMember,
Project,
)
@@ -53,6 +54,14 @@ class ModuleCreateSerializer(BaseSerializer):
]
def validate(self, data):
project_id = self.context.get("project_id")
if not project_id:
raise serializers.ValidationError("Project ID is required")
project = Project.objects.get(id=project_id)
if not project:
raise serializers.ValidationError("Project not found")
if not project.module_view:
raise serializers.ValidationError("Modules are not enabled for this project")
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
+138
View File
@@ -9,6 +9,9 @@ from plane.db.models import (
WorkspaceMember,
State,
Estimate,
Issue,
IssueType,
ProjectIssueType,
)
from plane.utils.content_validator import (
@@ -16,6 +19,11 @@ from plane.utils.content_validator import (
)
from .base import BaseSerializer
# ee imports
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
from plane.ee.models import WorkitemTemplate, ProjectFeature
from plane.payment.flags.flag import FeatureFlag
class ProjectCreateSerializer(BaseSerializer):
"""
@@ -283,3 +291,133 @@ class ProjectLiteSerializer(BaseSerializer):
"cover_image_url",
]
read_only_fields = fields
class ProjectFeatureSerializer(serializers.Serializer):
"""
Serializer for updating project features.
"""
epics = serializers.BooleanField(required=False)
modules = serializers.BooleanField(required=False)
cycles = serializers.BooleanField(required=False)
views = serializers.BooleanField(required=False)
pages = serializers.BooleanField(required=False)
intakes = serializers.BooleanField(required=False)
work_item_types = serializers.BooleanField(required=False)
def validate_epics(self, value):
if not check_workspace_feature_flag(FeatureFlag.EPICS, self.context["slug"]):
raise serializers.ValidationError("Upgrade your plan to enable Epics")
return value
def validate_work_item_types(self, value):
if not check_workspace_feature_flag(FeatureFlag.ISSUE_TYPES, self.context["slug"]):
raise serializers.ValidationError("Upgrade your plan to enable Work Item Types")
return value
def update(self, instance, validated_data):
project_feature_fields = [
"modules",
"cycles",
"views",
"pages",
"intakes",
]
old_name_map = {
"modules": "module_view",
"cycles": "cycle_view",
"views": "issue_views_view",
"pages": "page_view",
"intakes": "intake_view",
}
for field in project_feature_fields:
if field in validated_data:
Project.objects.filter(id=self.context["project_id"]).update(
**{old_name_map[field]: validated_data[field]}
)
project = Project.objects.get(id=self.context["project_id"])
if validated_data.get("work_item_types"):
# Check if default issue type already exists
if not ProjectIssueType.objects.filter(project_id=project.id, is_default=True).exists():
# Create default issue type
issue_type = IssueType.objects.create(
workspace_id=project.workspace_id,
name="Task",
is_default=True,
description="Default work item type with the option to add new properties",
logo_props={
"in_use": "icon",
"icon": {"color": "#ffffff", "background_color": "#6695FF"},
},
)
# Update existing issues to use the new default issue type
Issue.objects.filter(project_id=project.id, type_id__isnull=True).update(type_id=issue_type.id)
# Bridge the issue type with the project
ProjectIssueType.objects.create(
project_id=project.id, issue_type_id=issue_type.id, level=0, is_default=True
)
# Update existing work item templates to use the new default issue type
work_item_type_template_schema = {
"id": str(issue_type.id),
"name": issue_type.name,
"logo_props": issue_type.logo_props,
"is_epic": issue_type.is_epic,
}
WorkitemTemplate.objects.filter(
project_id=project.id, workspace=project.workspace, type__exact={}
).update(type=work_item_type_template_schema)
# Enable issue types for the project
project.is_issue_type_enabled = True
project.save()
if validated_data.get("epics", None) is not None:
if validated_data.get("epics"):
# get or create the project feature
project_feature = ProjectFeature.objects.filter(project=project).first()
if not project_feature:
project_feature = ProjectFeature.objects.create(project=project)
# Check if the epic issue type is already created for the project or not
project_issue_type = ProjectIssueType.objects.filter(project=project, issue_type__is_epic=True).first()
if not project_issue_type:
# create the epic issue type
epic = IssueType.objects.create(workspace=project.workspace, is_epic=True, level=1)
# add it to the project epic issue type
_ = ProjectIssueType.objects.create(project=project, issue_type=epic)
# enable epic issue type
project_feature.is_epic_enabled = True
project_feature.save()
else:
# get or create the project feature
project_feature = ProjectFeature.objects.filter(project=project).first()
if not project_feature:
project_feature = ProjectFeature.objects.create(project=project)
if project_feature.is_epic_enabled:
project_feature.is_epic_enabled = False
project_feature.save()
# Refresh instance with updated project data
project = Project.objects.get(id=self.context["project_id"])
project_feature = ProjectFeature.objects.filter(project=project).first()
is_epic_enabled = project_feature.is_epic_enabled if project_feature else False
return {
"epics": is_epic_enabled,
"modules": project.module_view,
"cycles": project.cycle_view,
"views": project.issue_views_view,
"pages": project.page_view,
"intakes": project.intake_view,
"work_item_types": project.is_issue_type_enabled,
}
+1 -2
View File
@@ -19,7 +19,7 @@ class StateSerializer(BaseSerializer):
class Meta:
model = State
fields = "__all__"
exclude = ["slug"]
read_only_fields = [
"id",
"created_by",
@@ -29,7 +29,6 @@ class StateSerializer(BaseSerializer):
"workspace",
"project",
"deleted_at",
"slug",
]
+30
View File
@@ -0,0 +1,30 @@
from rest_framework import serializers
from .base import BaseSerializer
from plane.db.models import Sticky
from plane.utils.content_validator import validate_html_content, validate_binary_data
class StickySerializer(BaseSerializer):
class Meta:
model = Sticky
fields = "__all__"
read_only_fields = ["workspace", "owner"]
extra_kwargs = {"name": {"required": False}}
def validate(self, data):
# Validate description content for security
if "description_html" in data and 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"})
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
return data
@@ -0,0 +1,48 @@
from rest_framework import serializers
from plane.app.serializers import BaseSerializer
from plane.ee.models import Teamspace, TeamspaceMember
class TeamspaceSerializer(BaseSerializer):
logo_props = serializers.JSONField(default=dict)
def create(self, validated_data):
validated_data["workspace_id"] = self.context["workspace_id"]
lead = validated_data.get("lead")
# add requester user as the first member
teamspace = super().create(validated_data)
TeamspaceMember.objects.create(
team_space=teamspace,
member=self.context["user"],
workspace_id=teamspace.workspace_id,
)
# if lead is set and is not the requester, add them as a member too
if lead and lead != self.context["user"]:
TeamspaceMember.objects.get_or_create(
team_space=teamspace,
member=lead,
workspace_id=teamspace.workspace_id,
)
return teamspace
def update(self, instance, validated_data):
lead = validated_data.get("lead")
# if lead is being updated, ensure they are a member
if lead and lead != instance.lead:
TeamspaceMember.objects.get_or_create(
team_space=instance,
member=lead,
workspace_id=instance.workspace_id,
)
return super().update(instance, validated_data)
class Meta:
model = Teamspace
fields = "__all__"
read_only_fields = ["workspace"]
@@ -0,0 +1,72 @@
# Third party imports
from rest_framework import serializers
# Module imports
from plane.db.models import (
Issue,
)
from .base import BaseSerializer
class WorkItemAdvancedSearchRequestSerializer(serializers.Serializer):
"""
Request serializer for work item advanced search endpoint.
Supports complex filtering using ComplexFilterBackend and IssueFilterSet.
Filters field accepts JSON structure with logical operators (and, or, not)
and field filters defined in IssueFilterSet.
"""
query = serializers.CharField(
required=False,
allow_blank=True,
help_text="Search query string for text-based search across issue fields",
)
filters = serializers.JSONField(
required=False,
allow_null=True,
help_text="Filter JSON passed through to IssueFilterSet for validation and application",
)
limit = serializers.IntegerField(
required=False,
default=10,
min_value=1,
help_text="Maximum number of results to return",
)
workspace_search = serializers.BooleanField(
required=False,
default=False,
help_text="Whether to search across all projects in the workspace",
)
project_id = serializers.UUIDField(
required=False,
allow_null=True,
help_text="Optional project ID to filter results to a specific project",
)
class WorkItemAdvancedSearchResponseSerializer(BaseSerializer):
"""
Serializer for work item advanced search response.
Provides list of work items with their identifiers, names, and project context.
"""
project_identifier = serializers.CharField(source="project.identifier", read_only=True)
class Meta:
model = Issue
fields = [
"id",
"name",
"sequence_id",
"project_identifier",
"project_id",
"workspace_id",
"type_id",
"state_id",
"priority",
"target_date",
"start_date",
]
@@ -2,6 +2,11 @@
from plane.db.models import Workspace
from .base import BaseSerializer
from rest_framework import serializers
from plane.ee.models import WorkspaceFeature
from plane.payment.flags.flag import FeatureFlag
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
class WorkspaceLiteSerializer(BaseSerializer):
"""
@@ -15,3 +20,77 @@ class WorkspaceLiteSerializer(BaseSerializer):
model = Workspace
fields = ["name", "slug", "id"]
read_only_fields = fields
class WorkspaceFeatureSerializer(serializers.Serializer):
"""
Serializer for updating workspace features.
"""
project_grouping = serializers.BooleanField(required=False)
initiatives = serializers.BooleanField(required=False)
teams = serializers.BooleanField(required=False)
customers = serializers.BooleanField(required=False)
wiki = serializers.BooleanField(required=False)
pi = serializers.BooleanField(required=False)
def validate_project_grouping(self, value):
if not check_workspace_feature_flag(FeatureFlag.PROJECT_GROUPING, self.context["slug"]):
raise serializers.ValidationError("Upgrade your plan to enable Project Grouping")
return value
def validate_initiatives(self, value):
if not check_workspace_feature_flag(FeatureFlag.INITIATIVES, self.context["slug"]):
raise serializers.ValidationError("Upgrade your plan to enable Initiatives")
return value
def validate_teams(self, value):
if not check_workspace_feature_flag(FeatureFlag.TEAMSPACES, self.context["slug"]):
raise serializers.ValidationError("Upgrade your plan to enable Teams")
return value
def validate_customers(self, value):
if not check_workspace_feature_flag(FeatureFlag.CUSTOMERS, self.context["slug"]):
raise serializers.ValidationError("Upgrade your plan to enable Customers")
return value
def validate_wiki(self, value):
if not check_workspace_feature_flag(FeatureFlag.WORKSPACE_PAGES, self.context["slug"]):
raise serializers.ValidationError("Upgrade your plan to enable Wiki")
return value
def validate_pi(self, value):
if not check_workspace_feature_flag(FeatureFlag.PI_CHAT, self.context["slug"]):
raise serializers.ValidationError("Upgrade your plan to enable PI")
return value
def update(self, instance, validated_data):
workspace_feature_fields = {
"project_grouping": "is_project_grouping_enabled",
"initiatives": "is_initiative_enabled",
"teams": "is_teams_enabled",
"customers": "is_customer_enabled",
"wiki": "is_wiki_enabled",
"pi": "is_pi_enabled",
}
workspace_feature = WorkspaceFeature.objects.get(workspace_id=self.context["workspace_id"])
# teams can only be enabled
if validated_data.get("teams", None) is not None:
validated_data["teams"] = True
for field, db_field in workspace_feature_fields.items():
if field in validated_data:
setattr(workspace_feature, db_field, validated_data[field])
workspace_feature.save()
return {
"project_grouping": workspace_feature.is_project_grouping_enabled,
"initiatives": workspace_feature.is_initiative_enabled,
"teams": workspace_feature.is_teams_enabled,
"customers": workspace_feature.is_customer_enabled,
"wiki": workspace_feature.is_wiki_enabled,
"pi": workspace_feature.is_pi_enabled,
}
+17
View File
@@ -1,4 +1,5 @@
from .asset import urlpatterns as asset_patterns
from .customer import urlpatterns as customer_patterns
from .cycle import urlpatterns as cycle_patterns
from .intake import urlpatterns as intake_patterns
from .label import urlpatterns as label_patterns
@@ -8,10 +9,19 @@ from .project import urlpatterns as project_patterns
from .state import urlpatterns as state_patterns
from .user import urlpatterns as user_patterns
from .work_item import urlpatterns as work_item_patterns
from .work_item_type import urlpatterns as work_item_type_patterns
from .invite import urlpatterns as invite_patterns
from .workspace import urlpatterns as workspace_patterns
# ee imports
from plane.ee.urls.api import urlpatterns as ee_api_urls
from .sticky import urlpatterns as sticky_patterns
from .initiative import urlpatterns as initiative_patterns
from .teamspace import urlpatterns as teamspace_patterns
urlpatterns = [
*asset_patterns,
*customer_patterns,
*cycle_patterns,
*intake_patterns,
*label_patterns,
@@ -21,5 +31,12 @@ urlpatterns = [
*state_patterns,
*user_patterns,
*work_item_patterns,
*work_item_type_patterns,
*workspace_patterns,
# ee url endpoints
*ee_api_urls,
*invite_patterns,
*sticky_patterns,
*initiative_patterns,
*teamspace_patterns,
]
+68
View File
@@ -0,0 +1,68 @@
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",
),
]
+43
View File
@@ -0,0 +1,43 @@
from plane.api.views import InitiativeViewSet, InitiativeLabelViewSet
from django.urls import path
urlpatterns = [
# Initiative urls - manage initiatives, and its associated labels, projects, and epics
path(
"workspaces/<str:slug>/initiatives/",
InitiativeViewSet.as_view({"get": "list", "post": "create"}),
name="initiatives",
),
path(
"workspaces/<str:slug>/initiatives/<uuid:pk>/",
InitiativeViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="initiatives",
),
path(
"workspaces/<str:slug>/initiatives/<uuid:initiative_id>/labels/",
InitiativeViewSet.as_view({"get": "get_labels", "post": "add_labels", "delete": "remove_labels"}),
name="initiative-labels-manage",
),
path(
"workspaces/<str:slug>/initiatives/<uuid:initiative_id>/projects/",
InitiativeViewSet.as_view({"get": "get_projects", "post": "add_projects", "delete": "remove_projects"}),
name="initiative-projects-manage",
),
path(
"workspaces/<str:slug>/initiatives/<uuid:initiative_id>/epics/",
InitiativeViewSet.as_view({"get": "get_epics", "post": "add_epics", "delete": "remove_epics"}),
name="initiative-epics-manage",
),
# initiative labels endpoints
path(
"workspaces/<str:slug>/initiatives/labels/",
InitiativeLabelViewSet.as_view({"get": "list", "post": "create"}),
name="initiative-labels",
),
path(
"workspaces/<str:slug>/initiatives/labels/<uuid:pk>/",
InitiativeLabelViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="initiative-label-detail",
),
]
+12 -2
View File
@@ -1,16 +1,26 @@
from django.urls import path
from plane.api.views import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
from plane.api.views import (
ProjectMemberListCreateAPIEndpoint,
ProjectMemberDetailAPIEndpoint,
WorkspaceMemberAPIEndpoint,
ProjectMemberSiloEndpoint,
)
urlpatterns = [
# Project members
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
ProjectMemberSiloEndpoint.as_view(http_method_names=["get", "post"]),
name="project-members",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project-members",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/<uuid:pk>/",
ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]),
name="project-member",
),
+6
View File
@@ -4,6 +4,7 @@ from plane.api.views import (
ProjectListCreateAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
ProjectFeatureAPIEndpoint,
)
urlpatterns = [
@@ -22,4 +23,9 @@ urlpatterns = [
ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]),
name="project-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/features/",
ProjectFeatureAPIEndpoint.as_view(http_method_names=["get", "patch"]),
name="project-feature",
),
]
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from plane.api.views import StickyViewSet
router = DefaultRouter()
router.register(r"stickies", StickyViewSet, basename="workspace-stickies")
urlpatterns = [
path("workspaces/<str:slug>/", include(router.urls)),
]
+26
View File
@@ -0,0 +1,26 @@
from django.urls import path, include
from plane.api.views import TeamspaceViewSet
urlpatterns = [
path(
"workspaces/<str:slug>/teamspaces/",
TeamspaceViewSet.as_view({"get": "list", "post": "create"}),
name="workspace-teamspaces",
),
path(
"workspaces/<str:slug>/teamspaces/<uuid:pk>/",
TeamspaceViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="workspace-teamspaces",
),
path(
"workspaces/<str:slug>/teamspaces/<uuid:teamspace_id>/projects/",
TeamspaceViewSet.as_view({"get": "get_projects", "post": "add_projects", "delete": "remove_projects"}),
name="workspace-teamspaces-projects",
),
path(
"workspaces/<str:slug>/teamspaces/<uuid:teamspace_id>/members/",
TeamspaceViewSet.as_view({"get": "get_members", "post": "add_members", "delete": "remove_members"}),
name="workspace-teamspaces-members",
),
]
+63 -11
View File
@@ -1,18 +1,22 @@
from django.urls import path
from plane.api.views import (
IssueListCreateAPIEndpoint,
IssueDetailAPIEndpoint,
IssueLinkListCreateAPIEndpoint,
IssueLinkDetailAPIEndpoint,
IssueCommentListCreateAPIEndpoint,
IssueCommentDetailAPIEndpoint,
IssueActivityListAPIEndpoint,
IssueActivityDetailAPIEndpoint,
IssueAttachmentListCreateAPIEndpoint,
IssueActivityListAPIEndpoint,
IssueAttachmentDetailAPIEndpoint,
WorkspaceIssueAPIEndpoint,
IssueAttachmentListCreateAPIEndpoint,
IssueAttachmentServerEndpoint,
IssueCommentDetailAPIEndpoint,
IssueCommentListCreateAPIEndpoint,
IssueDetailAPIEndpoint,
IssueLinkDetailAPIEndpoint,
IssueLinkListCreateAPIEndpoint,
IssueListCreateAPIEndpoint,
IssueRelationListCreateAPIEndpoint,
IssueRelationRemoveAPIEndpoint,
IssueSearchEndpoint,
WorkItemAdvancedSearchEndpoint,
WorkspaceIssueAPIEndpoint,
)
# Deprecated url patterns
@@ -77,6 +81,26 @@ old_url_patterns = [
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",
),
]
# New url patterns with work-items as the prefix
@@ -87,7 +111,7 @@ new_url_patterns = [
name="work-item-search",
),
path(
"workspaces/<str:slug>/work-items/<str:project_identifier>-<str:issue_identifier>/",
"workspaces/<str:slug>/work-items/<str:project_identifier>-<int:issue_identifier>/",
WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
name="work-item-by-identifier",
),
@@ -141,6 +165,34 @@ new_url_patterns = [
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="work-item-attachment-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/attachments/server/",
IssueAttachmentServerEndpoint.as_view(),
name="work-item-attachment-server",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/attachments/<uuid:pk>/server/",
IssueAttachmentServerEndpoint.as_view(),
name="work-item-attachment-server",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/relations/",
IssueRelationListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="work-item-relation-list",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/relations/remove/",
IssueRelationRemoveAPIEndpoint.as_view(http_method_names=["post"]),
name="work-item-relation-remove",
),
]
urlpatterns = old_url_patterns + new_url_patterns
advanced_search_url_patterns = [
path(
"workspaces/<str:slug>/work-items/advanced-search/",
WorkItemAdvancedSearchEndpoint.as_view(http_method_names=["post"]),
name="work-item-advanced-search",
),
]
urlpatterns = old_url_patterns + new_url_patterns + advanced_search_url_patterns
+31
View File
@@ -0,0 +1,31 @@
from django.urls import path
from plane.api.views import IssueTypeListCreateAPIEndpoint, IssueTypeDetailAPIEndpoint
old_url_patterns = [
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",
),
]
new_url_patterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-item-types/",
IssueTypeListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="external-work-item-type",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-item-types/<uuid:type_id>/",
IssueTypeDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="external-work-item-type-detail",
),
]
urlpatterns = old_url_patterns + new_url_patterns
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from plane.api.views.workspace import WorkspaceFeatureAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/features/",
WorkspaceFeatureAPIEndpoint.as_view(),
name="workspace-features",
),
]
+31 -2
View File
@@ -2,6 +2,7 @@ from .project import (
ProjectListCreateAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
ProjectFeatureAPIEndpoint,
)
from .state import (
@@ -11,6 +12,7 @@ from .state import (
from .issue import (
WorkspaceIssueAPIEndpoint,
IssueAttachmentServerEndpoint,
IssueListCreateAPIEndpoint,
IssueDetailAPIEndpoint,
LabelListCreateAPIEndpoint,
@@ -23,6 +25,8 @@ from .issue import (
IssueActivityDetailAPIEndpoint,
IssueAttachmentListCreateAPIEndpoint,
IssueAttachmentDetailAPIEndpoint,
IssueRelationListCreateAPIEndpoint,
IssueRelationRemoveAPIEndpoint,
IssueSearchEndpoint,
)
@@ -43,7 +47,26 @@ from .module import (
ModuleArchiveUnarchiveAPIEndpoint,
)
from .member import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
from .member import (
ProjectMemberListCreateAPIEndpoint,
ProjectMemberDetailAPIEndpoint,
WorkspaceMemberAPIEndpoint,
ProjectMemberSiloEndpoint,
)
from .user import UserEndpoint
from .customer import (
CustomerAPIEndpoint,
CustomerDetailAPIEndpoint,
CustomerRequestAPIEndpoint,
CustomerRequestDetailAPIEndpoint,
CustomerIssuesAPIEndpoint,
CustomerIssueDetailAPIEndpoint,
CustomerPropertiesAPIEndpoint,
CustomerPropertyDetailAPIEndpoint,
CustomerPropertyValuesAPIEndpoint,
CustomerPropertyValueDetailAPIEndpoint,
)
from .intake import (
IntakeIssueListCreateAPIEndpoint,
@@ -52,6 +75,12 @@ from .intake import (
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
from .issue_type import IssueTypeListCreateAPIEndpoint, IssueTypeDetailAPIEndpoint
from .user import UserEndpoint
from .invite import WorkspaceInvitationsViewset
from .invite import WorkspaceInvitationsViewset
from .sticky import StickyViewSet
from .initiative import InitiativeViewSet, InitiativeLabelViewSet
from .teamspace import TeamspaceViewSet
from .work_item_search import WorkItemAdvancedSearchEndpoint
+2
View File
@@ -432,6 +432,8 @@ 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(
+17 -6
View File
@@ -13,8 +13,12 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
# Third party imports
from oauth2_provider.contrib.rest_framework import (
OAuth2Authentication,
IsAuthenticatedOrTokenHasScope,
)
from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import APIException
from rest_framework.generics import GenericAPIView
@@ -23,6 +27,8 @@ from rest_framework.generics import GenericAPIView
from plane.db.models.api import APIToken
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
@@ -46,9 +52,13 @@ class TimezoneMixin:
class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator):
authentication_classes = [APIKeyAuthentication]
permission_classes = [IsAuthenticated]
authentication_classes = [APIKeyAuthentication, OAuth2Authentication]
permission_classes = [
IsAuthenticated,
IsAuthenticatedOrTokenHasScope,
OauthApplicationWorkspacePermission,
]
required_scopes = ["read", "write"]
use_read_replica = False
@@ -58,7 +68,7 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
return queryset
def get_throttles(self):
throttle_classes = []
throttle_classes = super().get_throttles()
api_key = self.request.headers.get("X-Api-Key")
if api_key:
@@ -69,6 +79,7 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
return throttle_classes
throttle_classes.append(ApiKeyRateThrottle())
throttle_classes.append(OAuthTokenRateThrottle())
return throttle_classes
File diff suppressed because it is too large Load Diff
+57 -2
View File
@@ -14,6 +14,7 @@ from django.db.models import (
Sum,
)
# Third party imports
from rest_framework import status
from rest_framework.response import Response
@@ -45,6 +46,9 @@ from plane.utils.cycle_transfer_issues import transfer_cycle_issues
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,
@@ -938,7 +942,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
responses={
200: OpenApiResponse(
description="Cycle work items added",
response=CycleIssueSerializer,
response=CycleIssueSerializer(many=True),
examples=[CYCLE_ISSUE_EXAMPLE],
),
400: REQUIRED_FIELDS_RESPONSE,
@@ -982,12 +986,35 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
]
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,
)
@@ -1019,6 +1046,22 @@ 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",
@@ -1148,6 +1191,18 @@ 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)
@@ -1236,7 +1291,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
request=request,
user_id=self.request.user.id,
)
# Handle the result
if result.get("success"):
return Response({"message": "Success"}, status=status.HTTP_200_OK)
+576
View File
@@ -0,0 +1,576 @@
from rest_framework.response import Response
from rest_framework import status
# Plane imports
from plane.api.views.base import BaseViewSet
from plane.ee.models import Initiative, InitiativeLabel, InitiativeLabelAssociation, InitiativeProject, InitiativeEpic
from plane.app.permissions import WorkSpaceAdminPermission
from plane.db.models import Workspace, Project, Issue
from plane.api.serializers import InitiativeSerializer, InitiativeLabelSerializer, ProjectSerializer, IssueSerializer
from plane.api.permissions import InitiativesFeatureFlagPermission
# OpenAPI imports
from plane.utils.openapi.decorators import initiative_docs, initiative_entity_docs
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
from plane.utils.openapi import (
INITIATIVE_EXAMPLE,
INITIATIVE_LABEL_EXAMPLE,
create_paginated_response,
DELETED_RESPONSE,
PROJECT_EXAMPLE,
EPIC_EXAMPLE,
INITIATIVE_ID_PARAMETER,
INITIATIVE_LABEL_ID_PARAMETER,
WORKSPACE_SLUG_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
)
class InitiativeViewSet(BaseViewSet):
serializer_class = InitiativeSerializer
model = Initiative
permission_classes = [WorkSpaceAdminPermission, InitiativesFeatureFlagPermission]
def get_queryset(self):
return Initiative.objects.filter(workspace__slug=self.kwargs.get("slug"))
@initiative_docs(
operation_id="create_initiative",
summary="Create a new initiative",
description="Create a new initiative in the workspace",
request=OpenApiRequest(request=InitiativeSerializer),
responses={
201: OpenApiResponse(
description="Initiative created", response=InitiativeSerializer, examples=[INITIATIVE_EXAMPLE]
),
},
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = InitiativeSerializer(data=request.data, context={"workspace_id": workspace.id})
if serializer.is_valid():
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@initiative_docs(
operation_id="list_initiatives",
summary="List initiatives",
description="List all initiatives in the workspace",
parameters=[
WORKSPACE_SLUG_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
],
responses={
200: create_paginated_response(
InitiativeSerializer, "Initiative", "List of initiatives", example_name="Paginated Initiatives"
),
},
)
def list(self, request, slug):
initiatives = self.get_queryset()
return self.paginate(
request=request,
queryset=(initiatives),
on_results=lambda initiatives: InitiativeSerializer(initiatives, many=True).data,
default_per_page=20,
)
@initiative_docs(
operation_id="retrieve_initiative",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
],
summary="Retrieve an initiative",
description="Retrieve an initiative by its ID",
responses={
200: OpenApiResponse(description="Initiative", response=InitiativeSerializer, examples=[INITIATIVE_EXAMPLE])
},
)
def retrieve(self, request, slug, pk):
initiative = self.get_queryset().get(id=pk)
return Response(InitiativeSerializer(initiative).data, status=status.HTTP_200_OK)
@initiative_docs(
operation_id="update_initiative",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
],
summary="Update an initiative",
description="Update an initiative by its ID",
request=OpenApiRequest(request=InitiativeSerializer),
responses={
200: OpenApiResponse(description="Initiative", response=InitiativeSerializer, examples=[INITIATIVE_EXAMPLE])
},
)
def partial_update(self, request, slug, pk):
initiative = self.get_queryset().get(id=pk)
serializer = InitiativeSerializer(initiative, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@initiative_docs(
operation_id="delete_initiative",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
],
summary="Delete an initiative",
description="Delete an initiative by its ID",
responses={204: DELETED_RESPONSE},
)
def destroy(self, request, slug, pk):
initiative = self.get_queryset().get(id=pk)
initiative.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@initiative_docs(
operation_id="list_initiative_labels",
summary="List initiative labels",
description="List all labels associated with an initiative",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
],
responses={
200: create_paginated_response(
InitiativeLabelSerializer,
"InitiativeLabel",
"List of initiative labels",
example_name="Paginated Initiative Labels",
)
},
)
def get_labels(self, request, slug, initiative_id):
initiative = self.get_queryset().get(id=initiative_id)
labels = InitiativeLabel.objects.filter(
initiative_label_associations__initiative=initiative, initiative_label_associations__deleted_at__isnull=True
)
return self.paginate(
request=request,
queryset=(labels),
on_results=lambda labels: InitiativeLabelSerializer(labels, many=True).data,
default_per_page=20,
)
@initiative_docs(
operation_id="add_initiative_labels",
summary="Add labels to an initiative",
description="Add labels to an initiative by its ID",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
],
request=OpenApiRequest(
request={
"type": "object",
"properties": {
"label_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
},
}
),
responses={
200: OpenApiResponse(
description="Labels added", response=InitiativeLabelSerializer, examples=[INITIATIVE_LABEL_EXAMPLE]
)
},
)
def add_labels(self, request, slug, initiative_id):
initiative = self.get_queryset().get(id=initiative_id)
label_ids = request.data.get("label_ids", [])
# skip adding labels that are already associated with the initiative
existing_label_ids = InitiativeLabelAssociation.objects.filter(
initiative=initiative, label_id__in=label_ids
).values_list("label_id", flat=True)
# Convert UUIDs to strings for proper comparison
existing_label_ids = [str(uuid_id) for uuid_id in existing_label_ids]
new_label_ids = set(label_ids) - set(existing_label_ids)
for label_id in new_label_ids:
label = InitiativeLabel.objects.get(id=label_id)
InitiativeLabelAssociation.objects.create(
initiative=initiative, label=label, workspace_id=initiative.workspace_id
)
# send new labels in response
new_labels = InitiativeLabel.objects.filter(id__in=label_ids)
serializer = InitiativeLabelSerializer(new_labels, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@initiative_docs(
operation_id="remove_initiative_labels",
summary="Remove labels from an initiative",
description="Remove labels from an initiative by its ID",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
],
request=OpenApiRequest(
request={
"type": "object",
"properties": {
"label_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
},
}
),
responses={204: DELETED_RESPONSE},
)
def remove_labels(self, request, slug, initiative_id):
initiative = self.get_queryset().get(id=initiative_id)
label_ids = request.data.get("label_ids", [])
for label_id in label_ids:
label = InitiativeLabel.objects.get(id=label_id)
InitiativeLabelAssociation.objects.filter(initiative=initiative, label=label).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@initiative_docs(
operation_id="list_initiative_projects",
summary="List projects associated with an initiative",
description="List all projects associated with an initiative",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
],
responses={
200: create_paginated_response(
ProjectSerializer, "Project", "List of projects", example_name="Paginated Projects"
)
},
)
def get_projects(self, request, slug, initiative_id):
initiative = self.get_queryset().get(id=initiative_id)
projects = Project.objects.filter(initiatives__initiative=initiative, initiatives__deleted_at__isnull=True)
return self.paginate(
request=request,
queryset=(projects),
on_results=lambda projects: ProjectSerializer(projects, many=True).data,
default_per_page=20,
)
@initiative_docs(
operation_id="add_initiative_projects",
summary="Add projects to an initiative",
description="Add projects to an initiative by its ID",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
],
request=OpenApiRequest(
request={
"type": "object",
"properties": {
"project_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
},
}
),
responses={
200: OpenApiResponse(description="Projects added", response=ProjectSerializer, examples=[PROJECT_EXAMPLE])
},
)
def add_projects(self, request, slug, initiative_id):
initiative = self.get_queryset().get(id=initiative_id)
project_ids = request.data.get("project_ids", [])
# skip adding projects that are already associated with the initiative
existing_project_ids = InitiativeProject.objects.filter(
initiative=initiative, project_id__in=project_ids
).values_list("project_id", flat=True)
# Convert UUIDs to strings for proper comparison
existing_project_ids = [str(uuid_id) for uuid_id in existing_project_ids]
new_project_ids = set(project_ids) - set(existing_project_ids)
for project_id in new_project_ids:
project = Project.objects.get(id=project_id)
InitiativeProject.objects.create(
initiative=initiative, project=project, workspace_id=initiative.workspace_id
)
# send new projects in response
new_projects = Project.objects.filter(id__in=project_ids)
serializer = ProjectSerializer(new_projects, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@initiative_docs(
operation_id="remove_initiative_projects",
summary="Remove projects from an initiative",
description="Remove projects from an initiative by its ID",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
],
request=OpenApiRequest(
request={
"type": "object",
"properties": {
"project_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
},
}
),
responses={204: DELETED_RESPONSE},
)
def remove_projects(self, request, slug, initiative_id):
initiative = self.get_queryset().get(id=initiative_id)
project_ids = request.data.get("project_ids", [])
for project_id in project_ids:
project = Project.objects.get(id=project_id)
InitiativeProject.objects.filter(initiative=initiative, project=project).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@initiative_docs(
operation_id="list_initiative_epics",
summary="List epics associated with an initiative",
description="List all epics associated with an initiative",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
],
responses={
200: create_paginated_response(IssueSerializer, "Issue", "List of epics", example_name="Paginated Epics")
},
)
def get_epics(self, request, slug, initiative_id):
initiative = self.get_queryset().get(id=initiative_id)
epics = Issue.objects.filter(initiative_epics__initiative=initiative, initiative_epics__deleted_at__isnull=True)
return self.paginate(
request=request,
queryset=(epics),
on_results=lambda epics: IssueSerializer(epics, many=True).data,
default_per_page=20,
)
@initiative_docs(
operation_id="add_initiative_epics",
summary="Add epics to an initiative",
description="Add epics to an initiative by its ID",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
],
request=OpenApiRequest(
request={
"type": "object",
"properties": {
"epic_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
},
}
),
responses={200: OpenApiResponse(description="Epics added", response=IssueSerializer, examples=[EPIC_EXAMPLE])},
)
def add_epics(self, request, slug, initiative_id):
initiative = self.get_queryset().get(id=initiative_id)
epic_ids = request.data.get("epic_ids", [])
# Validate that all provided IDs are actually epics
valid_epics_count = Issue.objects.filter(id__in=epic_ids, type__isnull=False, type__is_epic=True).count()
if valid_epics_count != len(epic_ids):
return Response({"error": "Invalid epic IDs provided"}, status=status.HTTP_400_BAD_REQUEST)
# skip adding epics that are already associated with the initiative
existing_epic_ids = InitiativeEpic.objects.filter(initiative=initiative, epic_id__in=epic_ids).values_list(
"epic_id", flat=True
)
# Convert UUIDs to strings for proper comparison
existing_epic_ids = [str(uuid_id) for uuid_id in existing_epic_ids]
new_epic_ids = set(epic_ids) - set(existing_epic_ids)
for epic_id in new_epic_ids:
epic = Issue.objects.get(id=epic_id)
InitiativeEpic.objects.create(initiative=initiative, epic=epic, workspace_id=initiative.workspace_id)
# send new epics in response
new_epics = Issue.objects.filter(id__in=epic_ids)
serializer = IssueSerializer(new_epics, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@initiative_entity_docs(
operation_id="remove_initiative_epics",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_ID_PARAMETER,
],
summary="Remove epics from an initiative",
description="Remove epics from an initiative by its ID",
request=OpenApiRequest(
request={
"type": "object",
"properties": {
"epic_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
},
}
),
responses={204: DELETED_RESPONSE},
)
def remove_epics(self, request, slug, initiative_id):
initiative = self.get_queryset().get(id=initiative_id)
epic_ids = request.data.get("epic_ids", [])
for epic_id in epic_ids:
epic = Issue.objects.get(id=epic_id)
InitiativeEpic.objects.filter(initiative=initiative, epic=epic).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class InitiativeLabelViewSet(BaseViewSet):
serializer_class = InitiativeLabelSerializer
model = InitiativeLabel
permission_classes = [WorkSpaceAdminPermission]
def get_queryset(self):
return InitiativeLabel.objects.filter(workspace__slug=self.kwargs.get("slug")).order_by("sort_order")
@initiative_entity_docs(
operation_id="create_initiative_label",
summary="Create a new initiative label",
description="Create a new initiative label in the workspace",
request=OpenApiRequest(request=InitiativeLabelSerializer),
responses={
201: OpenApiResponse(
description="Initiative label created",
response=InitiativeLabelSerializer,
examples=[INITIATIVE_LABEL_EXAMPLE],
)
},
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = InitiativeLabelSerializer(data=request.data, context={"workspace_id": workspace.id})
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@initiative_entity_docs(
operation_id="list_initiative_labels",
summary="List initiative labels",
description="List all initiative labels in the workspace",
parameters=[
WORKSPACE_SLUG_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
],
responses={
200: create_paginated_response(
InitiativeLabelSerializer,
"InitiativeLabel",
"List of initiative labels",
example_name="Paginated Initiative Labels",
)
},
)
def list(self, request, slug):
initiative_labels = self.get_queryset()
print("initiative_labels", initiative_labels)
paginated_data = self.paginate(
request=request,
queryset=initiative_labels,
on_results=lambda initiative_labels: InitiativeLabelSerializer(initiative_labels, many=True).data,
default_per_page=20,
)
return paginated_data
@initiative_entity_docs(
operation_id="retrieve_initiative_label",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_LABEL_ID_PARAMETER,
],
summary="Retrieve an initiative label",
description="Retrieve an initiative label by its ID",
responses={
200: OpenApiResponse(
description="Initiative label", response=InitiativeLabelSerializer, examples=[INITIATIVE_LABEL_EXAMPLE]
)
},
)
def retrieve(self, request, slug, pk):
initiative_label = self.get_queryset().get(id=pk)
return Response(InitiativeLabelSerializer(initiative_label).data, status=status.HTTP_200_OK)
@initiative_entity_docs(
operation_id="update_initiative_label",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_LABEL_ID_PARAMETER,
],
summary="Update an initiative label",
description="Update an initiative label by its ID",
request=OpenApiRequest(request=InitiativeLabelSerializer),
responses={
200: OpenApiResponse(
description="Initiative label", response=InitiativeLabelSerializer, examples=[INITIATIVE_LABEL_EXAMPLE]
)
},
)
def partial_update(self, request, slug, pk):
initiative_label = self.get_queryset().get(id=pk)
serializer = InitiativeLabelSerializer(
initiative_label, data=request.data, partial=True, context={"workspace_id": initiative_label.workspace_id}
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@initiative_entity_docs(
operation_id="delete_initiative_label",
parameters=[
WORKSPACE_SLUG_PARAMETER,
INITIATIVE_LABEL_ID_PARAMETER,
],
summary="Delete an initiative label",
description="Delete an initiative label by its ID",
responses={204: DELETED_RESPONSE},
)
def destroy(self, request, slug, pk):
initiative_label = self.get_queryset().get(id=pk)
initiative_label.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+44 -5
View File
@@ -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,8 +23,18 @@ 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
from plane.db.models import (
Intake,
IntakeIssue,
Issue,
Project,
ProjectMember,
State,
IssueType,
)
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 (
@@ -165,6 +175,11 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
]:
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()
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -172,6 +187,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
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
@@ -295,6 +311,16 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
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"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the intake issue
intake_issue = IntakeIssue.objects.get(
issue_id=issue_id,
@@ -344,6 +370,19 @@ 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 = {
+636 -9
View File
@@ -1,7 +1,7 @@
# Python imports
import json
import uuid
import re
import uuid
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
@@ -40,8 +40,14 @@ from plane.api.serializers import (
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssueDetailSerializer,
IssueLinkSerializer,
IssueSerializer,
IssueRelationSerializer,
IssueRelationCreateSerializer,
IssueRelationRemoveSerializer,
IssueRelationResponseSerializer,
RelatedIssueSerializer,
LabelSerializer,
IssueAttachmentUploadSerializer,
IssueSearchSerializer,
@@ -62,6 +68,7 @@ from plane.db.models import (
FileAsset,
IssueComment,
IssueLink,
IssueRelation,
Label,
Project,
ProjectMember,
@@ -72,10 +79,13 @@ from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.utils.issue_relation_mapper import get_actual_relation
from plane.bgtasks.webhook_task import model_activity
from plane.app.permissions import ROLE
from plane.utils.openapi import (
work_item_docs,
issue_docs,
label_docs,
issue_link_docs,
issue_comment_docs,
@@ -141,6 +151,12 @@ from plane.utils.openapi import (
WORKSPACE_NOT_FOUND_RESPONSE,
)
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
from plane.payment.flags.flag import FeatureFlag
from plane.ee.utils.workflow import WorkflowStateManager
from plane.ee.bgtasks.entity_issue_state_progress_task import (
entity_issue_state_activity_task,
)
def user_has_issue_permission(user_id, project_id, issue=None, allowed_roles=None, allow_creator=True):
@@ -190,6 +206,7 @@ class WorkspaceIssueAPIEndpoint(BaseAPIView):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related("type")
.order_by(self.kwargs.get("order_by", "-created_at"))
).distinct()
@@ -218,6 +235,25 @@ class WorkspaceIssueAPIEndpoint(BaseAPIView):
Retrieve a specific work item using workspace slug, project identifier, and issue identifier.
This endpoint provides workspace-level access to work items.
"""
# ee start
# epics support
if request.GET.get("include_epics") == "true" and issue_identifier and project_identifier:
issue = Issue.issue_and_epics_objects.annotate(
sub_issues_count=Issue.issue_and_epics_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
).get(
workspace__slug=slug,
project__identifier=project_identifier,
sequence_id=issue_identifier,
)
return Response(
IssueSerializer(issue, fields=self.fields, expand=self.expand).data,
status=status.HTTP_200_OK,
)
# ee end
if issue_identifier and project_identifier:
issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
@@ -423,6 +459,15 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
"default_assignee_id": project.default_assignee_id,
},
)
if request.data.get("state_id"):
workflow_state_manager = WorkflowStateManager(project_id=project_id, slug=slug)
if workflow_state_manager.validate_issue_creation(
state_id=request.data.get("state_id"), user_id=request.user.id
):
return Response(
{"error": "You cannot create a work item in this state"},
status=status.HTTP_403_FORBIDDEN,
)
if serializer.is_valid():
if (
@@ -524,7 +569,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
responses={
200: OpenApiResponse(
description="List of issues or issue details",
response=IssueSerializer,
response=IssueDetailSerializer,
examples=[ISSUE_EXAMPLE],
),
400: INVALID_REQUEST_RESPONSE,
@@ -544,8 +589,15 @@ class IssueDetailAPIEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
).get(workspace__slug=slug, project_id=project_id, pk=pk)
# Ensure labels and assignees are always expanded for issue details
# this is required until we find a better way to create issue detail serializer
expand = self.expand or []
required_expansions = {"labels", "assignees"}
expand = list(set(expand) | required_expansions)
return Response(
IssueSerializer(issue, fields=self.fields, expand=self.expand).data,
IssueDetailSerializer(issue, fields=self.fields, expand=expand).data,
status=status.HTTP_200_OK,
)
@@ -612,6 +664,19 @@ class IssueDetailAPIEndpoint(BaseAPIView):
},
partial=True,
)
# 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,
)
if serializer.is_valid():
# If the serializer is valid, save the issue and dispatch
# the update issue activity worker event.
@@ -625,6 +690,19 @@ class IssueDetailAPIEndpoint(BaseAPIView):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
cycle_issue = CycleIssue.objects.filter(issue_id=issue.id).first()
if cycle_issue and (request.data.get("state_id") or request.data.get("estimate_point")):
entity_issue_state_activity_task.delay(
issue_cycle_data=[
{
"issue_id": str(issue.id),
"cycle_id": str(cycle_issue.cycle_id),
}
],
user_id=str(request.user.id),
slug=slug,
action="UPDATED",
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
# If the serializer is not valid, respond with 400 bad
@@ -663,6 +741,19 @@ class IssueDetailAPIEndpoint(BaseAPIView):
issue.created_at = request.data.get("created_at", timezone.now())
issue.created_by_id = request.data.get("created_by", request.user.id)
issue.save(update_fields=["created_at", "created_by"])
cycle_issue = CycleIssue.objects.filter(issue_id=issue.id).first()
if cycle_issue and (request.data.get("state_id") or request.data.get("estimate_point")):
entity_issue_state_activity_task.delay(
issue_cycle_data=[
{
"issue_id": str(issue.id),
"cycle_id": str(cycle_issue.cycle_id),
}
],
user_id=str(request.user.id),
slug=slug,
action="UPDATED",
)
issue_activity.delay(
type="issue.activity.created",
@@ -719,6 +810,19 @@ class IssueDetailAPIEndpoint(BaseAPIView):
context={"project_id": project_id, "workspace_id": project.workspace_id},
partial=True,
)
# 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,
)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -748,6 +852,19 @@ class IssueDetailAPIEndpoint(BaseAPIView):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
cycle_issue = CycleIssue.objects.filter(issue_id=pk).first()
if cycle_issue and (request.data.get("state_id") or request.data.get("estimate_point")):
entity_issue_state_activity_task.delay(
issue_cycle_data=[
{
"issue_id": str(issue.id),
"cycle_id": str(cycle_issue.cycle_id),
}
],
user_id=str(request.user.id),
slug=slug,
action="UPDATED",
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -785,6 +902,16 @@ class IssueDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder)
cycle_issue = CycleIssue.objects.filter(issue_id=pk).first()
if cycle_issue:
# added a entry to remove from the entity issue state activity
entity_issue_state_activity_task.delay(
issue_cycle_data=[{"issue_id": str(issue.id), "cycle_id": str(cycle_issue.cycle_id)}],
user_id=str(request.user.id),
slug=slug,
action="REMOVED",
)
# EE code end here
issue.delete()
issue_activity.delay(
type="issue.activity.deleted",
@@ -958,6 +1085,7 @@ class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint):
@label_docs(
operation_id="update_label",
summary="Update a label",
description="Partially update an existing label's properties like name, color, or description.",
parameters=[
LABEL_ID_PARAMETER,
@@ -1013,6 +1141,7 @@ class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint):
@label_docs(
operation_id="delete_label",
summary="Delete a label",
description="Permanently remove a label from the project. This action cannot be undone.",
parameters=[
LABEL_ID_PARAMETER,
@@ -1119,7 +1248,7 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView):
serializer = IssueLinkCreateSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(serializer.instance.id, serializer.instance.url)
crawl_work_item_link_title.delay(serializer.instance.id, serializer.instance.url, "issue")
link = IssueLink.objects.get(pk=serializer.instance.id)
link.created_by_id = request.data.get("created_by", request.user.id)
link.save(update_fields=["created_by"])
@@ -1202,6 +1331,7 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
@issue_link_docs(
operation_id="update_issue_link",
summary="Update an issue link",
description="Modify the URL, title, or metadata of an existing issue link.",
parameters=[
ISSUE_ID_PARAMETER,
@@ -1233,7 +1363,7 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url"))
crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url"), "issue")
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
@@ -1340,6 +1470,23 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
Retrieve all comments for a work item.
"""
external_id = request.GET.get("external_id")
external_source = request.GET.get("external_source")
if external_id and external_source:
try:
issue_comment = IssueComment.objects.get(
external_id=external_id,
external_source=external_source,
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
)
serializer = IssueCommentSerializer(issue_comment, fields=self.fields, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
except IssueComment.DoesNotExist:
return Response({"error": "Comment not found"}, status=status.HTTP_404_NOT_FOUND)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
@@ -1408,7 +1555,7 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
issue_comment.created_at = request.data.get("created_at", timezone.now())
issue_comment.created_by_id = request.data.get("created_by", request.user.id)
issue_comment.actor_id = request.data.get("created_by", request.user.id)
issue_comment.save(update_fields=["created_at", "created_by"])
issue_comment.save(update_fields=["created_at", "created_by", "actor"])
issue_activity.delay(
type="comment.activity.created",
@@ -1430,7 +1577,6 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
slug=slug,
origin=base_host(request=request, is_app=True),
)
serializer = IssueCommentSerializer(issue_comment)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1559,6 +1705,7 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
# Send the model activity
model_activity.delay(
model_name="issue_comment",
@@ -1569,7 +1716,6 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
slug=slug,
origin=base_host(request=request, is_app=True),
)
issue_comment = IssueComment.objects.get(pk=serializer.instance.id)
serializer = IssueCommentSerializer(issue_comment)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -1829,7 +1975,15 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
size_limit = min(size, settings.FILE_SIZE_LIMIT)
# Check if the file size is greater than the limit
if check_workspace_feature_flag(
feature_key=FeatureFlag.FILE_SIZE_LIMIT_PRO,
slug=slug,
user_id=str(request.user.id),
):
size_limit = min(size, settings.PRO_FILE_SIZE_LIMIT)
else:
size_limit = min(size, settings.FILE_SIZE_LIMIT)
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response(
@@ -2138,6 +2292,183 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueAttachmentServerEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
permission_classes = [ProjectEntityPermission]
model = FileAsset
use_read_replica = True
def post(self, request, slug, project_id, issue_id):
name = request.data.get("name")
type = request.data.get("type", False)
size = request.data.get("size")
external_id = request.data.get("external_id")
external_source = request.data.get("external_source")
# Check if the request is valid
if not name or not size:
return Response(
{"error": "Invalid request.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the file size is greater than the limit
if check_workspace_feature_flag(
feature_key=FeatureFlag.FILE_SIZE_LIMIT_PRO,
slug=slug,
user_id=str(request.user.id),
):
size_limit = min(size, settings.PRO_FILE_SIZE_LIMIT)
else:
size_limit = min(size, settings.FILE_SIZE_LIMIT)
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response(
{"error": "Invalid file type.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
if (
request.data.get("external_id")
and request.data.get("external_source")
and FileAsset.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
).exists()
):
asset = FileAsset.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
).first()
return Response(
{
"error": "Issue with the same external id and external source already exists",
"id": str(asset.id),
},
status=status.HTTP_409_CONFLICT,
)
# Create a File Asset
asset = FileAsset.objects.create(
attributes={"name": name, "type": type, "size": size_limit},
asset=asset_key,
size=size_limit,
workspace_id=workspace.id,
created_by=request.user,
issue_id=issue_id,
project_id=project_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
external_id=external_id,
external_source=external_source,
)
# 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)
# Return the presigned URL
return Response(
{
"upload_data": presigned_url,
"asset_id": str(asset.id),
"attachment": IssueAttachmentSerializer(asset).data,
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
issue_attachment.is_deleted = True
issue_attachment.deleted_at = timezone.now()
issue_attachment.save()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Get the storage metadata
if not issue_attachment.storage_metadata:
get_asset_object_metadata.delay(str(issue_attachment.id))
issue_attachment.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def get(self, request, slug, project_id, issue_id, pk=None):
if pk:
# Get the asset
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
# Check if the asset is uploaded
if not asset.is_uploaded:
return Response(
{"error": "The asset is not uploaded.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
storage = S3Storage(request=request)
presigned_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
return HttpResponseRedirect(presigned_url)
def patch(self, request, slug, project_id, issue_id, pk):
"""Confirm attachment upload
Mark an attachment as uploaded after successful file transfer to storage.
Triggers activity logging and metadata extraction.
"""
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
serializer = IssueAttachmentSerializer(issue_attachment)
# Send this activity only if the attachment is not uploaded before
if not issue_attachment.is_uploaded:
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Update the attachment
issue_attachment.is_uploaded = True
issue_attachment.created_by = request.user
# Get the storage metadata
if not issue_attachment.storage_metadata:
get_asset_object_metadata.delay(str(issue_attachment.id))
issue_attachment.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueSearchEndpoint(BaseAPIView):
"""Endpoint to search across multiple fields in the issues"""
@@ -2213,6 +2544,302 @@ class IssueSearchEndpoint(BaseAPIView):
"project__identifier",
"project_id",
"workspace__slug",
"type_id",
)[: int(limit)]
return Response({"issues": issue_results}, status=status.HTTP_200_OK)
class IssueRelationListCreateAPIEndpoint(BaseAPIView):
"""Issue Relation List and Create Endpoint"""
serializer_class = IssueRelationSerializer
model = IssueRelation
permission_classes = [ProjectEntityPermission]
@issue_docs(
operation_id="list_work_item_relations",
summary="List work item relations",
description="Retrieve all relationships for a work item including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after relations.", # noqa E501
parameters=[
ISSUE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Work item relations grouped by relation type",
response=IssueRelationResponseSerializer,
examples=[
OpenApiExample(
name="Work Item Relations Response",
value={
"blocking": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
],
"blocked_by": ["550e8400-e29b-41d4-a716-446655440002"],
"duplicate": [],
"relates_to": ["550e8400-e29b-41d4-a716-446655440003"],
"start_after": [],
"start_before": ["550e8400-e29b-41d4-a716-446655440004"],
"finish_after": [],
"finish_before": [],
},
)
],
),
400: INVALID_REQUEST_RESPONSE,
404: ISSUE_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug, project_id, issue_id):
"""List work item relations
Retrieve all relationships for a work item organized by relation type.
Returns a structured response with relations grouped by type.
"""
# Fetch all issue relations in a single query
issue_relations = list(
IssueRelation.objects.filter(Q(issue_id=issue_id) | Q(related_issue=issue_id))
.filter(workspace__slug=slug)
.select_related("project", "workspace", "issue", "related_issue")
.order_by("-created_at")
.distinct()
)
# Initialize lists for different relation types
blocking_issues = []
blocked_by_issues = []
duplicate_issues = []
relates_to_issues = []
start_after_issues = []
start_before_issues = []
finish_after_issues = []
finish_before_issues = []
# Process relations on application side to avoid N+1 queries
for relation in issue_relations:
if relation.relation_type == "blocked_by":
if relation.related_issue_id == issue_id:
# This issue is blocked by the related issue
blocking_issues.append(relation.issue_id)
elif relation.issue_id == issue_id:
# This issue blocks the related issue
blocked_by_issues.append(relation.related_issue_id)
elif relation.relation_type == "duplicate":
if relation.issue_id == issue_id:
duplicate_issues.append(relation.related_issue_id)
elif relation.related_issue_id == issue_id:
duplicate_issues.append(relation.issue_id)
elif relation.relation_type == "relates_to":
if relation.issue_id == issue_id:
relates_to_issues.append(relation.related_issue_id)
elif relation.related_issue_id == issue_id:
relates_to_issues.append(relation.issue_id)
elif relation.relation_type == "start_before":
if relation.related_issue_id == issue_id:
# The related issue starts after this issue
start_after_issues.append(relation.issue_id)
elif relation.issue_id == issue_id:
# This issue starts before the related issue
start_before_issues.append(relation.related_issue_id)
elif relation.relation_type == "finish_before":
if relation.related_issue_id == issue_id:
# The related issue finishes after this issue
finish_after_issues.append(relation.issue_id)
elif relation.issue_id == issue_id:
# This issue finishes before the related issue
finish_before_issues.append(relation.related_issue_id)
response_data = {
"blocking": blocking_issues,
"blocked_by": blocked_by_issues,
"duplicate": duplicate_issues,
"relates_to": relates_to_issues,
"start_after": start_after_issues,
"start_before": start_before_issues,
"finish_after": finish_after_issues,
"finish_before": finish_before_issues,
}
return Response(response_data, status=status.HTTP_200_OK)
@issue_docs(
operation_id="create_work_item_relation",
summary="Create work item relation",
description="Create relationships between work items. Supports various relation types including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after.", # noqa E501
parameters=[
ISSUE_ID_PARAMETER,
],
request=OpenApiRequest(
request=IssueRelationCreateSerializer,
examples=[
OpenApiExample(
name="Create blocking relation",
value={
"relation_type": "blocking",
"issues": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
],
},
)
],
),
responses={
201: OpenApiResponse(
description="Work item relations created successfully",
response=IssueRelationSerializer(many=True),
examples=[
OpenApiExample(
name="Relations created",
value=[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Fix authentication bug",
"sequence_id": 42,
"project_id": "550e8400-e29b-41d4-a716-446655440001",
"relation_type": "blocked_by",
"state_id": "550e8400-e29b-41d4-a716-446655440002",
"priority": "high",
"type_id": "550e8400-e29b-41d4-a716-446655440003",
"is_epic": False,
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-15T10:00:00Z",
"created_by": "550e8400-e29b-41d4-a716-446655440004",
"updated_by": "550e8400-e29b-41d4-a716-446655440004",
}
],
)
],
),
400: INVALID_REQUEST_RESPONSE,
404: ISSUE_NOT_FOUND_RESPONSE,
},
)
def post(self, request, slug, project_id, issue_id):
"""Create work item relation
Create relationships between work items with specified relation type.
Automatically tracks relation creation activity.
"""
# Validate request data using serializer
serializer = IssueRelationCreateSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
relation_type = serializer.validated_data["relation_type"]
issues = serializer.validated_data["issues"]
project = Project.objects.get(pk=project_id)
issue_relation = IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=(issue if relation_type in ["blocking", "start_after", "finish_after"] else issue_id),
related_issue_id=(
issue_id if relation_type in ["blocking", "start_after", "finish_after"] else issue
),
relation_type=(get_actual_relation(relation_type)),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
issue_activity.delay(
type="issue_relation.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
if relation_type in ["blocking", "start_after", "finish_after"]:
return Response(
RelatedIssueSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
else:
return Response(
IssueRelationSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
class IssueRelationRemoveAPIEndpoint(BaseAPIView):
"""Issue Relation Remove Endpoint"""
permission_classes = [ProjectEntityPermission]
model = IssueRelation
@issue_docs(
operation_id="remove_work_item_relation",
summary="Remove work item relation",
description="Remove a relationship between work items by specifying the related work item ID.",
parameters=[
ISSUE_ID_PARAMETER,
],
request=OpenApiRequest(
request=IssueRelationRemoveSerializer,
examples=[
OpenApiExample(
name="Remove relation",
value={"related_issue": "550e8400-e29b-41d4-a716-446655440000"},
)
],
),
responses={
204: OpenApiResponse(description="Work item relation removed successfully"),
404: OpenApiResponse(description="Work item relation not found"),
},
)
def post(self, request, slug, project_id, issue_id):
"""Remove a work item relation
Remove a relationship between work items.
Records deletion activity for audit purposes.
"""
# Validate request data using serializer
serializer = IssueRelationRemoveSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
related_issue = serializer.validated_data["related_issue"]
issue_relations = IssueRelation.objects.filter(
workspace__slug=slug,
).filter(
Q(issue_id=related_issue, related_issue_id=issue_id) | Q(issue_id=issue_id, related_issue_id=related_issue)
)
issue_relations = issue_relations.first()
current_instance = json.dumps(IssueRelationSerializer(issue_relations).data, cls=DjangoJSONEncoder)
issue_relations.delete()
issue_activity.delay(
type="issue_relation.activity.deleted",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response(status=status.HTTP_204_NO_CONTENT)
+384
View File
@@ -0,0 +1,384 @@
# 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"),
)
# 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"),
)
# 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()
.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")
),
[],
)
)
.get(pk=type_id)
)
serializer = self.serializer_class(issue_type)
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)
+154 -3
View File
@@ -1,3 +1,12 @@
# 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
@@ -10,7 +19,7 @@ from drf_spectacular.utils import (
# Module imports
from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer, ProjectMemberSerializer
from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember
from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember, Project
from plane.utils.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission
from plane.utils.openapi import (
WORKSPACE_SLUG_PARAMETER,
@@ -23,6 +32,8 @@ 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]
@@ -87,7 +98,9 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
return Response(users_with_roles, status=status.HTTP_200_OK)
class ProjectMemberListCreateAPIEndpoint(BaseAPIView):
class ProjectMemberSiloEndpoint(BaseAPIView):
#TODO: Remove this endpoint once the silo is updated to use the new endpoint
permission_classes = [ProjectMemberPermission]
use_read_replica = True
@@ -105,7 +118,7 @@ class ProjectMemberListCreateAPIEndpoint(BaseAPIView):
responses={
200: OpenApiResponse(
description="List of project members with their roles",
response=UserLiteSerializer,
response=UserLiteSerializer(many=True),
examples=[PROJECT_MEMBER_EXAMPLE],
),
401: UNAUTHORIZED_RESPONSE,
@@ -126,7 +139,145 @@ class ProjectMemberListCreateAPIEndpoint(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
)
# Get all the users that are present inside the workspace
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)
class ProjectMemberListCreateAPIEndpoint(BaseAPIView):
permission_classes = [ProjectMemberPermission]
use_read_replica = True
def get_permissions(self):
if self.request.method == "GET":
return [ProjectMemberPermission()]
return [ProjectAdminPermission()]
@extend_schema(
operation_id="get_project_members",
summary="List project members",
description="Retrieve all users who are members of the specified project.",
tags=["Members"],
parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
responses={
200: OpenApiResponse(
description="List of project members with their roles",
response=UserLiteSerializer(many=True),
examples=[PROJECT_MEMBER_EXAMPLE],
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: PROJECT_NOT_FOUND_RESPONSE,
},
)
# Get all the users that are present inside the workspace
def get(self, request, slug, project_id):
"""List project members
Retrieve all users who are members of the specified project.
Returns user profiles with their project-specific roles and access levels.
"""
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"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
+16 -1
View File
@@ -265,6 +265,21 @@ 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)),
@@ -650,7 +665,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
responses={
200: OpenApiResponse(
description="Module issues added",
response=ModuleIssueSerializer,
response=ModuleIssueSerializer(many=True),
examples=[MODULE_ISSUE_EXAMPLE],
),
400: REQUIRED_FIELDS_RESPONSE,
+133 -1
View File
@@ -34,8 +34,9 @@ from plane.api.serializers import (
ProjectSerializer,
ProjectCreateSerializer,
ProjectUpdateSerializer,
ProjectFeatureSerializer,
)
from plane.app.permissions import ProjectBasePermission
from plane.app.permissions import ProjectBasePermission, ProjectMemberPermission
from plane.utils.openapi import (
project_docs,
PROJECT_ID_PARAMETER,
@@ -57,8 +58,12 @@ from plane.utils.openapi import (
DELETED_RESPONSE,
ARCHIVED_RESPONSE,
UNARCHIVED_RESPONSE,
WORKSPACE_SLUG_PARAMETER,
PROJECT_FEATURE_EXAMPLE,
)
from plane.ee.models import ProjectFeature
class ProjectListCreateAPIEndpoint(BaseAPIView):
"""Project List and Create Endpoint"""
@@ -156,6 +161,20 @@ 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"),
@@ -211,6 +230,27 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
workspace = Workspace.objects.get(slug=slug)
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
@@ -457,6 +497,26 @@ 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,
)
.exclude(id=pk)
.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()
@@ -584,3 +644,75 @@ class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
project.archived_at = None
project.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class ProjectFeatureAPIEndpoint(BaseAPIView):
permission_classes = [ProjectMemberPermission]
serializer_class = ProjectFeatureSerializer
def get_queryset(self):
project = Project.objects.filter(
workspace__slug=self.kwargs.get("slug"), id=self.kwargs.get("project_id")
).first()
# get or create the project feature
project_feature, _ = ProjectFeature.objects.get_or_create(project=project)
is_epic_enabled = project_feature.is_epic_enabled
return {
"epics": is_epic_enabled,
"modules": project.module_view,
"cycles": project.cycle_view,
"views": project.issue_views_view,
"pages": project.page_view,
"intakes": project.intake_view,
"work_item_types": project.is_issue_type_enabled,
}
@project_docs(
operation_id="get_project_features",
summary="Get project features",
description="Get the features of a project",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Project features",
response=ProjectFeatureSerializer,
examples=[PROJECT_FEATURE_EXAMPLE],
),
},
)
def get(self, request, slug, project_id):
project_features = self.get_queryset()
return Response(project_features, status=status.HTTP_200_OK)
@project_docs(
operation_id="update_project_features",
summary="Update project features",
description="Update the features of a project",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
],
request=OpenApiRequest(
request=ProjectFeatureSerializer,
examples=[PROJECT_FEATURE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Project features updated successfully",
response=ProjectFeatureSerializer,
examples=[PROJECT_FEATURE_EXAMPLE],
),
},
)
def patch(self, request, slug, project_id):
project_features = self.get_queryset()
serializer = ProjectFeatureSerializer(
project_features, data=request.data, partial=True, context={"slug": slug, "project_id": project_id}
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+15
View File
@@ -148,6 +148,21 @@ 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()),
+110
View File
@@ -0,0 +1,110 @@
from rest_framework.response import Response
from rest_framework import status
from plane.api.views.base import BaseViewSet
from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import Sticky, Workspace
from plane.api.serializers import StickySerializer
# OpenAPI imports
from plane.utils.openapi.decorators import sticky_docs
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
from plane.utils.openapi import (
STICKY_EXAMPLE,
create_paginated_response,
DELETED_RESPONSE,
)
class StickyViewSet(BaseViewSet):
serializer_class = StickySerializer
model = Sticky
use_read_replica = True
permission_classes = [WorkspaceUserPermission]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(owner_id=self.request.user.id)
.select_related("workspace", "owner")
.distinct()
)
@sticky_docs(
operation_id="create_sticky",
summary="Create a new sticky",
description="Create a new sticky in the workspace",
request=OpenApiRequest(request=StickySerializer),
responses={
201: OpenApiResponse(description="Sticky created", response=StickySerializer, examples=[STICKY_EXAMPLE])
},
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = StickySerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@sticky_docs(
operation_id="list_stickies",
summary="List stickies",
description="List all stickies in the workspace",
responses={
200: create_paginated_response(
StickySerializer, "Sticky", "List of stickies", example_name="List of stickies"
)
},
)
def list(self, request, slug):
query = request.query_params.get("query", False)
stickies = self.get_queryset().order_by("-created_at")
if query:
stickies = stickies.filter(description_stripped__icontains=query)
return self.paginate(
request=request,
queryset=(stickies),
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
default_per_page=20,
)
@sticky_docs(
operation_id="retrieve_sticky",
summary="Retrieve a sticky",
description="Retrieve a sticky by its ID",
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
)
def retrieve(self, request, slug, pk):
sticky = self.get_object()
return Response(StickySerializer(sticky).data)
@sticky_docs(
operation_id="update_sticky",
summary="Update a sticky",
description="Update a sticky by its ID",
request=OpenApiRequest(request=StickySerializer),
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
)
def partial_update(self, request, slug, pk):
sticky = self.get_object()
serializer = StickySerializer(sticky, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@sticky_docs(
operation_id="delete_sticky",
summary="Delete a sticky",
description="Delete a sticky by its ID",
responses={204: DELETED_RESPONSE},
)
def destroy(self, request, slug, pk):
sticky = self.get_object()
sticky.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+321
View File
@@ -0,0 +1,321 @@
from rest_framework.response import Response
from rest_framework import status
from plane.api.views.base import BaseViewSet
from plane.api.serializers import TeamspaceSerializer, ProjectSerializer, UserLiteSerializer
from plane.ee.models import Teamspace, TeamspaceProject, TeamspaceMember
from plane.db.models import Workspace, Project, User
from plane.app.permissions import WorkSpaceAdminPermission
from plane.api.permissions import TeamspaceFeatureFlagPermission
# OpenAPI imports
from plane.utils.openapi.decorators import teamspace_docs, teamspace_entity_docs
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
from plane.utils.openapi import (
TEAMSPACE_EXAMPLE,
create_paginated_response,
DELETED_RESPONSE,
PROJECT_EXAMPLE,
USER_EXAMPLE,
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER
)
class TeamspaceViewSet(BaseViewSet):
serializer_class = TeamspaceSerializer
model = Teamspace
permission_classes = [WorkSpaceAdminPermission, TeamspaceFeatureFlagPermission]
def get_queryset(self):
return Teamspace.objects.filter(workspace__slug=self.kwargs.get("slug"))
@teamspace_docs(
operation_id="create_teamspace",
summary="Create a new teamspace",
description="Create a new teamspace in the workspace",
request=OpenApiRequest(request=TeamspaceSerializer),
responses={
201: OpenApiResponse(
description="Teamspace created", response=TeamspaceSerializer, examples=[TEAMSPACE_EXAMPLE]
)
},
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = TeamspaceSerializer(
data=request.data, context={"workspace_id": workspace.id, "user": request.user}
)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@teamspace_docs(
operation_id="list_teamspaces",
summary="List teamspaces",
description="List all teamspaces in the workspace",
parameters=[
WORKSPACE_SLUG_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
],
responses={
200: create_paginated_response(
TeamspaceSerializer, "Teamspace", "List of teamspaces", example_name="Paginated Teamspaces"
)
},
)
def list(self, request, slug):
teamspaces = self.get_queryset()
return self.paginate(
request=request,
queryset=(teamspaces),
on_results=lambda teamspaces: TeamspaceSerializer(teamspaces, many=True).data,
default_per_page=20,
)
@teamspace_docs(
operation_id="retrieve_teamspace",
summary="Retrieve a teamspace",
description="Retrieve a teamspace by its ID",
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
],
responses={
200: OpenApiResponse(description="Teamspace", response=TeamspaceSerializer, examples=[TEAMSPACE_EXAMPLE])
},
)
def retrieve(self, request, slug, pk):
teamspace = self.get_queryset().get(id=pk)
return Response(TeamspaceSerializer(teamspace).data, status=status.HTTP_200_OK)
@teamspace_docs(
operation_id="update_teamspace",
summary="Update a teamspace",
description="Update a teamspace by its ID",
request=OpenApiRequest(request=TeamspaceSerializer),
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
],
responses={
200: OpenApiResponse(description="Teamspace", response=TeamspaceSerializer, examples=[TEAMSPACE_EXAMPLE])
},
)
def partial_update(self, request, slug, pk):
teamspace = self.get_queryset().get(id=pk)
serializer = TeamspaceSerializer(teamspace, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@teamspace_docs(
operation_id="delete_teamspace",
summary="Delete a teamspace",
description="Delete a teamspace by its ID",
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
],
responses={204: DELETED_RESPONSE},
)
def destroy(self, request, slug, pk):
teamspace = self.get_queryset().get(id=pk)
teamspace.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@teamspace_entity_docs(
operation_id="list_teamspace_projects",
summary="List teamspace projects",
description="List all projects in a teamspace",
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
],
responses={
200: create_paginated_response(
ProjectSerializer, "Project", "List of projects", example_name="Paginated Projects"
)
},
)
def get_projects(self, request, slug, teamspace_id):
teamspace = self.get_queryset().get(id=teamspace_id)
projects = Project.objects.filter(team_spaces__team_space=teamspace, team_spaces__deleted_at__isnull=True)
# paginate the projects
return self.paginate(
request=request,
queryset=(projects),
on_results=lambda projects: ProjectSerializer(projects, many=True).data,
default_per_page=20,
)
@teamspace_entity_docs(
operation_id="add_teamspace_projects",
summary="Add projects to a teamspace",
description="Add projects to a teamspace",
request=OpenApiRequest(request={"type": "object", "properties": {
"project_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
}}),
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
],
responses={
200: OpenApiResponse(description="Projects added", response=ProjectSerializer, examples=[PROJECT_EXAMPLE])
},
)
def add_projects(self, request, slug, teamspace_id):
teamspace = self.get_queryset().get(id=teamspace_id)
project_ids = request.data.get("project_ids", [])
# skip adding projects that are already associated with the initiative
existing_project_ids = TeamspaceProject.objects.filter(
team_space=teamspace, project_id__in=project_ids
).values_list("project_id", flat=True)
# Convert UUIDs to strings for proper comparison
existing_project_ids = [str(uuid_id) for uuid_id in existing_project_ids]
new_project_ids = set(project_ids) - set(existing_project_ids)
for project_id in new_project_ids:
project = Project.objects.get(id=project_id)
TeamspaceProject.objects.create(team_space=teamspace, project=project, workspace_id=teamspace.workspace_id)
# send new projects in response
new_projects = Project.objects.filter(id__in=project_ids)
serializer = ProjectSerializer(new_projects, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@teamspace_entity_docs(
operation_id="remove_teamspace_projects",
summary="Remove projects from a teamspace",
description="Remove projects from a teamspace by its ID",
request=OpenApiRequest(request={"type": "object", "properties": {
"project_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
}}),
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
],
responses={204: DELETED_RESPONSE},
)
def remove_projects(self, request, slug, teamspace_id):
teamspace = self.get_queryset().get(id=teamspace_id)
project_ids = request.data.get("project_ids", [])
for project_id in project_ids:
project = Project.objects.get(id=project_id)
TeamspaceProject.objects.filter(team_space=teamspace, project=project).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@teamspace_entity_docs(
operation_id="list_teamspace_members",
summary="List teamspace members",
description="List all members in a teamspace",
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
],
responses={
200: create_paginated_response(
UserLiteSerializer, "UserLite", "List of members", example_name="Paginated Members"
)
},
)
def get_members(self, request, slug, teamspace_id):
teamspace = self.get_queryset().get(id=teamspace_id)
members = User.objects.filter(team_spaces__team_space=teamspace, team_spaces__deleted_at__isnull=True)
return self.paginate(
request=request,
queryset=(members),
on_results=lambda members: UserLiteSerializer(members, many=True).data,
default_per_page=20,
)
@teamspace_entity_docs(
operation_id="add_teamspace_members",
summary="Add members to a teamspace",
description="Add members to a teamspace",
request=OpenApiRequest(request={"type": "object", "properties": {
"member_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
}}),
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
],
responses={
200: OpenApiResponse(description="Members added", response=UserLiteSerializer, examples=[USER_EXAMPLE])
},
)
def add_members(self, request, slug, teamspace_id):
teamspace = self.get_queryset().get(id=teamspace_id)
member_ids = request.data.get("member_ids", [])
# skip adding members that are already associated with the teamspace
existing_member_ids = TeamspaceMember.objects.filter(
team_space=teamspace, member_id__in=member_ids
).values_list("member_id", flat=True)
existing_member_ids = [str(uuid_id) for uuid_id in existing_member_ids]
new_member_ids = set(member_ids) - set(existing_member_ids)
for member_id in new_member_ids:
member = User.objects.get(id=member_id)
TeamspaceMember.objects.create(team_space=teamspace, member=member, workspace_id=teamspace.workspace_id)
# send new members in response
new_members = User.objects.filter(id__in=member_ids)
serializer = UserLiteSerializer(new_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@teamspace_entity_docs(
operation_id="remove_teamspace_members",
summary="Delete members from a teamspace",
description="Delete members from a teamspace by its ID",
request=OpenApiRequest(request={"type": "object", "properties": {
"member_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
}}),
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
],
responses={204: DELETED_RESPONSE},
)
def remove_members(self, request, slug, teamspace_id):
teamspace = self.get_queryset().get(id=teamspace_id)
member_ids = request.data.get("member_ids", [])
for member_id in member_ids:
member = User.objects.get(id=member_id)
TeamspaceMember.objects.filter(team_space=teamspace, member=member).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
-1
View File
@@ -10,7 +10,6 @@ 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
@@ -0,0 +1,279 @@
# Python imports
import re
from django.conf import settings
# Django imports
from django.db.models import (
Q,
)
# drf-spectacular imports
from drf_spectacular.utils import (
OpenApiExample,
OpenApiRequest,
OpenApiResponse,
extend_schema,
)
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.api.serializers import (
WorkItemAdvancedSearchRequestSerializer,
WorkItemAdvancedSearchResponseSerializer,
)
from plane.db.models import (
Issue,
)
# Module imports
from plane.payment.flags.flag import FeatureFlag
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
from plane.utils.filters import ComplexFilterBackend, IssueFilterSet
from plane.utils.openapi import (
INVALID_REQUEST_RESPONSE,
WORKSPACE_SLUG_PARAMETER,
)
from .base import BaseAPIView
if settings.OPENSEARCH_ENABLED:
from plane.ee.documents import IssueDocument
from plane.ee.utils.opensearch_helper import OpenSearchHelper
class WorkItemAdvancedSearchEndpoint(BaseAPIView):
"""Work Item Advanced Search Endpoint"""
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
@extend_schema(
operation_id="work_item_advanced_search",
summary="Work Item Advanced Search",
description="Search for work items with advanced filters and search query.",
parameters=[
WORKSPACE_SLUG_PARAMETER,
],
request=OpenApiRequest(
request=WorkItemAdvancedSearchRequestSerializer,
examples=[
OpenApiExample(
name="Basic text search in project",
value={
"query": "login",
"project_id": "11111111-1111-1111-1111-111111111111",
"limit": 10,
},
),
OpenApiExample(
name="Workspace search by assignee and state",
value={
"filters": {
"and": [
{"assignee_id": "22222222-2222-2222-2222-222222222222"},
{"state_group__in": ["started", "unstarted"]},
{"priority__in": ["high", "urgent"]},
]
},
"workspace_search": True,
"limit": 20,
},
),
OpenApiExample(
name="Filter by date range and labels",
value={
"filters": {
"and": [
{"target_date__range": ["2025-01-01", "2025-01-31"]},
{
"label_id__in": [
"33333333-3333-3333-3333-333333333333",
"44444444-4444-4444-4444-444444444444",
]
},
{"module_id": "55555555-5555-5555-5555-555555555555"},
{"is_archived": False},
]
},
"project_id": "66666666-6666-6666-6666-666666666666",
"limit": 15,
},
),
OpenApiExample(
name="Nested and/or with not",
value={
"filters": {
"and": [
{
"or": [
{"assignee_id": "77777777-7777-7777-7777-777777777777"},
{"created_by_id": "88888888-8888-8888-8888-888888888888"},
]
},
{"not": {"state_group": "completed"}},
{"priority__in": ["high", "urgent"]},
]
},
"workspace_search": True,
"limit": 25,
},
),
OpenApiExample(
name="Deeply nested groups across cycle/module/date",
value={
"filters": {
"or": [
{
"and": [
{
"cycle_id__in": [
"99999999-9999-9999-9999-999999999999",
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
]
},
{"target_date__range": ["2025-02-01", "2025-02-28"]},
]
},
{
"and": [
{
"module_id__in": [
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"cccccccc-cccc-cccc-cccc-cccccccccccc",
]
},
{
"state_id__in": [
"dddddddd-dddd-dddd-dddd-dddddddddddd",
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
]
},
{"is_archived": False},
]
},
]
},
"project_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
"limit": 30,
},
),
],
),
responses={
200: OpenApiResponse(
description="Work item advanced search results",
response=WorkItemAdvancedSearchResponseSerializer(many=True),
examples=[
OpenApiExample(
name="Work Item Advanced Search Results",
value=[
{
"id": "0f5e2c9a-1b2c-4d5e-8f01-23456789abcd",
"name": "Fix login redirect loop",
"sequence_id": 102,
"project_identifier": "WEB",
"project_id": "11111111-1111-1111-1111-111111111111",
"workspace_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"type_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"state_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"priority": "high",
"target_date": "2025-01-15",
"start_date": "2025-01-10",
},
{
"id": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f890",
"name": "Update billing email validation",
"sequence_id": 245,
"project_identifier": "API",
"project_id": "22222222-2222-2222-2222-222222222222",
"workspace_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"type_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"state_id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"priority": "medium",
"target_date": None,
"start_date": "2025-01-05",
},
],
)
],
),
400: INVALID_REQUEST_RESPONSE,
},
)
def post(self, request, slug):
"""Work Item Advanced Search"""
query = request.data.get("query", False)
filters = request.data.get("filters", False)
limit = request.data.get("limit", 25)
workspace_search = request.data.get("workspace_search", False)
project_id = request.data.get("project_id", False)
if not (query or filters):
return Response([], status=status.HTTP_200_OK)
q = Q()
if query:
opensearch_enabled = settings.OPENSEARCH_ENABLED and check_workspace_feature_flag(
FeatureFlag.ADVANCED_SEARCH, slug, self.request.user.id
)
if opensearch_enabled:
opensearch_filters = [
{"workspace_slug": slug},
{"active_project_member_user_ids": self.request.user.id},
{"project_is_archived": False},
]
if project_id:
opensearch_filters.append({"project_id": project_id})
fields_to_retrieve = ["id"]
boosts = {"name": 1.25, "description": 1.0}
search_fields = ["name", "description", "project_identifier", "pretty_sequence", "sequence_id"]
helper = OpenSearchHelper(
document_cls=IssueDocument,
filters=opensearch_filters,
query=query,
search_fields=search_fields,
source_fields=fields_to_retrieve,
page=1,
page_size=100,
boosts=boosts,
operator="and", # Use AND operator for stricter matching
)
issues = helper.execute()
q |= Q(**{"id__in": [issue["id"] for issue in issues]})
else:
# Build search query
fields = ["name", "sequence_id", "project__identifier"]
for field in fields:
if field == "sequence_id":
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
# Filter issues
issues = (
Issue.issue_and_epics_objects.filter(q).select_related("project").accessible_to(self.request.user.id, slug)
)
# Apply project filter if not searching across workspace
if (not workspace_search) and project_id:
issues = issues.filter(project_id=project_id)
if filters:
# Pass filters from request body explicitly to ComplexFilterBackend
backend = ComplexFilterBackend()
issues = backend.filter_queryset(request, issues, self, filter_data=filters)
issues = issues.distinct()[: int(limit)]
issue_results = WorkItemAdvancedSearchResponseSerializer(issues, many=True).data
return Response(issue_results, status=status.HTTP_200_OK)
+79
View File
@@ -0,0 +1,79 @@
from rest_framework.response import Response
from rest_framework import status
from plane.api.views.base import BaseAPIView
from plane.api.serializers import WorkspaceFeatureSerializer
from plane.ee.models import WorkspaceFeature
from plane.db.models import Workspace
from plane.app.permissions import WorkSpaceAdminPermission
# OpenAPI imports
from plane.utils.openapi.decorators import workspace_docs
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
from plane.utils.openapi import WORKSPACE_FEATURE_EXAMPLE
class WorkspaceFeatureAPIEndpoint(BaseAPIView):
permission_classes = [WorkSpaceAdminPermission]
serializer_class = WorkspaceFeatureSerializer
def get_queryset(self, slug):
workspace = Workspace.objects.filter(slug=slug).first()
if not workspace:
return None
workspace_feature, _ = WorkspaceFeature.objects.get_or_create(workspace=workspace)
return {
"project_grouping": workspace_feature.is_project_grouping_enabled,
"initiatives": workspace_feature.is_initiative_enabled,
"teams": workspace_feature.is_teams_enabled,
"customers": workspace_feature.is_customer_enabled,
"wiki": workspace_feature.is_wiki_enabled,
"pi": workspace_feature.is_pi_enabled,
}
@workspace_docs(
operation_id="get_workspace_features",
summary="Get workspace features",
description="Get the features of a workspace",
responses={
200: OpenApiResponse(
description="Workspace features",
response=WorkspaceFeatureSerializer,
examples=[WORKSPACE_FEATURE_EXAMPLE],
)
},
)
def get(self, request, slug):
workspace_features = self.get_queryset(slug)
if workspace_features is None:
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
return Response(workspace_features, status=status.HTTP_200_OK)
@workspace_docs(
operation_id="update_workspace_features",
summary="Update workspace features",
description="Update the features of a workspace",
request=OpenApiRequest(request=WorkspaceFeatureSerializer, examples=[WORKSPACE_FEATURE_EXAMPLE]),
responses={
200: OpenApiResponse(
description="Workspace features",
response=WorkspaceFeatureSerializer,
examples=[WORKSPACE_FEATURE_EXAMPLE],
)
},
)
def patch(self, request, slug):
workspace = Workspace.objects.filter(slug=slug).first()
if not workspace:
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
workspace_features = self.get_queryset(slug)
serializer = WorkspaceFeatureSerializer(
workspace_features, data=request.data, partial=True, context={"slug": slug, "workspace_id": workspace.id}
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -0,0 +1,7 @@
from rest_framework.authentication import SessionAuthentication
class BaseSessionAuthentication(SessionAuthentication):
# Disable csrf for the rest apis
def enforce_csrf(self, request):
return
+55 -19
View File
@@ -1,9 +1,16 @@
from plane.db.models import WorkspaceMember, ProjectMember
# Python imports
from enum import Enum
from functools import wraps
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from enum import Enum
# 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
class ROLE(Enum):
@@ -12,13 +19,19 @@ class ROLE(Enum):
GUEST = 5
def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
def allow_permission(
allowed_roles,
level="PROJECT",
creator=False,
field="created_by",
model=None,
):
def decorator(view_func):
@wraps(view_func)
def _wrapped_view(instance, request, *args, **kwargs):
# Check for creator if required
# Check for ownership if required
if creator and model:
obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists()
obj = model.objects.filter(id=kwargs["pk"], **{field: request.user}).exists()
if obj:
return view_func(instance, request, *args, **kwargs)
@@ -46,21 +59,44 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
# 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)
elif (
ProjectMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
project_id=kwargs["project_id"],
is_active=True,
).exists()
and WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
role=ROLE.ADMIN.value,
is_active=True,
).exists()
# 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,
):
return view_func(instance, request, *args, **kwargs)
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 permission denied if no conditions are met
return Response(
+108 -9
View File
@@ -1,13 +1,60 @@
# 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
class ProjectBasePermission(BasePermission):
def has_permission(self, request, view):
"""
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:
if request.user.is_anonymous:
return False
@@ -50,7 +97,15 @@ class ProjectBasePermission(BasePermission):
class ProjectMemberPermission(BasePermission):
def has_permission(self, request, view):
"""
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:
if request.user.is_anonymous:
return False
@@ -69,7 +124,7 @@ class ProjectMemberPermission(BasePermission):
).exists()
## Only Project Admins can update project attributes
return ProjectMember.objects.filter(
is_project_member = ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
@@ -77,33 +132,59 @@ class ProjectMemberPermission(BasePermission):
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):
def has_permission(self, request, view):
"""
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:
if request.user.is_anonymous:
return False
# Handle requests based on project__identifier
if hasattr(view, "project_identifier") and view.project_identifier:
if request.method in SAFE_METHODS:
return ProjectMember.objects.filter(
is_project_member = 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:
return ProjectMember.objects.filter(
is_project_member = 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
return ProjectMember.objects.filter(
is_project_member = ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
@@ -111,6 +192,11 @@ class ProjectEntityPermission(BasePermission):
is_active=True,
).exists()
if is_project_member:
return True
else:
return check_teamspace_membership(view=view, request=request)
class ProjectAdminPermission(BasePermission):
def has_permission(self, request, view):
@@ -127,13 +213,26 @@ class ProjectAdminPermission(BasePermission):
class ProjectLitePermission(BasePermission):
def has_permission(self, request, view):
"""
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:
if request.user.is_anonymous:
return False
return ProjectMember.objects.filter(
is_project_member = 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)
+18 -2
View File
@@ -1,4 +1,4 @@
from .base import BaseSerializer
from .base import BaseSerializer, DynamicBaseSerializer
from .user import (
UserSerializer,
UserLiteSerializer,
@@ -23,6 +23,7 @@ from .workspace import (
WorkspaceRecentVisitSerializer,
WorkspaceHomePreferenceSerializer,
StickySerializer,
WorkspaceUserMeSerializer,
)
from .project import (
ProjectSerializer,
@@ -33,7 +34,6 @@ from .project import (
ProjectIdentifierSerializer,
ProjectLiteSerializer,
ProjectMemberLiteSerializer,
DeployBoardSerializer,
ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer,
ProjectMemberRoleSerializer,
@@ -45,12 +45,14 @@ from .cycle import (
CycleIssueSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
EntityProgressSerializer,
)
from .asset import FileAssetSerializer
from .issue import (
IssueCreateSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssueCommentReplySerializer,
IssueUserPropertySerializer,
IssueAssigneeSerializer,
LabelSerializer,
@@ -75,6 +77,7 @@ from .issue import (
IssueVersionDetailSerializer,
IssueDescriptionVersionDetailSerializer,
IssueListDetailSerializer,
IssueDuplicateSerializer,
)
from .module import (
@@ -92,10 +95,12 @@ from .importer import ImporterSerializer
from .page import (
PageSerializer,
PageLiteSerializer,
PageDetailSerializer,
PageVersionSerializer,
PageBinaryUpdateSerializer,
PageVersionDetailSerializer,
PageUserSerializer,
)
from .estimate import (
@@ -128,3 +133,14 @@ from .draft import (
DraftIssueSerializer,
DraftIssueDetailSerializer,
)
from .integration import (
IntegrationSerializer,
WorkspaceIntegrationSerializer,
GithubIssueSyncSerializer,
GithubRepositorySerializer,
GithubRepositorySyncSerializer,
GithubCommentSyncSerializer,
SlackProjectSyncSerializer,
)
from .deploy_board import DeployBoardSerializer
+1
View File
@@ -3,6 +3,7 @@ from rest_framework import serializers
class BaseSerializer(serializers.ModelSerializer):
id = serializers.PrimaryKeyRelatedField(read_only=True)
deleted_at = serializers.ReadOnlyField()
class DynamicBaseSerializer(BaseSerializer):
+15 -1
View File
@@ -3,9 +3,11 @@ 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):
@@ -36,7 +38,13 @@ class CycleWriteSerializer(BaseSerializer):
class Meta:
model = Cycle
fields = "__all__"
read_only_fields = ["workspace", "project", "owned_by", "archived_at"]
read_only_fields = [
"workspace",
"project",
"owned_by",
"archived_at",
"version",
]
class CycleSerializer(BaseSerializer):
@@ -100,3 +108,9 @@ class CycleUserPropertiesSerializer(BaseSerializer):
model = CycleUserProperties
fields = "__all__"
read_only_fields = ["workspace", "project", "cycle", "user"]
class EntityProgressSerializer(BaseSerializer):
class Meta:
model = EntityProgress
fields = "__all__"
@@ -0,0 +1,15 @@
# 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"]
+18 -1
View File
@@ -12,6 +12,7 @@ from plane.db.models import (
Label,
State,
DraftIssue,
IssueType,
DraftIssueAssignee,
DraftIssueLabel,
DraftIssueCycle,
@@ -34,6 +35,9 @@ 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,
@@ -145,8 +149,21 @@ 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)
issue = DraftIssue.objects.create(
**validated_data, workspace_id=workspace_id, project_id=project_id, type=issue_type
)
# Issue Audit Users
created_by_id = issue.created_by_id
@@ -1,6 +1,7 @@
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):
@@ -39,6 +40,12 @@ 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),
@@ -47,6 +54,7 @@ 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))
+111 -2
View File
@@ -1,3 +1,6 @@
# Python imports
from collections import defaultdict
# Third party frameworks
from rest_framework import serializers
@@ -10,6 +13,103 @@ from .user import UserLiteSerializer
from plane.db.models import Intake, IntakeIssue, Issue
def _extract_property_value(property_value):
property_obj = getattr(property_value, "property", None)
property_type = getattr(property_obj, "property_type", None)
if property_type in {"TEXT", "URL", "EMAIL", "FILE"}:
return property_value.value_text
if property_type == "DATETIME":
return property_value.value_datetime.isoformat() if property_value.value_datetime else None
if property_type == "DECIMAL":
return property_value.value_decimal
if property_type == "BOOLEAN":
return property_value.value_boolean
if property_type == "OPTION":
return str(property_value.value_option_id) if property_value.value_option_id else None
if property_type == "RELATION":
return str(property_value.value_uuid) if property_value.value_uuid else None
# Fallback: return the first non-null stored value
for attr in (
"value_text",
"value_option_id",
"value_uuid",
"value_decimal",
"value_boolean",
"value_datetime",
):
raw_value = getattr(property_value, attr, None)
if raw_value is None:
continue
if attr == "value_datetime":
return raw_value.isoformat()
if attr in {"value_option_id", "value_uuid"}:
return str(raw_value)
return raw_value
return None
def serialize_issue_property_values(issue):
if not issue:
return []
property_manager = getattr(issue, "properties", None)
if property_manager is None:
return []
property_values = list(property_manager.all())
if not property_values:
return []
values_map: dict[str, list] = defaultdict(list)
metadata_map: dict[str, dict] = {}
for property_value in property_values:
property_id = getattr(property_value, "property_id", None)
if property_id is None:
continue
property_id_str = str(property_id)
property_obj = getattr(property_value, "property", None)
if property_id_str not in metadata_map and property_obj:
metadata_map[property_id_str] = {
"is_multi": getattr(property_obj, "is_multi", False),
"sort_order": getattr(property_obj, "sort_order", 0),
}
extracted_value = _extract_property_value(property_value)
if extracted_value is None:
continue
values_map[property_id_str].append(extracted_value)
if not values_map:
return []
serialized = []
for property_id, values in values_map.items():
metadata = metadata_map.get(property_id, {})
is_multi = metadata.get("is_multi", len(values) > 1)
serialized.append(
{
"property_id": property_id,
"value": values if is_multi or len(values) > 1 else values[0],
}
)
serialized.sort(
key=lambda item: (
metadata_map.get(item["property_id"], {}).get("sort_order") is None,
metadata_map.get(item["property_id"], {}).get("sort_order", 0),
)
)
return serialized
class IntakeSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(source="project", read_only=True)
pending_issue_count = serializers.IntegerField(read_only=True)
@@ -40,7 +140,12 @@ class IntakeIssueSerializer(BaseSerializer):
# Pass the annotated fields to the Issue instance if they exist
if hasattr(instance, "label_ids"):
instance.issue.label_ids = instance.label_ids
return super().to_representation(instance)
data = super().to_representation(instance)
issue_payload = data.get("issue")
if isinstance(issue_payload, dict):
issue_payload["additional_information"] = serialize_issue_property_values(getattr(instance, "issue", None))
return data
class IntakeIssueDetailSerializer(BaseSerializer):
@@ -67,7 +172,11 @@ class IntakeIssueDetailSerializer(BaseSerializer):
if hasattr(instance, "label_ids"):
instance.issue.label_ids = instance.label_ids
return super().to_representation(instance)
data = super().to_representation(instance)
issue_payload = data.get("issue")
if isinstance(issue_payload, dict):
issue_payload["additional_information"] = serialize_issue_property_values(getattr(instance, "issue", None))
return data
class IntakeIssueLiteSerializer(BaseSerializer):

Some files were not shown because too many files have changed in this diff Show More