fix: merge conflicts
This commit is contained in:
+13
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+15
-4
@@ -87,6 +87,7 @@ tmp/
|
||||
|
||||
## packages
|
||||
dist
|
||||
.flatfile
|
||||
.temp/
|
||||
deploy/selfhost/plane-app/
|
||||
|
||||
@@ -100,15 +101,25 @@ 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/
|
||||
|
||||
# Ignore any test results JSON files
|
||||
*test_results*.json
|
||||
apps/pi/tests/test_results.json
|
||||
|
||||
scripts/
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
"paths": {
|
||||
"@/app/*": ["app/*"],
|
||||
"@/*": ["core/*"],
|
||||
"@/plane-admin/*": ["ce/*"],
|
||||
"@/styles/*": ["styles/*"]
|
||||
"@/plane-admin/*": ["ee/*"],
|
||||
"@/styles/*": ["styles/*"],
|
||||
"@/ce/*": ["ce/*"]
|
||||
},
|
||||
"strictNullChecks": true
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dump.rdb
|
||||
@@ -4,7 +4,7 @@ FROM python:3.12.10-alpine
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/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
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# API SERVER
|
||||
Executable
+18
@@ -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 -
|
||||
Executable
+39
@@ -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 -
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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,
|
||||
@@ -55,4 +62,12 @@ from .asset import (
|
||||
)
|
||||
from .invite import WorkspaceInviteSerializer
|
||||
from .member import ProjectMemberSerializer
|
||||
from .sticky import StickySerializer
|
||||
from .sticky import StickySerializer
|
||||
from .project import ProjectFeatureSerializer
|
||||
from .initiative import InitiativeSerializer, InitiativeLabelSerializer
|
||||
from .teamspace import TeamspaceSerializer
|
||||
|
||||
from .work_item_search import (
|
||||
WorkItemAdvancedSearchRequestSerializer,
|
||||
WorkItemAdvancedSearchResponseSerializer,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class StateSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = State
|
||||
fields = "__all__"
|
||||
exclude = ["slug"]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_by",
|
||||
@@ -33,7 +33,6 @@ class StateSerializer(BaseSerializer):
|
||||
"workspace",
|
||||
"project",
|
||||
"deleted_at",
|
||||
"slug",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,11 +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 .sticky import urlpatterns as sticky_patterns
|
||||
from .workspace import urlpatterns as workspace_patterns
|
||||
|
||||
# ee imports
|
||||
from plane.ee.urls.api import urlpatterns as ee_api_urls
|
||||
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,
|
||||
@@ -22,6 +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,
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,8 +75,11 @@ from .intake import (
|
||||
|
||||
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
|
||||
|
||||
from .user import UserEndpoint
|
||||
from .issue_type import IssueTypeListCreateAPIEndpoint, IssueTypeDetailAPIEndpoint
|
||||
|
||||
from .invite import WorkspaceInvitationsViewset
|
||||
|
||||
from .sticky import StickyViewSet
|
||||
from .initiative import InitiativeViewSet, InitiativeLabelViewSet
|
||||
from .teamspace import TeamspaceViewSet
|
||||
from .work_item_search import WorkItemAdvancedSearchEndpoint
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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,19 @@ 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, StateGroup
|
||||
from plane.db.models import (
|
||||
Intake,
|
||||
IntakeIssue,
|
||||
Issue,
|
||||
Project,
|
||||
ProjectMember,
|
||||
State,
|
||||
StateGroup,
|
||||
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 (
|
||||
@@ -178,6 +189,10 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
sequence=65000,
|
||||
default=False,
|
||||
)
|
||||
# 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(
|
||||
@@ -187,6 +202,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
priority=request.data.get("issue", {}).get("priority", "none"),
|
||||
project_id=project_id,
|
||||
state_id=triage_state.id,
|
||||
type=issue_type,
|
||||
)
|
||||
|
||||
# create an intake issue
|
||||
@@ -310,6 +326,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,
|
||||
@@ -363,6 +389,18 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
),
|
||||
).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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,8 +35,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,
|
||||
@@ -58,8 +59,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"""
|
||||
@@ -157,6 +162,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"),
|
||||
@@ -212,6 +231,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
|
||||
@@ -423,6 +463,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()
|
||||
@@ -550,3 +610,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)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -46,12 +46,14 @@ from .cycle import (
|
||||
CycleIssueSerializer,
|
||||
CycleWriteSerializer,
|
||||
CycleUserPropertiesSerializer,
|
||||
EntityProgressSerializer,
|
||||
)
|
||||
from .asset import FileAssetSerializer
|
||||
from .issue import (
|
||||
IssueCreateSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueCommentReplySerializer,
|
||||
IssueUserPropertySerializer,
|
||||
IssueAssigneeSerializer,
|
||||
LabelSerializer,
|
||||
@@ -76,6 +78,7 @@ from .issue import (
|
||||
IssueVersionDetailSerializer,
|
||||
IssueDescriptionVersionDetailSerializer,
|
||||
IssueListDetailSerializer,
|
||||
IssueDuplicateSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
@@ -93,10 +96,12 @@ from .importer import ImporterSerializer
|
||||
|
||||
from .page import (
|
||||
PageSerializer,
|
||||
PageLiteSerializer,
|
||||
PageDetailSerializer,
|
||||
PageVersionSerializer,
|
||||
PageBinaryUpdateSerializer,
|
||||
PageVersionDetailSerializer,
|
||||
PageUserSerializer,
|
||||
)
|
||||
|
||||
from .estimate import (
|
||||
@@ -129,3 +134,183 @@ from .draft import (
|
||||
DraftIssueSerializer,
|
||||
DraftIssueDetailSerializer,
|
||||
)
|
||||
from .integration import (
|
||||
IntegrationSerializer,
|
||||
WorkspaceIntegrationSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
SlackProjectSyncSerializer,
|
||||
)
|
||||
|
||||
from .deploy_board import DeployBoardSerializer
|
||||
|
||||
|
||||
# Extended serializers
|
||||
from .extended.issue import ExtendedIssueCreateSerializer as IssueCreateSerializer # noqa: F811
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Base serializers
|
||||
"BaseSerializer",
|
||||
"DynamicBaseSerializer",
|
||||
|
||||
# User serializers
|
||||
"UserSerializer",
|
||||
"UserLiteSerializer",
|
||||
"ChangePasswordSerializer",
|
||||
"ResetPasswordSerializer",
|
||||
"UserAdminLiteSerializer",
|
||||
"UserMeSerializer",
|
||||
"UserMeSettingsSerializer",
|
||||
"ProfileSerializer",
|
||||
"AccountSerializer",
|
||||
|
||||
# Workspace serializers
|
||||
"WorkSpaceSerializer",
|
||||
"WorkSpaceMemberSerializer",
|
||||
"WorkSpaceMemberInviteSerializer",
|
||||
"WorkspaceLiteSerializer",
|
||||
"WorkspaceThemeSerializer",
|
||||
"WorkspaceMemberAdminSerializer",
|
||||
"WorkspaceMemberMeSerializer",
|
||||
"WorkspaceUserPropertiesSerializer",
|
||||
"WorkspaceUserLinkSerializer",
|
||||
"WorkspaceRecentVisitSerializer",
|
||||
"WorkspaceHomePreferenceSerializer",
|
||||
"StickySerializer",
|
||||
"WorkspaceUserMeSerializer",
|
||||
|
||||
# Project serializers
|
||||
"ProjectSerializer",
|
||||
"ProjectListSerializer",
|
||||
"ProjectDetailSerializer",
|
||||
"ProjectMemberSerializer",
|
||||
"ProjectMemberInviteSerializer",
|
||||
"ProjectIdentifierSerializer",
|
||||
"ProjectLiteSerializer",
|
||||
"ProjectMemberLiteSerializer",
|
||||
"ProjectMemberAdminSerializer",
|
||||
"ProjectPublicMemberSerializer",
|
||||
"ProjectMemberRoleSerializer",
|
||||
|
||||
# State serializers
|
||||
"StateSerializer",
|
||||
"StateLiteSerializer",
|
||||
|
||||
# View serializers
|
||||
"IssueViewSerializer",
|
||||
"ViewIssueListSerializer",
|
||||
|
||||
# Cycle serializers
|
||||
"CycleSerializer",
|
||||
"CycleIssueSerializer",
|
||||
"CycleWriteSerializer",
|
||||
"CycleUserPropertiesSerializer",
|
||||
"EntityProgressSerializer",
|
||||
|
||||
# Asset serializers
|
||||
"FileAssetSerializer",
|
||||
|
||||
# Issue serializers
|
||||
"IssueCreateSerializer",
|
||||
"IssueActivitySerializer",
|
||||
"IssueCommentSerializer",
|
||||
"IssueUserPropertySerializer",
|
||||
"IssueAssigneeSerializer",
|
||||
"LabelSerializer",
|
||||
"IssueSerializer",
|
||||
"IssueFlatSerializer",
|
||||
"IssueStateSerializer",
|
||||
"IssueLinkSerializer",
|
||||
"IssueIntakeSerializer",
|
||||
"IssueLiteSerializer",
|
||||
"IssueSubscriberSerializer",
|
||||
"IssueReactionSerializer",
|
||||
"CommentReactionSerializer",
|
||||
"IssueVoteSerializer",
|
||||
"IssueRelationSerializer",
|
||||
"RelatedIssueSerializer",
|
||||
"IssuePublicSerializer",
|
||||
"IssueDetailSerializer",
|
||||
"IssueReactionLiteSerializer",
|
||||
"IssueAttachmentLiteSerializer",
|
||||
"IssueLinkLiteSerializer",
|
||||
"IssueVersionDetailSerializer",
|
||||
"IssueDescriptionVersionDetailSerializer",
|
||||
"IssueListDetailSerializer",
|
||||
"IssueDuplicateSerializer",
|
||||
"IssueAttachmentSerializer",
|
||||
|
||||
# Module serializers
|
||||
"ModuleDetailSerializer",
|
||||
"ModuleWriteSerializer",
|
||||
"ModuleSerializer",
|
||||
"ModuleIssueSerializer",
|
||||
"ModuleLinkSerializer",
|
||||
"ModuleUserPropertiesSerializer",
|
||||
|
||||
# API serializers
|
||||
"APITokenSerializer",
|
||||
"APITokenReadSerializer",
|
||||
|
||||
# Importer serializers
|
||||
"ImporterSerializer",
|
||||
|
||||
# Page serializers
|
||||
"PageSerializer",
|
||||
"PageLiteSerializer",
|
||||
"PageDetailSerializer",
|
||||
"PageVersionSerializer",
|
||||
"PageBinaryUpdateSerializer",
|
||||
"PageVersionDetailSerializer",
|
||||
"PageUserSerializer",
|
||||
|
||||
# Estimate serializers
|
||||
"EstimateSerializer",
|
||||
"EstimatePointSerializer",
|
||||
"EstimateReadSerializer",
|
||||
"WorkspaceEstimateSerializer",
|
||||
|
||||
# Intake serializers
|
||||
"IntakeSerializer",
|
||||
"IntakeIssueSerializer",
|
||||
"IssueStateIntakeSerializer",
|
||||
"IntakeIssueLiteSerializer",
|
||||
"IntakeIssueDetailSerializer",
|
||||
|
||||
# Analytic serializers
|
||||
"AnalyticViewSerializer",
|
||||
|
||||
# Notification serializers
|
||||
"NotificationSerializer",
|
||||
"UserNotificationPreferenceSerializer",
|
||||
|
||||
# Exporter serializers
|
||||
"ExporterHistorySerializer",
|
||||
|
||||
# Webhook serializers
|
||||
"WebhookSerializer",
|
||||
"WebhookLogSerializer",
|
||||
|
||||
# Favorite serializers
|
||||
"UserFavoriteSerializer",
|
||||
|
||||
# Draft serializers
|
||||
"DraftIssueCreateSerializer",
|
||||
"DraftIssueSerializer",
|
||||
"DraftIssueDetailSerializer",
|
||||
|
||||
# Integration serializers
|
||||
"IntegrationSerializer",
|
||||
"WorkspaceIntegrationSerializer",
|
||||
"GithubIssueSyncSerializer",
|
||||
"GithubRepositorySerializer",
|
||||
"GithubRepositorySyncSerializer",
|
||||
"GithubCommentSyncSerializer",
|
||||
"SlackProjectSyncSerializer",
|
||||
|
||||
# Deploy board serializers
|
||||
"DeployBoardSerializer",
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers.issue import IssueCreateSerializer
|
||||
from plane.db.models import Issue, IssueType, IssueAssignee, IssueLabel
|
||||
from plane.ee.models import IntakeResponsibility, IntakeResponsibilityTypeChoices
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
|
||||
|
||||
class ExtendedIssueCreateSerializer(IssueCreateSerializer):
|
||||
def create(self, validated_data):
|
||||
assignees = validated_data.pop("assignee_ids", None)
|
||||
labels = validated_data.pop("label_ids", None)
|
||||
|
||||
project_id = self.context["project_id"]
|
||||
workspace_id = self.context["workspace_id"]
|
||||
default_assignee_id = self.context["default_assignee_id"]
|
||||
|
||||
issue_type = validated_data.pop("type", None)
|
||||
|
||||
if not issue_type:
|
||||
# Get default issue type
|
||||
issue_type = IssueType.objects.filter(
|
||||
project_issue_types__project_id=project_id,
|
||||
is_epic=False,
|
||||
is_default=True,
|
||||
).first()
|
||||
issue_type = issue_type
|
||||
|
||||
# Create Issue
|
||||
issue = Issue.objects.create(**validated_data, project_id=project_id, type=issue_type)
|
||||
|
||||
# Issue Audit Users
|
||||
created_by_id = issue.created_by_id
|
||||
updated_by_id = issue.updated_by_id
|
||||
|
||||
# Check if the issue is an intake issue and if the intake responsibility feature flag is enabled
|
||||
# then check if the payload has assignee_ids and if so, then assign the issue to the assignees
|
||||
# else assign to the intake responsibility assignees
|
||||
|
||||
intake_id = self.context.get("intake_id", None)
|
||||
workspace_slug = self.context.get("slug", None)
|
||||
|
||||
if assignees is not None and len(assignees):
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
if (
|
||||
intake_id
|
||||
and workspace_slug
|
||||
and check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.INTAKE_RESPONSIBILITY, user_id=self.context["user_id"], slug=workspace_slug
|
||||
)
|
||||
):
|
||||
intake_responsibilities = IntakeResponsibility.objects.filter(
|
||||
project_id=project_id, intake=intake_id,
|
||||
type=IntakeResponsibilityTypeChoices.ASSIGNEE,
|
||||
).values_list("user_id", flat=True)
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=user_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user_id in intake_responsibilities
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
# Assign to default assignee if valid
|
||||
if default_assignee_id is not None and self._is_valid_assignee(default_assignee_id, project_id):
|
||||
try:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None and len(labels):
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return issue
|
||||
@@ -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))
|
||||
|
||||
@@ -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, StateGroup, State
|
||||
|
||||
|
||||
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)
|
||||
@@ -83,7 +183,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):
|
||||
@@ -110,7 +215,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):
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
|
||||
from .github import (
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
from .slack import SlackProjectSyncSerializer
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user