Compare commits

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

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