mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 22:09:12 +02:00
Merge branch 'preview' into sync/ce-ee
This commit is contained in:
14
.env.example
14
.env.example
@@ -26,9 +26,10 @@ AWS_S3_BUCKET_NAME="uploads"
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# GPT settings
|
||||
SILO_BASE_URL=
|
||||
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
|
||||
OPENAI_API_KEY="sk-" # deprecated
|
||||
GPT_ENGINE="gpt-3.5-turbo" # deprecated
|
||||
GPT_ENGINE="gpt-4o-mini" # deprecated
|
||||
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1 # deprecated
|
||||
@@ -42,5 +43,16 @@ NGINX_PORT=80
|
||||
# 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
.github/actions/build-push-cloud/action.yml
vendored
Normal file
127
.github/actions/build-push-cloud/action.yml
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
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
.github/actions/build-push-ee/action.yml
vendored
Normal file
168
.github/actions/build-push-ee/action.yml
vendored
Normal file
@@ -0,0 +1,168 @@
|
||||
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 }}
|
||||
297
.github/workflows/appliance-docker-ee.yml
vendored
Normal file
297
.github/workflows/appliance-docker-ee.yml
vendored
Normal file
@@ -0,0 +1,297 @@
|
||||
name: Appliance Build Docker EE
|
||||
|
||||
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'
|
||||
|
||||
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'
|
||||
LATEST_MANIFEST_FILE: 'ee-docker-aws-ami-latest-manifest.json'
|
||||
PREVIOUS_MANIFEST_FILE: 'ee-docker-aws-ami-previous-manifest.json'
|
||||
EE_PACKER_FILE: 'ee-docker-aws-ami.pkr.hcl'
|
||||
CF_TEMPLATE_FILE: deploy/packer/ee-cloudformation-template.yaml
|
||||
CF_OUTPUT_FILE: deploy/packer/plane-commercial-cloudformation.yaml
|
||||
|
||||
jobs:
|
||||
|
||||
build_aws_appliance_ee:
|
||||
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: Download Previous Manifest
|
||||
run: |
|
||||
# Download the previous manifest from S3 if it exists
|
||||
aws s3 cp s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/${{ env.LATEST_MANIFEST_FILE }} ./${{ env.PREVIOUS_MANIFEST_FILE }} || true
|
||||
|
||||
- name: Setup `packer`
|
||||
uses: hashicorp/setup-packer@main
|
||||
id: setup
|
||||
with:
|
||||
version: latest
|
||||
- name: Copy Upload Assets
|
||||
run: |
|
||||
mkdir -p plane-dist
|
||||
cp deploy/packer/cloudinit-ee/* plane-dist/
|
||||
|
||||
- name: Run `packer init`
|
||||
id: init
|
||||
run: "packer init ./deploy/packer/${{ env.EE_PACKER_FILE }}"
|
||||
|
||||
- name: Run `packer validate`
|
||||
id: validate
|
||||
run: "packer validate ./deploy/packer/${{ 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 \
|
||||
./deploy/packer/${{ env.EE_PACKER_FILE }}
|
||||
|
||||
- name: Cleanup Old AMIs
|
||||
if: hashFiles('ee-docker-aws-ami-previous-manifest.json') != ''
|
||||
run: |
|
||||
# Validate the JSON file
|
||||
if ! jq empty ee-docker-aws-ami-previous-manifest.json > /dev/null 2>&1; then
|
||||
echo "Invalid or corrupted JSON file: ee-docker-aws-ami-previous-manifest.json"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract all AMI IDs from the artifact_id field
|
||||
OLD_AMI_STRING=$(jq -r '.builds[-1].artifact_id' ee-docker-aws-ami-previous-manifest.json)
|
||||
|
||||
# Split the AMI string into individual AMI IDs
|
||||
IFS=',' read -ra AMI_ARRAY <<< "$OLD_AMI_STRING"
|
||||
|
||||
for ami in "${AMI_ARRAY[@]}"; do
|
||||
# Extract just the AMI ID part after the region prefix
|
||||
AMI_ID=$(echo "$ami" | cut -d ":" -f2)
|
||||
if [ ! -z "$AMI_ID" ]; then
|
||||
echo "Found previous AMI: $AMI_ID"
|
||||
# Get the region from the AMI string
|
||||
REGION=$(echo "$ami" | cut -d ":" -f1)
|
||||
echo "Deregistering AMI in region: $REGION"
|
||||
# Deregister the AMI in the specific region
|
||||
aws ec2 deregister-image --region "$REGION" --image-id "$AMI_ID" || true
|
||||
echo "Deregistered previous AMI: $AMI_ID in region $REGION"
|
||||
fi
|
||||
done
|
||||
|
||||
- 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}}
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
- name: Store Manifest in S3
|
||||
run: |
|
||||
# Store the current manifest as latest
|
||||
aws s3 cp ${{env.CURRENT_MANIFEST_FILE}} s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/ee-docker-aws-ami-latest-manifest.json
|
||||
|
||||
# Also store a versioned copy
|
||||
aws s3 cp ${{env.CURRENT_MANIFEST_FILE}} "s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/ee-docker-aws-ami-manifest-${{ github.sha }}.json"
|
||||
|
||||
- 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 ".Mappings.RegionMap.\"${region}\".AMI = \"${ami_id}\"" -i "${{ env.CF_OUTPUT_FILE }}"
|
||||
yq eval ".Mappings.RegionMap.\"${region}\".Allowed = \"true\"" -i "${{ env.CF_OUTPUT_FILE }}"
|
||||
done
|
||||
|
||||
cat "${{ env.CF_OUTPUT_FILE }}"
|
||||
|
||||
echo "Updated template saved as: ${{ env.CF_OUTPUT_FILE }}"
|
||||
|
||||
- name: Upload Updated CloudFormation Template
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cloudformation-template
|
||||
path: ${{ env.CF_OUTPUT_FILE }}
|
||||
retention-days: 30
|
||||
204
.github/workflows/build-aio-branch-ee.yml
vendored
Normal file
204
.github/workflows/build-aio-branch-ee.yml
vendored
Normal file
@@ -0,0 +1,204 @@
|
||||
name: Branch Build AIO
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full:
|
||||
description: 'Run full build'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
slim:
|
||||
description: 'Run slim build'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
base_tag_name:
|
||||
description: 'Base Tag Name'
|
||||
required: false
|
||||
default: ''
|
||||
release:
|
||||
types: [released, prereleased]
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
FULL_BUILD_INPUT: ${{ github.event.inputs.full }}
|
||||
SLIM_BUILD_INPUT: ${{ github.event.inputs.slim }}
|
||||
|
||||
jobs:
|
||||
branch_build_setup:
|
||||
name: Build Setup
|
||||
runs-on: ubuntu-latest
|
||||
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 }}
|
||||
aio_base_tag: ${{ steps.set_env_variables.outputs.AIO_BASE_TAG }}
|
||||
do_full_build: ${{ steps.set_env_variables.outputs.DO_FULL_BUILD }}
|
||||
do_slim_build: ${{ steps.set_env_variables.outputs.DO_SLIM_BUILD }}
|
||||
|
||||
steps:
|
||||
- id: set_env_variables
|
||||
name: Set Environment Variables
|
||||
run: |
|
||||
if [ [ "${{ github.event_name }}" == "release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ] ; then
|
||||
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "AIO_BASE_TAG=latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
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
|
||||
|
||||
if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
|
||||
echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then
|
||||
echo "AIO_BASE_TAG=preview" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "AIO_BASE_TAG=develop" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "${{ env.FULL_BUILD_INPUT }}" == "true" ] || [ "${{github.event_name}}" == "push" ] || [ "${{github.event_name}}" == "release" ]; then
|
||||
echo "DO_FULL_BUILD=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "DO_FULL_BUILD=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ "${{ env.SLIM_BUILD_INPUT }}" == "true" ] || [ "${{github.event_name}}" == "push" ] || [ "${{github.event_name}}" == "release" ]; then
|
||||
echo "DO_SLIM_BUILD=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "DO_SLIM_BUILD=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
full_build_push:
|
||||
if: ${{ needs.branch_build_setup.outputs.do_full_build == 'true' }}
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BUILD_TYPE: full
|
||||
AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
|
||||
AIO_IMAGE_TAGS: makeplane/plane-aio-enterprise:full-${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
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 }}
|
||||
steps:
|
||||
- name: Set Docker Tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=makeplane/plane-aio-enterprise:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio-enterprise:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }}
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=makeplane/plane-aio-enterprise:${{env.BUILD_TYPE}}-latest
|
||||
else
|
||||
TAG=${{ env.AIO_IMAGE_TAGS }}
|
||||
fi
|
||||
echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push to Docker Hub
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./aio/Dockerfile-app
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.AIO_IMAGE_TAGS }}
|
||||
push: true
|
||||
build-args: |
|
||||
BUILD_TAG=${{ env.AIO_BASE_TAG }}
|
||||
BUILD_TYPE=${{env.BUILD_TYPE}}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
slim_build_push:
|
||||
if: ${{ needs.branch_build_setup.outputs.do_slim_build == 'true' }}
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BUILD_TYPE: slim
|
||||
AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
|
||||
AIO_IMAGE_TAGS: makeplane/plane-aio-enterprise:slim-${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
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 }}
|
||||
steps:
|
||||
- name: Set Docker Tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=makeplane/plane-aio-enterprise:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio-enterprise:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }}
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=makeplane/plane-aio-enterprise:${{env.BUILD_TYPE}}-latest
|
||||
else
|
||||
TAG=${{ env.AIO_IMAGE_TAGS }}
|
||||
fi
|
||||
echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push to Docker Hub
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./aio/Dockerfile-app
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.AIO_IMAGE_TAGS }}
|
||||
push: true
|
||||
build-args: |
|
||||
BUILD_TAG=${{ env.AIO_BASE_TAG }}
|
||||
BUILD_TYPE=${{env.BUILD_TYPE}}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
370
.github/workflows/build-branch-cloud.yml
vendored
Normal file
370
.github/workflows/build-branch-cloud.yml
vendored
Normal file
@@ -0,0 +1,370 @@
|
||||
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: ./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: ./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: ./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: ./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: ./silo/Dockerfile.silo
|
||||
|
||||
branch_build_push_apiserver:
|
||||
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: ./apiserver
|
||||
dockerfile-path: ./apiserver/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: ./email
|
||||
dockerfile-path: ./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_apiserver,
|
||||
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
|
||||
635
.github/workflows/build-branch-ee.yml
vendored
Normal file
635
.github/workflows/build-branch-ee.yml
vendored
Normal file
@@ -0,0 +1,635 @@
|
||||
name: Branch Build Enterprise
|
||||
|
||||
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
|
||||
isPrerelease:
|
||||
description: "Is Pre-release"
|
||||
type: boolean
|
||||
default: false
|
||||
required: true
|
||||
arm64:
|
||||
description: "Build for ARM64 architecture"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
airgapped_build:
|
||||
description: "Build for airgapped installation"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.ref_name }}
|
||||
ARM64_BUILD: ${{ github.event.inputs.arm64 }}
|
||||
BUILD_TYPE: ${{ github.event.inputs.build_type }}
|
||||
RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }}
|
||||
IS_PRERELEASE: ${{ github.event.inputs.isPrerelease }}
|
||||
AIRGAPPED_BUILD: ${{ github.event.inputs.airgapped_build }}
|
||||
|
||||
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 }}
|
||||
|
||||
artifact_upload_to_s3: ${{ steps.set_env_variables.outputs.artifact_upload_to_s3 }}
|
||||
artifact_s3_suffix: ${{ steps.set_env_variables.outputs.artifact_s3_suffix }}
|
||||
|
||||
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_backend: ${{ steps.set_env_variables.outputs.DH_IMG_BACKEND }}
|
||||
dh_img_proxy: ${{ steps.set_env_variables.outputs.DH_IMG_PROXY }}
|
||||
dh_img_monitor: ${{ steps.set_env_variables.outputs.DH_IMG_MONITOR }}
|
||||
dh_img_silo: ${{ steps.set_env_variables.outputs.DH_IMG_SILO }}
|
||||
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 }}
|
||||
airgapped_build: ${{ steps.set_env_variables.outputs.AIRGAPPED_BUILD }}
|
||||
arm64_build: ${{ steps.set_env_variables.outputs.ARM64_BUILD }}
|
||||
|
||||
steps:
|
||||
- id: set_env_variables
|
||||
name: Set Environment Variables
|
||||
run: |
|
||||
if [ "${{ env.ARM64_BUILD }}" == "true" ] || ([ "${{ env.BUILD_TYPE }}" == "Release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ]); then
|
||||
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
|
||||
echo "ARM64_BUILD=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
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
|
||||
echo "ARM64_BUILD=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
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-commercial" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_SPACE=space-commercial" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_ADMIN=admin-commercial" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_LIVE=live-commercial" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_BACKEND=backend-commercial" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_PROXY=proxy-commercial" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_MONITOR=monitor-commercial" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_SILO=silo-commercial" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_EMAIL=email-commercial" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "BUILD_TYPE=${{env.BUILD_TYPE}}" >> $GITHUB_OUTPUT
|
||||
BUILD_RELEASE=false
|
||||
BUILD_PRERELEASE=false
|
||||
RELVERSION="latest"
|
||||
HARBOR_PUSH=false
|
||||
|
||||
echo "AIRGAPPED_BUILD=${{ env.AIRGAPPED_BUILD }}" >> $GITHUB_OUTPUT
|
||||
|
||||
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
|
||||
# HARBOR_PUSH=true
|
||||
|
||||
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
|
||||
echo "HARBOR_PUSH=${HARBOR_PUSH}" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "${{ env.BUILD_TYPE }}" == "Release" ]; then
|
||||
echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT
|
||||
echo "artifact_s3_suffix=${{ env.RELEASE_VERSION }}" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT
|
||||
echo "artifact_s3_suffix=latest" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "preview" ] || [ "${{ env.TARGET_BRANCH }}" == "develop" ] || [ "${{ env.TARGET_BRANCH }}" == "uat" ]; then
|
||||
echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT
|
||||
echo "artifact_s3_suffix=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "artifact_upload_to_s3=false" >> $GITHUB_OUTPUT
|
||||
echo "artifact_s3_suffix=$BR_NAME" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
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 }}
|
||||
private-registry-push: ${{ needs.branch_build_setup.outputs.harbor_push }}
|
||||
private-registry-username: ${{ secrets.HARBOR_USERNAME }}
|
||||
private-registry-token: ${{ secrets.HARBOR_TOKEN }}
|
||||
private-registry-addr: ${{ vars.HARBOR_REGISTRY }}
|
||||
private-registry-project: ${{ vars.HARBOR_PROJECT }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }}
|
||||
build-context: .
|
||||
dockerfile-path: ./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 }}
|
||||
|
||||
branch_build_push_web:
|
||||
name: Build-Push Web Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- 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 }}
|
||||
private-registry-push: ${{ needs.branch_build_setup.outputs.harbor_push }}
|
||||
private-registry-username: ${{ secrets.HARBOR_USERNAME }}
|
||||
private-registry-token: ${{ secrets.HARBOR_TOKEN }}
|
||||
private-registry-addr: ${{ vars.HARBOR_REGISTRY }}
|
||||
private-registry-project: ${{ vars.HARBOR_PROJECT }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }}
|
||||
build-context: .
|
||||
dockerfile-path: ./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 }}
|
||||
|
||||
branch_build_push_space:
|
||||
name: Build-Push Space Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- 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 }}
|
||||
private-registry-push: ${{ needs.branch_build_setup.outputs.harbor_push }}
|
||||
private-registry-username: ${{ secrets.HARBOR_USERNAME }}
|
||||
private-registry-token: ${{ secrets.HARBOR_TOKEN }}
|
||||
private-registry-addr: ${{ vars.HARBOR_REGISTRY }}
|
||||
private-registry-project: ${{ vars.HARBOR_PROJECT }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }}
|
||||
build-context: .
|
||||
dockerfile-path: ./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 }}
|
||||
|
||||
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 }}
|
||||
private-registry-push: ${{ needs.branch_build_setup.outputs.harbor_push }}
|
||||
private-registry-username: ${{ secrets.HARBOR_USERNAME }}
|
||||
private-registry-token: ${{ secrets.HARBOR_TOKEN }}
|
||||
private-registry-addr: ${{ vars.HARBOR_REGISTRY }}
|
||||
private-registry-project: ${{ vars.HARBOR_PROJECT }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }}
|
||||
build-context: .
|
||||
dockerfile-path: ./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 }}
|
||||
private-registry-push: ${{ needs.branch_build_setup.outputs.harbor_push }}
|
||||
private-registry-username: ${{ secrets.HARBOR_USERNAME }}
|
||||
private-registry-token: ${{ secrets.HARBOR_TOKEN }}
|
||||
private-registry-addr: ${{ vars.HARBOR_REGISTRY }}
|
||||
private-registry-project: ${{ vars.HARBOR_PROJECT }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_silo }}
|
||||
build-context: .
|
||||
dockerfile-path: ./silo/Dockerfile.silo
|
||||
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_apiserver:
|
||||
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 }}
|
||||
private-registry-push: ${{ needs.branch_build_setup.outputs.harbor_push }}
|
||||
private-registry-username: ${{ secrets.HARBOR_USERNAME }}
|
||||
private-registry-token: ${{ secrets.HARBOR_TOKEN }}
|
||||
private-registry-addr: ${{ vars.HARBOR_REGISTRY }}
|
||||
private-registry-project: ${{ vars.HARBOR_PROJECT }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }}
|
||||
build-context: ./apiserver
|
||||
dockerfile-path: ./apiserver/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_proxy:
|
||||
name: Build-Push Proxy Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Proxy 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 }}
|
||||
private-registry-push: ${{ needs.branch_build_setup.outputs.harbor_push }}
|
||||
private-registry-username: ${{ secrets.HARBOR_USERNAME }}
|
||||
private-registry-token: ${{ secrets.HARBOR_TOKEN }}
|
||||
private-registry-addr: ${{ vars.HARBOR_REGISTRY }}
|
||||
private-registry-project: ${{ vars.HARBOR_PROJECT }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
|
||||
build-context: ./proxy
|
||||
dockerfile-path: ./proxy/Dockerfile.ee
|
||||
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_monitor:
|
||||
name: Build-Push Monitor Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Generate Keypair
|
||||
run: |
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ env.BUILD_TYPE }}" == "Release" ]; then
|
||||
openssl genrsa -out private_key.pem 2048
|
||||
else
|
||||
echo "${{ secrets.DEFAULT_PRIME_PRIVATE_KEY }}" > private_key.pem
|
||||
fi
|
||||
openssl rsa -in private_key.pem -pubout -out public_key.pem
|
||||
cat public_key.pem
|
||||
|
||||
# Generating the private key env for the generated keys
|
||||
PRIVATE_KEY=$(cat private_key.pem | base64 -w 0)
|
||||
echo "PRIVATE_KEY=${PRIVATE_KEY}" >> $GITHUB_ENV
|
||||
|
||||
- name: Monitor 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 }}
|
||||
private-registry-push: ${{ needs.branch_build_setup.outputs.harbor_push }}
|
||||
private-registry-username: ${{ secrets.HARBOR_USERNAME }}
|
||||
private-registry-token: ${{ secrets.HARBOR_TOKEN }}
|
||||
private-registry-addr: ${{ vars.HARBOR_REGISTRY }}
|
||||
private-registry-project: ${{ vars.HARBOR_PROJECT }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_monitor }}
|
||||
build-context: ./monitor
|
||||
dockerfile-path: ./monitor/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 }}
|
||||
build-args: |
|
||||
PRIVATE_KEY=${{ env.PRIVATE_KEY }}
|
||||
|
||||
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 }}
|
||||
private-registry-push: ${{ needs.branch_build_setup.outputs.harbor_push }}
|
||||
private-registry-username: ${{ secrets.HARBOR_USERNAME }}
|
||||
private-registry-token: ${{ secrets.HARBOR_TOKEN }}
|
||||
private-registry-addr: ${{ vars.HARBOR_REGISTRY }}
|
||||
private-registry-project: ${{ vars.HARBOR_PROJECT }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_email }}
|
||||
build-context: ./email
|
||||
dockerfile-path: ./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 }}
|
||||
|
||||
package_airgapped_artifacts:
|
||||
if: ${{ needs.branch_build_setup.outputs.airgapped_build == 'true' || needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Package Airgapped Artifacts
|
||||
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_apiserver, branch_build_push_proxy, branch_build_push_monitor, branch_build_push_email, branch_build_push_silo]
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SELF_HOST_BUCKET_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SELF_HOST_BUCKET_SECRET_KEY }}
|
||||
AIRGAPPED_RELEASE_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
S3_PATH_AMD: "s3://${{ vars.SELF_HOST_BUCKET_NAME }}/plane-enterprise/${{ needs.branch_build_setup.outputs.artifact_s3_suffix }}/airgapped-amd64.tar.gz"
|
||||
S3_PATH_ARM: "s3://${{ vars.SELF_HOST_BUCKET_NAME }}/plane-enterprise/${{ needs.branch_build_setup.outputs.artifact_s3_suffix }}/airgapped-arm64.tar.gz"
|
||||
S3_REGION: ${{ vars.SELF_HOST_BUCKET_REGION }}
|
||||
COMMIT_HASH: $(git rev-parse HEAD)
|
||||
PRESIGNED_URL_EXPIRY: 604800 # 7 days (max allowed by AWS)
|
||||
AIRGAPPED_ARM64_BUILD: ${{ needs.branch_build_setup.outputs.arm64_build == 'true' }}
|
||||
steps:
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- id: install-qemu
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Docker Setup
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Setup yq
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Install crane
|
||||
run: |
|
||||
echo "Installing crane..."
|
||||
curl -sL "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_x86_64.tar.gz" | tar -xz -C /usr/local/bin crane
|
||||
sudo chmod +x /usr/local/bin/crane
|
||||
|
||||
- id: install-aws-cli
|
||||
uses: unfor19/install-aws-cli-action@v1
|
||||
with:
|
||||
version: 2
|
||||
|
||||
- id: package_airgapped_artifacts
|
||||
name: Package Airgapped Artifacts
|
||||
run: |
|
||||
AIRGAPPED_RELEASE_VERSION=${{ env.AIRGAPPED_RELEASE_VERSION }}
|
||||
if [ "${{ needs.branch_build_setup.outputs.build_type}}" == "Build" ]; then
|
||||
AIRGAPPED_RELEASE_VERSION=${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
# for testing
|
||||
# AIRGAPPED_RELEASE_VERSION=v1.10.0
|
||||
fi
|
||||
echo "AIRGAPPED_RELEASE_VERSION=$AIRGAPPED_RELEASE_VERSION" >> $GITHUB_ENV
|
||||
|
||||
# build for amd64
|
||||
cd ${{ github.workspace }}/deploy/airgapped
|
||||
bash ./build.sh --release=$AIRGAPPED_RELEASE_VERSION --platform=linux/amd64
|
||||
cd dist
|
||||
echo "Packaging airgapped artifacts and uploading to S3"
|
||||
tar -czf - * | aws s3 cp - ${{ env.S3_PATH_AMD}}
|
||||
echo "Airgapped artifacts uploaded to S3 at ${{ env.S3_PATH_AMD }}"
|
||||
|
||||
# create a presigned url for the airgapped artifacts for 7 days
|
||||
S3_PRESIGNED_URL_AMD=$(aws s3 presign ${{ env.S3_PATH_AMD }} --expires-in ${{ env.PRESIGNED_URL_EXPIRY }} --region ${{ env.S3_REGION }})
|
||||
echo "$S3_PRESIGNED_URL_AMD" > /tmp/presigned_url_amd.txt
|
||||
|
||||
echo "AIRGAPPED_ARM64_BUILD=${{ env.AIRGAPPED_ARM64_BUILD }}" >> $GITHUB_OUTPUT
|
||||
# build for arm64
|
||||
if [ "${{ env.AIRGAPPED_ARM64_BUILD }}" == "true" ]; then
|
||||
cd ${{ github.workspace }}/deploy/airgapped
|
||||
bash ./build.sh --release=$AIRGAPPED_RELEASE_VERSION --platform=linux/arm64
|
||||
cd dist
|
||||
echo "Packaging airgapped artifacts and uploading to S3"
|
||||
tar -czf - * | aws s3 cp - ${{ env.S3_PATH_ARM}}
|
||||
echo "Airgapped artifacts uploaded to S3 at ${{ env.S3_PATH_ARM }}"
|
||||
|
||||
# create a presigned url for the airgapped artifacts for 7 days
|
||||
S3_PRESIGNED_URL_ARM=$(aws s3 presign ${{ env.S3_PATH_ARM }} --expires-in ${{ env.PRESIGNED_URL_EXPIRY }} --region ${{ env.S3_REGION }})
|
||||
echo "$S3_PRESIGNED_URL_ARM" > /tmp/presigned_url_arm.txt
|
||||
fi
|
||||
|
||||
echo "COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: upload_artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pre-signed-url-amd.txt
|
||||
path: /tmp/presigned_url_amd.txt
|
||||
|
||||
- name: upload_artifact
|
||||
if: ${{ steps.package_airgapped_artifacts.outputs.AIRGAPPED_ARM64_BUILD == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pre-signed-url-arm.txt
|
||||
path: /tmp/presigned_url_arm.txt
|
||||
|
||||
- name: Airgapped Package Summary
|
||||
run: |
|
||||
|
||||
# Print Step Build Summary
|
||||
echo "# 🚀 Airgapped Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 🌍 Artifacts" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Key | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release Version | \`${{ env.AIRGAPPED_RELEASE_VERSION }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| S3 Path AMD | \`${{ env.S3_PATH_AMD }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ steps.package_airgapped_artifacts.outputs.AIRGAPPED_ARM64_BUILD }}" == "true" ]; then
|
||||
echo "| S3 Path ARM | \`${{ env.S3_PATH_ARM }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "| Git Branch | \`${{env.TARGET_BRANCH}}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Git Commit Hash | \`${{ env.COMMIT_HASH }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
|
||||
## 📘 Usage Instructions
|
||||
```bash
|
||||
curl -fsSL -o airgapped.tar.gz "<presigned-url>"
|
||||
mkdir -p airgapped
|
||||
tar -xzf airgapped.tar.gz -C airgapped
|
||||
cd airgapped
|
||||
bash install.sh
|
||||
```
|
||||
EOF
|
||||
|
||||
|
||||
upload_artifacts_s3:
|
||||
if: ${{ needs.branch_build_setup.outputs.artifact_upload_to_s3 == 'true' }}
|
||||
name: Upload artifacts to S3 Bucket
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
container:
|
||||
image: docker:20.10.7
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
env:
|
||||
ARTIFACT_SUFFIX: ${{ needs.branch_build_setup.outputs.artifact_s3_suffix }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SELF_HOST_BUCKET_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SELF_HOST_BUCKET_SECRET_KEY }}
|
||||
steps:
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Upload artifacts
|
||||
run: |
|
||||
apk update
|
||||
apk add --no-cache aws-cli
|
||||
|
||||
mkdir -p ~/${{ env.ARTIFACT_SUFFIX }}
|
||||
|
||||
sed -i 's@APP_RELEASE_VERSION=.*@APP_RELEASE_VERSION='${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/variables.env
|
||||
cp deploy/cli-install/variables.env ~/${{ env.ARTIFACT_SUFFIX }}/variables.env
|
||||
|
||||
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/docker-compose-caddy.yml
|
||||
cp deploy/cli-install/docker-compose-caddy.yml ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose-caddy.yml
|
||||
|
||||
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/coolify-compose.yml
|
||||
cp deploy/cli-install/coolify-compose.yml ~/${{ env.ARTIFACT_SUFFIX }}/coolify-compose.yml
|
||||
|
||||
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/portainer-compose.yml
|
||||
cp deploy/cli-install/portainer-compose.yml ~/${{ env.ARTIFACT_SUFFIX }}/portainer-compose.yml
|
||||
|
||||
# required for prime-cli backward compatibility
|
||||
cp proxy/Caddyfile.ee ~/${{ env.ARTIFACT_SUFFIX }}/Caddyfile
|
||||
cp deploy/cli-install/docker-compose-caddy.yml ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose.yml
|
||||
|
||||
aws s3 cp ~/${{ env.ARTIFACT_SUFFIX }} s3://${{ vars.SELF_HOST_BUCKET_NAME }}/plane-enterprise/${{ env.ARTIFACT_SUFFIX }} --recursive
|
||||
|
||||
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_apiserver,
|
||||
branch_build_push_proxy,
|
||||
branch_build_push_monitor,
|
||||
branch_build_push_email,
|
||||
branch_build_push_silo,
|
||||
upload_artifacts_s3,
|
||||
]
|
||||
env:
|
||||
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
ARTIFACT_SUFFIX: ${{ needs.branch_build_setup.outputs.artifact_s3_suffix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update docker-compose
|
||||
run: |
|
||||
sed -i 's@APP_RELEASE_VERSION=.*@APP_RELEASE_VERSION='${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/variables.env
|
||||
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/docker-compose-caddy.yml
|
||||
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/coolify-compose.yml
|
||||
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/portainer-compose.yml
|
||||
cp deploy/cli-install/docker-compose-caddy.yml deploy/cli-install/docker-compose.yml
|
||||
cp deploy/cli-install/portainer-compose.yml deploy/cli-install/swarm-compose.yml
|
||||
|
||||
- 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
|
||||
files: |
|
||||
${{ github.workspace }}/deploy/cli-install/variables.env
|
||||
${{ github.workspace }}/deploy/cli-install/docker-compose.yml
|
||||
${{ github.workspace }}/deploy/cli-install/coolify-compose.yml
|
||||
${{ github.workspace }}/deploy/cli-install/portainer-compose.yml
|
||||
${{ github.workspace }}/deploy/cli-install/swarm-compose.yml
|
||||
70
.github/workflows/create-release.yml
vendored
Normal file
70
.github/workflows/create-release.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Manual Release Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release Tag (e.g., v0.16-cannary-1)'
|
||||
required: true
|
||||
prerelease:
|
||||
description: 'Pre-Release'
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
draft:
|
||||
description: 'Draft'
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0 # Necessary to fetch all history for tags
|
||||
|
||||
- name: Set up Git
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
git config user.email "github-actions@github.com"
|
||||
|
||||
- name: Check for the Prerelease
|
||||
run: |
|
||||
echo ${{ github.event.release.prerelease }}
|
||||
|
||||
- name: Generate Release Notes
|
||||
id: generate_notes
|
||||
run: |
|
||||
bash ./generate_release_notes.sh
|
||||
# Directly use the content of RELEASE_NOTES.md for the release body
|
||||
RELEASE_NOTES=$(cat RELEASE_NOTES.md)
|
||||
echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV
|
||||
echo "$RELEASE_NOTES" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Tag
|
||||
run: |
|
||||
git tag ${{ github.event.inputs.release_tag }}
|
||||
git push origin ${{ github.event.inputs.release_tag }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.release_tag }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
draft: ${{ github.event.inputs.draft }}
|
||||
prerelease: ${{ github.event.inputs.prerelease }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
55
.github/workflows/sync-community.yml
vendored
Normal file
55
.github/workflows/sync-community.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Sync from Community Repo
|
||||
|
||||
on:
|
||||
# schedule:
|
||||
# - cron: "*/30 * * * *" # Runs every 30 minutes
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_branch:
|
||||
description: "Source branch in Community repo"
|
||||
required: true
|
||||
default: "preview"
|
||||
target_branch:
|
||||
description: "Target branch in Enterprise repo"
|
||||
required: true
|
||||
default: "preview"
|
||||
|
||||
jobs:
|
||||
sync-from-community-repo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout enterprise repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set branch names
|
||||
run: |
|
||||
echo "SOURCE_BRANCH=${{ github.event.inputs.source_branch || 'preview' }}" >> $GITHUB_ENV
|
||||
echo "TARGET_BRANCH=${{ github.event.inputs.target_branch || 'preview' }}" >> $GITHUB_ENV
|
||||
echo "SYNC_BRANCH=sync-${{ github.run_id }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Create sync branch
|
||||
run: git checkout -b ${{ env.SYNC_BRANCH }}
|
||||
|
||||
- name: Fetch from community repository
|
||||
run: |
|
||||
git config user.name github-actions
|
||||
git config user.email 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
|
||||
6
.github/workflows/sync-repo-pr.yml
vendored
6
.github/workflows/sync-repo-pr.yml
vendored
@@ -41,12 +41,16 @@ jobs:
|
||||
|
||||
- name: Create PR to Target Branch
|
||||
run: |
|
||||
# Determine target branch based on current branch prefix
|
||||
TARGET_BRANCH="preview"
|
||||
PR_TITLE="${{vars.SYNC_PR_TITLE}}"
|
||||
|
||||
# get all pull requests and check if there is already a PR
|
||||
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number')
|
||||
if [ -n "$PR_EXISTS" ]; then
|
||||
echo "Pull Request already exists: $PR_EXISTS"
|
||||
else
|
||||
echo "Creating new pull request"
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "${{ vars.SYNC_PR_TITLE }}" --body "")
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "$PR_TITLE" --body "")
|
||||
echo "Pull Request created: $PR_URL"
|
||||
fi
|
||||
|
||||
2
.github/workflows/sync-repo.yml
vendored
2
.github/workflows/sync-repo.yml
vendored
@@ -36,8 +36,8 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
|
||||
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -84,6 +84,7 @@ tmp/
|
||||
|
||||
## packages
|
||||
dist
|
||||
.flatfile
|
||||
.temp/
|
||||
deploy/selfhost/plane-app/
|
||||
|
||||
@@ -95,3 +96,10 @@ dev-editor
|
||||
# Redis
|
||||
*.rdb
|
||||
*.rdb.gz
|
||||
|
||||
# Monitor
|
||||
monitor/prime.key
|
||||
monitor/prime.key.pub
|
||||
monitor.db
|
||||
|
||||
.cursorrules
|
||||
|
||||
@@ -27,7 +27,7 @@ FILE_SIZE_LIMIT=5242880
|
||||
# GPT settings
|
||||
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
|
||||
OPENAI_API_KEY="sk-" # deprecated
|
||||
GPT_ENGINE="gpt-3.5-turbo" # deprecated
|
||||
GPT_ENGINE="gpt-4o-mini" # deprecated
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1 # deprecated
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
|
||||
240
admin/app/authentication/oidc/form.tsx
Normal file
240
admin/app/authentication/oidc/form.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
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 {
|
||||
ConfirmDiscardModal,
|
||||
ControllerInput,
|
||||
TControllerInputFormField,
|
||||
CopyField,
|
||||
TCopyField,
|
||||
CodeBlock,
|
||||
} from "@/components/common";
|
||||
// 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
admin/app/authentication/oidc/page.tsx
Normal file
120
admin/app/authentication/oidc/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication";
|
||||
import { PageHeader } from "@/components/common";
|
||||
// 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;
|
||||
238
admin/app/authentication/saml/form.tsx
Normal file
238
admin/app/authentication/saml/form.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
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 {
|
||||
ConfirmDiscardModal,
|
||||
ControllerInput,
|
||||
TControllerInputFormField,
|
||||
CopyField,
|
||||
TCopyField,
|
||||
CodeBlock,
|
||||
} from "@/components/common";
|
||||
// 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
admin/app/authentication/saml/page.tsx
Normal file
120
admin/app/authentication/saml/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication";
|
||||
import { PageHeader } from "@/components/common";
|
||||
// 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;
|
||||
@@ -10,11 +10,12 @@ import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
import { useInstance, useTheme } from "@/hooks/store";
|
||||
// assets
|
||||
// eslint-disable-next-line import/order
|
||||
import packageJson from "package.json";
|
||||
|
||||
|
||||
const helpOptions = [
|
||||
{
|
||||
name: "Documentation",
|
||||
@@ -37,6 +38,7 @@ export const HelpSection: FC = observer(() => {
|
||||
// states
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
// store
|
||||
const { instance } = useInstance();
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
// refs
|
||||
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -129,8 +131,20 @@ export const HelpSection: 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{packageJson.version}</div>
|
||||
<div className="px-2 pb-1 pt-2 text-[10px]">Version: v{instance?.current_version}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@ export const SidebarMenu = 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>
|
||||
|
||||
@@ -58,7 +58,7 @@ export const InstanceHeader: FC = observer(() => {
|
||||
return breadcrumbItems;
|
||||
};
|
||||
|
||||
const breadcrumbItems = generateBreadcrumbItems(pathName);
|
||||
const breadcrumbItems = generateBreadcrumbItems(pathName || "");
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-[3.75rem] 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">
|
||||
|
||||
@@ -54,13 +54,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,
|
||||
|
||||
@@ -46,9 +46,9 @@ export const InstanceSignInForm: FC = (props) => {
|
||||
const {} = props;
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const emailParam = searchParams.get("email") || undefined;
|
||||
const errorCode = searchParams.get("error_code") || undefined;
|
||||
const errorMessage = searchParams.get("error_message") || undefined;
|
||||
const emailParam = searchParams?.get("email") || undefined;
|
||||
const errorCode = searchParams?.get("error_code") || undefined;
|
||||
const errorMessage = searchParams?.get("error_message") || undefined;
|
||||
// state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { FC, ReactNode, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { InstanceSidebar } from "@/components/admin-sidebar";
|
||||
import { InstanceHeader } from "@/components/auth-header";
|
||||
@@ -9,6 +10,8 @@ import { LogoSpinner } from "@/components/common";
|
||||
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";
|
||||
|
||||
type TAdminLayout = {
|
||||
children: ReactNode;
|
||||
@@ -20,6 +23,14 @@ export const AdminLayout: FC<TAdminLayout> = observer((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) {
|
||||
@@ -27,7 +38,7 @@ export const AdminLayout: FC<TAdminLayout> = observer((props) => {
|
||||
}
|
||||
}, [router, isUserLoggedIn]);
|
||||
|
||||
if (isUserLoggedIn === undefined) {
|
||||
if ((flagsLoader && !flagsError) || isUserLoggedIn === undefined) {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
|
||||
@@ -1 +1,85 @@
|
||||
export * from "ce/components/authentication/authentication-modes";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
TInstanceAuthenticationMethodKeys as TBaseAuthenticationMethods,
|
||||
TInstanceAuthenticationModes,
|
||||
TInstanceEnterpriseAuthenticationMethodKeys,
|
||||
} from "@plane/types";
|
||||
import { getAuthenticationModes as getCEAuthenticationModes } from "@/ce/components/authentication/authentication-modes";
|
||||
// types
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication";
|
||||
// helpers
|
||||
import { getBaseAuthenticationModes } from "@/lib/auth-helpers";
|
||||
// plane admin components
|
||||
import { OIDCConfiguration, SAMLConfiguration } from "@/plane-admin/components/authentication";
|
||||
// images
|
||||
import { useInstanceFlag } from "@/plane-admin/hooks/store/use-instance-flag";
|
||||
import OIDCLogo from "@/public/logos/oidc-logo.svg";
|
||||
import SAMLLogo from "@/public/logos/saml-logo.svg";
|
||||
// plane admin hooks
|
||||
|
||||
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,
|
||||
}) => [
|
||||
...getBaseAuthenticationModes({ disabled, updateConfig, resolvedTheme }),
|
||||
{
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export * from "./authentication-modes";
|
||||
export * from "./oidc-config";
|
||||
export * from "./saml-config";
|
||||
export * from "./saml-attribute-mapping-table";
|
||||
|
||||
57
admin/ee/components/authentication/oidc-config.tsx
Normal file
57
admin/ee/components/authentication/oidc-config.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
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>
|
||||
);
|
||||
57
admin/ee/components/authentication/saml-config.tsx
Normal file
57
admin/ee/components/authentication/saml-config.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { TInstanceEnterpriseAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceEnterpriseAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const SAMLConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableSAMLConfig = formattedConfig?.IS_SAML_ENABLED ?? "";
|
||||
const isSAMLConfigured = !!formattedConfig?.SAML_ENTITY_ID && !!formattedConfig?.SAML_CERTIFICATE;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSAMLConfigured ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/authentication/saml" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
|
||||
Edit
|
||||
</Link>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableSAMLConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableSAMLConfig)) === true
|
||||
? updateConfig("IS_SAML_ENABLED", "0")
|
||||
: updateConfig("IS_SAML_ENABLED", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href="/authentication/saml"
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export * from "ce/components/common";
|
||||
export * from "./upgrade-button";
|
||||
|
||||
38
admin/ee/components/common/upgrade-button.tsx
Normal file
38
admin/ee/components/common/upgrade-button.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
11
admin/ee/hooks/store/use-instance-feature-flag.ts
Normal file
11
admin/ee/hooks/store/use-instance-feature-flag.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
// context
|
||||
import { StoreContext } from "@/lib/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;
|
||||
};
|
||||
13
admin/ee/hooks/store/use-instance-flag.ts
Normal file
13
admin/ee/hooks/store/use-instance-flag.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useContext } from "react";
|
||||
// context
|
||||
import { StoreContext } from "@/lib/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;
|
||||
};
|
||||
48
admin/ee/store/instance-feature-flags.store.ts
Normal file
48
admin/ee/store/instance-feature-flags.store.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { set } from "lodash";
|
||||
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 +1,29 @@
|
||||
export * from "ce/store/root.store";
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "14.2.30",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss": "^8.4.49",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "7.51.5",
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
"paths": {
|
||||
"@/*": ["core/*"],
|
||||
"@/public/*": ["public/*"],
|
||||
"@/plane-admin/*": ["ce/*"],
|
||||
"@/styles/*": ["styles/*"]
|
||||
"@/plane-admin/*": ["ee/*"],
|
||||
"@/styles/*": ["styles/*"],
|
||||
"@/ce/*": ["ce/*"]
|
||||
},
|
||||
"strictNullChecks": true
|
||||
},
|
||||
|
||||
@@ -61,6 +61,9 @@ 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"
|
||||
|
||||
# Hard delete files after days
|
||||
|
||||
1
apiserver/.gitignore
vendored
Normal file
1
apiserver/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dump.rdb
|
||||
@@ -3,8 +3,8 @@ 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 INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/f366114e9aba4cbfbe4265b8f2b74f65
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
@@ -27,6 +27,7 @@ RUN apk add --no-cache --virtual .build-deps \
|
||||
"postgresql-dev" \
|
||||
"libc-dev" \
|
||||
"linux-headers" \
|
||||
"xmlsec-dev"\
|
||||
&& \
|
||||
pip install -r requirements.txt --compile --no-cache-dir \
|
||||
&& \
|
||||
|
||||
@@ -4,7 +4,8 @@ FROM python:3.12.5-alpine AS backend
|
||||
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
|
||||
|
||||
|
||||
RUN apk --no-cache add \
|
||||
"bash~=5.2" \
|
||||
@@ -21,7 +22,8 @@ RUN apk --no-cache add \
|
||||
"make" \
|
||||
"postgresql-dev" \
|
||||
"libc-dev" \
|
||||
"linux-headers"
|
||||
"linux-headers" \
|
||||
"xmlsec-dev"
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
|
||||
1
apiserver/README.md
Normal file
1
apiserver/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# API SERVER
|
||||
18
apiserver/bin/docker-entrypoint-api-cloud.sh
Executable file
18
apiserver/bin/docker-entrypoint-api-cloud.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
export SKIP_ENV_VAR=0
|
||||
|
||||
python manage.py wait_for_db
|
||||
# Wait for migrations
|
||||
python manage.py wait_for_migrations
|
||||
|
||||
# Clear Cache before starting to remove stale values
|
||||
python manage.py clear_cache
|
||||
|
||||
# Register instance if INSTANCE_ADMIN_EMAIL is set
|
||||
if [ -n "$INSTANCE_ADMIN_EMAIL" ]; then
|
||||
python manage.py setup_instance $INSTANCE_ADMIN_EMAIL
|
||||
fi
|
||||
|
||||
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
39
apiserver/bin/docker-entrypoint-api-ee.sh
Executable file
39
apiserver/bin/docker-entrypoint-api-ee.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
python manage.py wait_for_db
|
||||
# Wait for migrations
|
||||
python manage.py wait_for_migrations
|
||||
|
||||
# Create the default bucket
|
||||
#!/bin/bash
|
||||
|
||||
# Collect system information
|
||||
HOSTNAME=$(hostname)
|
||||
MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1)
|
||||
CPU_INFO=$(cat /proc/cpuinfo)
|
||||
MEMORY_INFO=$(free -h)
|
||||
DISK_INFO=$(df -h)
|
||||
|
||||
# Concatenate information and compute SHA-256 hash
|
||||
SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}')
|
||||
|
||||
# Export the variables
|
||||
MACHINE_SIGNATURE=${MACHINE_SIGNATURE:-$SIGNATURE}
|
||||
export SKIP_ENV_VAR=1
|
||||
|
||||
# Register instance
|
||||
python manage.py register_instance_ee "$MACHINE_SIGNATURE"
|
||||
|
||||
# Load the configuration variable
|
||||
python manage.py configure_instance
|
||||
|
||||
# Create the default bucket
|
||||
python manage.py create_bucket
|
||||
|
||||
# Clear Cache before starting to remove stale values
|
||||
python manage.py clear_cache
|
||||
|
||||
# Fetch latest information from monitor and update all licenses
|
||||
python manage.py update_licenses
|
||||
|
||||
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
@@ -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 -
|
||||
@@ -16,3 +16,4 @@ from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
|
||||
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
|
||||
from .intake import IntakeIssueSerializer
|
||||
from .estimate import EstimatePointSerializer
|
||||
from .issue_type import IssueTypeAPISerializer, ProjectIssueTypeAPISerializer
|
||||
|
||||
@@ -56,7 +56,7 @@ class IssueSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Issue
|
||||
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"]
|
||||
exclude = ["description", "description_stripped"]
|
||||
exclude = ["description"]
|
||||
|
||||
def validate(self, data):
|
||||
if (
|
||||
@@ -405,7 +405,7 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
exclude = ["comment_stripped", "comment_json"]
|
||||
exclude = ["comment_json"]
|
||||
|
||||
def validate(self, data):
|
||||
try:
|
||||
|
||||
42
apiserver/plane/api/serializers/issue_type.py
Normal file
42
apiserver/plane/api/serializers/issue_type.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from plane.ee.serializers import BaseSerializer
|
||||
from plane.db.models import IssueType, ProjectIssueType
|
||||
|
||||
|
||||
class IssueTypeAPISerializer(BaseSerializer):
|
||||
project_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||
|
||||
class Meta:
|
||||
model = IssueType
|
||||
fields = 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 = fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"level",
|
||||
"is_default",
|
||||
"deleted_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
@@ -71,9 +71,16 @@ class ModuleSerializer(BaseSerializer):
|
||||
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_id=project_id).exists():
|
||||
module = Module.objects.filter(
|
||||
name=module_name, project_id=project_id
|
||||
).first()
|
||||
|
||||
if module:
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Module with this name already exists"}
|
||||
{
|
||||
"id": str(module.id),
|
||||
"error": "Module with this name already exists",
|
||||
}
|
||||
)
|
||||
|
||||
module = Module.objects.create(**validated_data, project_id=project_id)
|
||||
|
||||
@@ -5,6 +5,12 @@ 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 .issue_type import urlpatterns as issue_type_patterns
|
||||
|
||||
# ee imports
|
||||
from plane.ee.urls.api import urlpatterns as ee_api_urls
|
||||
|
||||
urlpatterns = [
|
||||
*project_patterns,
|
||||
@@ -12,6 +18,10 @@ urlpatterns = [
|
||||
*issue_patterns,
|
||||
*cycle_patterns,
|
||||
*module_patterns,
|
||||
*user_patterns,
|
||||
*intake_patterns,
|
||||
*member_patterns,
|
||||
*issue_type_patterns,
|
||||
*asset_patterns,
|
||||
*ee_api_urls,
|
||||
]
|
||||
|
||||
30
apiserver/plane/api/urls/asset.py
Normal file
30
apiserver/plane/api/urls/asset.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
UserAssetEndpoint,
|
||||
UserServerAssetEndpoint,
|
||||
GenericAssetEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("assets/user-assets/", UserAssetEndpoint.as_view(), name="users"),
|
||||
path(
|
||||
"assets/user-assets/<uuid:asset_id>/", UserAssetEndpoint.as_view(), name="users"
|
||||
),
|
||||
path("assets/user-assets/server/", UserServerAssetEndpoint.as_view(), name="users"),
|
||||
path(
|
||||
"assets/user-assets/<uuid:asset_id>/server/",
|
||||
UserServerAssetEndpoint.as_view(),
|
||||
name="users",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/assets/",
|
||||
GenericAssetEndpoint.as_view(),
|
||||
name="generic-asset",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/assets/<uuid:asset_id>/",
|
||||
GenericAssetEndpoint.as_view(),
|
||||
name="generic-asset-detail",
|
||||
),
|
||||
]
|
||||
@@ -8,9 +8,16 @@ from plane.api.views import (
|
||||
IssueActivityAPIEndpoint,
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
IssueAttachmentServerEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/search/",
|
||||
IssueSearchEndpoint.as_view(),
|
||||
name="issue-search",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/<str:project__identifier>-<str:issue__identifier>/",
|
||||
WorkspaceIssueAPIEndpoint.as_view(),
|
||||
@@ -76,4 +83,14 @@ urlpatterns = [
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
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",
|
||||
),
|
||||
]
|
||||
|
||||
18
apiserver/plane/api/urls/issue_type.py
Normal file
18
apiserver/plane/api/urls/issue_type.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import IssueTypeAPIEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
# ======================== issue types start ========================
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-types/",
|
||||
IssueTypeAPIEndpoint.as_view(),
|
||||
name="external-issue-type",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-types/<uuid:type_id>/",
|
||||
IssueTypeAPIEndpoint.as_view(),
|
||||
name="external-issue-type-detail",
|
||||
),
|
||||
# ======================== issue types end ========================
|
||||
]
|
||||
@@ -1,11 +1,16 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import ProjectMemberAPIEndpoint
|
||||
from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<str:project_id>/members/",
|
||||
ProjectMemberAPIEndpoint.as_view(),
|
||||
name="users",
|
||||
)
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/members/",
|
||||
WorkspaceMemberAPIEndpoint.as_view(),
|
||||
name="users",
|
||||
),
|
||||
]
|
||||
|
||||
7
apiserver/plane/api/urls/user.py
Normal file
7
apiserver/plane/api/urls/user.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import UserEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
path("users/me/", UserEndpoint.as_view(), name="users"),
|
||||
]
|
||||
@@ -10,6 +10,8 @@ from .issue import (
|
||||
IssueCommentAPIEndpoint,
|
||||
IssueActivityAPIEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
IssueAttachmentServerEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
)
|
||||
|
||||
from .cycle import (
|
||||
@@ -25,6 +27,10 @@ from .module import (
|
||||
ModuleArchiveUnarchiveAPIEndpoint,
|
||||
)
|
||||
|
||||
from .member import ProjectMemberAPIEndpoint
|
||||
from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
|
||||
from .user import UserEndpoint
|
||||
|
||||
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
|
||||
|
||||
from .issue_type import IssueTypeAPIEndpoint
|
||||
from .intake import IntakeIssueAPIEndpoint
|
||||
|
||||
407
apiserver/plane/api/views/asset.py
Normal file
407
apiserver/plane/api/views/asset.py
Normal file
@@ -0,0 +1,407 @@
|
||||
# Python Imports
|
||||
import uuid
|
||||
|
||||
# Django Imports
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module Imports
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.db.models import FileAsset, User, Workspace
|
||||
from plane.api.views.base import BaseAPIView
|
||||
|
||||
|
||||
class UserAssetEndpoint(BaseAPIView):
|
||||
"""This endpoint is used to upload user profile images."""
|
||||
|
||||
def asset_delete(self, asset_id):
|
||||
asset = FileAsset.objects.filter(id=asset_id).first()
|
||||
if asset is None:
|
||||
return
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return
|
||||
|
||||
def entity_asset_delete(self, entity_type, asset, request):
|
||||
# User Avatar
|
||||
if entity_type == FileAsset.EntityTypeContext.USER_AVATAR:
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.avatar_asset_id = None
|
||||
user.save()
|
||||
return
|
||||
# User Cover
|
||||
if entity_type == FileAsset.EntityTypeContext.USER_COVER:
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.cover_image_asset_id = None
|
||||
user.save()
|
||||
return
|
||||
return
|
||||
|
||||
def post(self, request):
|
||||
# get the asset key
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type", False)
|
||||
|
||||
# Check if the file size is within the limit
|
||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
||||
|
||||
# Check if the entity type is allowed
|
||||
if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]:
|
||||
return Response(
|
||||
{"error": "Invalid entity type.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the file type is allowed
|
||||
allowed_types = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/jpg",
|
||||
"image/gif",
|
||||
]
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{uuid.uuid4().hex}-{name}"
|
||||
|
||||
# Create a File Asset
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={"name": name, "type": type, "size": size_limit},
|
||||
asset=asset_key,
|
||||
size=size_limit,
|
||||
user=request.user,
|
||||
created_by=request.user,
|
||||
entity_type=entity_type,
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
"upload_data": presigned_url,
|
||||
"asset_id": str(asset.id),
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def patch(self, request, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if not asset.storage_metadata:
|
||||
get_asset_object_metadata.delay(asset_id=str(asset_id))
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save(update_fields=["is_uploaded", "attributes"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def delete(self, request, asset_id):
|
||||
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
|
||||
)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class UserServerAssetEndpoint(BaseAPIView):
|
||||
"""This endpoint is used to upload user profile images."""
|
||||
|
||||
def asset_delete(self, asset_id):
|
||||
asset = FileAsset.objects.filter(id=asset_id).first()
|
||||
if asset is None:
|
||||
return
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return
|
||||
|
||||
def entity_asset_delete(self, entity_type, asset, request):
|
||||
# User Avatar
|
||||
if entity_type == FileAsset.EntityTypeContext.USER_AVATAR:
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.avatar_asset_id = None
|
||||
user.save()
|
||||
return
|
||||
# User Cover
|
||||
if entity_type == FileAsset.EntityTypeContext.USER_COVER:
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.cover_image_asset_id = None
|
||||
user.save()
|
||||
return
|
||||
return
|
||||
|
||||
def post(self, request):
|
||||
# get the asset key
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type", False)
|
||||
|
||||
# Check if the file size is within the limit
|
||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
||||
|
||||
# Check if the entity type is allowed
|
||||
if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]:
|
||||
return Response(
|
||||
{"error": "Invalid entity type.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the file type is allowed
|
||||
allowed_types = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/jpg",
|
||||
"image/gif",
|
||||
]
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{uuid.uuid4().hex}-{name}"
|
||||
|
||||
# Create a File Asset
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={"name": name, "type": type, "size": size_limit},
|
||||
asset=asset_key,
|
||||
size=size_limit,
|
||||
user=request.user,
|
||||
created_by=request.user,
|
||||
entity_type=entity_type,
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request, is_server=True)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
"upload_data": presigned_url,
|
||||
"asset_id": str(asset.id),
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def patch(self, request, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if not asset.storage_metadata:
|
||||
get_asset_object_metadata.delay(asset_id=str(asset_id))
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save(update_fields=["is_uploaded", "attributes"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def delete(self, request, asset_id):
|
||||
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
|
||||
)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class GenericAssetEndpoint(BaseAPIView):
|
||||
"""This endpoint is used to upload generic assets that can be later bound to entities."""
|
||||
|
||||
def get(self, request, slug, asset_id=None):
|
||||
"""Get a presigned URL for an asset"""
|
||||
try:
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# If asset_id is not provided, return 400
|
||||
if not asset_id:
|
||||
return Response(
|
||||
{"error": "Asset ID is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the asset
|
||||
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:
|
||||
return Response(
|
||||
{"error": "Asset not yet uploaded"},
|
||||
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(
|
||||
object_name=asset.asset.name, filename=asset.attributes.get("name")
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"asset_id": str(asset.id),
|
||||
"asset_url": presigned_url,
|
||||
"asset_name": asset.attributes.get("name", ""),
|
||||
"asset_type": asset.attributes.get("type", ""),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Workspace.DoesNotExist:
|
||||
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
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
def post(self, request, slug):
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
project_id = request.data.get("project_id")
|
||||
external_id = request.data.get("external_id")
|
||||
external_source = request.data.get("external_source")
|
||||
|
||||
# Check if the request is valid
|
||||
if not name or not size:
|
||||
return Response(
|
||||
{"error": "Name and size are required fields.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the file size is within the limit
|
||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
||||
|
||||
# Check if the file type is allowed
|
||||
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||
return Response(
|
||||
{"error": "Invalid file type.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
# Check for existing asset with same external details if provided
|
||||
if external_id and external_source:
|
||||
existing_asset = FileAsset.objects.filter(
|
||||
workspace__slug=slug,
|
||||
external_source=external_source,
|
||||
external_id=external_id,
|
||||
is_deleted=False,
|
||||
).first()
|
||||
|
||||
if existing_asset:
|
||||
return Response(
|
||||
{
|
||||
"message": "Asset with same external id and source already exists",
|
||||
"asset_id": str(existing_asset.id),
|
||||
"asset_url": existing_asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
# Create a File Asset
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={"name": name, "type": type, "size": size_limit},
|
||||
asset=asset_key,
|
||||
size=size_limit,
|
||||
workspace_id=workspace.id,
|
||||
project_id=project_id,
|
||||
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
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"upload_data": presigned_url,
|
||||
"asset_id": str(asset.id),
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def patch(self, request, slug, asset_id):
|
||||
try:
|
||||
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)
|
||||
|
||||
# Update storage metadata if not present
|
||||
if not asset.storage_metadata:
|
||||
get_asset_object_metadata.delay(asset_id=str(asset_id))
|
||||
|
||||
asset.save(update_fields=["is_uploaded"])
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
@@ -14,10 +14,17 @@ from rest_framework.response import Response
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.views import APIView
|
||||
from oauth2_provider.contrib.rest_framework import (
|
||||
OAuth2Authentication,
|
||||
IsAuthenticatedOrTokenHasScope,
|
||||
)
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
@@ -37,9 +44,13 @@ class TimezoneMixin:
|
||||
|
||||
|
||||
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
||||
authentication_classes = [APIKeyAuthentication]
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
authentication_classes = [APIKeyAuthentication, OAuth2Authentication]
|
||||
permission_classes = [
|
||||
IsAuthenticated,
|
||||
IsAuthenticatedOrTokenHasScope,
|
||||
OauthApplicationWorkspacePermission,
|
||||
]
|
||||
required_scopes = ["read", "write"]
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
for backend in list(self.filter_backends):
|
||||
@@ -47,7 +58,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
||||
return queryset
|
||||
|
||||
def get_throttles(self):
|
||||
throttle_classes = []
|
||||
throttle_classes = super().get_throttles()
|
||||
api_key = self.request.headers.get("X-Api-Key")
|
||||
|
||||
if api_key:
|
||||
@@ -60,6 +71,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
||||
return throttle_classes
|
||||
|
||||
throttle_classes.append(ApiKeyRateThrottle())
|
||||
throttle_classes.append(OAuthTokenRateThrottle())
|
||||
|
||||
return throttle_classes
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
class CycleAPIEndpoint(BaseAPIView):
|
||||
@@ -271,6 +274,7 @@ class CycleAPIEndpoint(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",
|
||||
@@ -656,24 +660,50 @@ class CycleIssueAPIEndpoint(BaseAPIView):
|
||||
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(
|
||||
{
|
||||
"error": "The Cycle has already been completed so no new issues can be added"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get all CycleIssues already created
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
|
||||
)
|
||||
|
||||
existing_issues = [
|
||||
str(cycle_issue.issue_id)
|
||||
for cycle_issue in cycle_issues
|
||||
if str(cycle_issue.issue_id) in issues
|
||||
]
|
||||
existing_issues = [str(cycle_issue.issue_id) for cycle_issue in cycle_issues]
|
||||
new_issues = list(set(issues) - set(existing_issues))
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -705,6 +735,22 @@ class CycleIssueAPIEndpoint(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",
|
||||
@@ -753,6 +799,18 @@ class CycleIssueAPIEndpoint(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)
|
||||
|
||||
|
||||
@@ -1165,6 +1223,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
issue__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
updated_cycles = []
|
||||
@@ -1184,6 +1243,39 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
updated_cycles, ["cycle_id"], batch_size=100
|
||||
)
|
||||
|
||||
# EE code
|
||||
# Extract issue IDs from cycle_issues
|
||||
issue_ids = [ci.issue_id for ci in cycle_issues]
|
||||
|
||||
# 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",
|
||||
)
|
||||
|
||||
# Trigger Celery task for ADDED
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{
|
||||
"issue_id": str(issue_id),
|
||||
"cycle_id": str(new_cycle_id),
|
||||
}
|
||||
for issue_id in issue_ids
|
||||
],
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="ADDED",
|
||||
)
|
||||
|
||||
# EE code end here
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q, Value, UUIDField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Q, UUIDField, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -17,8 +17,18 @@ from rest_framework.response import Response
|
||||
from plane.api.serializers import IntakeIssueSerializer, IssueSerializer
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
|
||||
from plane.db.models import (
|
||||
Intake,
|
||||
IntakeIssue,
|
||||
Issue,
|
||||
Project,
|
||||
ProjectMember,
|
||||
State,
|
||||
IssueType,
|
||||
)
|
||||
from plane.utils.host import base_host
|
||||
from plane.ee.models import IntakeSetting
|
||||
from plane.ee.utils.workflow import WorkflowStateManager
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models.intake import SourceType
|
||||
|
||||
@@ -110,6 +120,11 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
|
||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get the issue type
|
||||
issue_type = IssueType.objects.filter(
|
||||
project_issue_types__project_id=project_id, is_epic=False, is_default=True
|
||||
).first()
|
||||
|
||||
# create an issue
|
||||
issue = Issue.objects.create(
|
||||
name=request.data.get("issue", {}).get("name"),
|
||||
@@ -119,6 +134,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
|
||||
),
|
||||
priority=request.data.get("issue", {}).get("priority", "none"),
|
||||
project_id=project_id,
|
||||
type=issue_type,
|
||||
)
|
||||
|
||||
# create an intake issue
|
||||
@@ -159,6 +175,16 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
intake_settings = IntakeSetting.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, intake=intake
|
||||
).first()
|
||||
|
||||
if intake_settings is not None and not intake_settings.is_in_app_enabled:
|
||||
return Response(
|
||||
{"error": "Creating intake issues is disabled"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the intake issue
|
||||
intake_issue = IntakeIssue.objects.get(
|
||||
issue_id=issue_id,
|
||||
@@ -213,6 +239,23 @@ class IntakeIssueAPIEndpoint(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 = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
@@ -57,8 +58,16 @@ from plane.settings.storage import S3Storage
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
from plane.ee.utils.workflow import WorkflowStateManager
|
||||
from plane.ee.bgtasks.entity_issue_state_progress_task import (
|
||||
entity_issue_state_activity_task,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceIssueAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
@@ -278,6 +287,17 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
"default_assignee_id": project.default_assignee_id,
|
||||
},
|
||||
)
|
||||
if request.data.get("state_id"):
|
||||
workflow_state_manager = WorkflowStateManager(
|
||||
project_id=project_id, slug=slug
|
||||
)
|
||||
if workflow_state_manager.validate_issue_creation(
|
||||
state_id=request.data.get("state_id"), user_id=request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot create a work item in this state"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
@@ -374,6 +394,23 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
},
|
||||
partial=True,
|
||||
)
|
||||
|
||||
# Check if state is updated then is the transition allowed
|
||||
workflow_state_manager = WorkflowStateManager(
|
||||
project_id=project_id, slug=slug
|
||||
)
|
||||
if request.data.get(
|
||||
"state_id"
|
||||
) and not workflow_state_manager.validate_state_transition(
|
||||
issue=issue,
|
||||
new_state_id=request.data.get("state_id"),
|
||||
user_id=request.user.id,
|
||||
):
|
||||
return Response(
|
||||
{"error": "State transition is not allowed"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
# If the serializer is valid, save the issue and dispatch
|
||||
# the update issue activity worker event.
|
||||
@@ -387,6 +424,22 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
cycle_issue = CycleIssue.objects.filter(issue_id=issue.id).first()
|
||||
if cycle_issue and (
|
||||
request.data.get("state_id")
|
||||
or request.data.get("estimate_point")
|
||||
):
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{
|
||||
"issue_id": str(issue.id),
|
||||
"cycle_id": str(cycle_issue.cycle_id),
|
||||
}
|
||||
],
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="UPDATED",
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
# If the serializer is not valid, respond with 400 bad
|
||||
@@ -427,6 +480,22 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
"created_by", request.user.id
|
||||
)
|
||||
issue.save(update_fields=["created_at", "created_by"])
|
||||
cycle_issue = CycleIssue.objects.filter(issue_id=issue.id).first()
|
||||
if cycle_issue and (
|
||||
request.data.get("state_id")
|
||||
or request.data.get("estimate_point")
|
||||
):
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{
|
||||
"issue_id": str(issue.id),
|
||||
"cycle_id": str(cycle_issue.cycle_id),
|
||||
}
|
||||
],
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="UPDATED",
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
@@ -460,6 +529,21 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
context={"project_id": project_id, "workspace_id": project.workspace_id},
|
||||
partial=True,
|
||||
)
|
||||
|
||||
# Check if state is updated then is the transition allowed
|
||||
workflow_state_manager = WorkflowStateManager(project_id=project_id, slug=slug)
|
||||
if request.data.get(
|
||||
"state_id"
|
||||
) and not workflow_state_manager.validate_state_transition(
|
||||
issue=issue,
|
||||
new_state_id=request.data.get("state_id"),
|
||||
user_id=request.user.id,
|
||||
):
|
||||
return Response(
|
||||
{"error": "State transition is not allowed"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -491,6 +575,21 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
cycle_issue = CycleIssue.objects.filter(issue_id=pk).first()
|
||||
if cycle_issue and (
|
||||
request.data.get("state_id") or request.data.get("estimate_point")
|
||||
):
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{
|
||||
"issue_id": str(issue.id),
|
||||
"cycle_id": str(cycle_issue.cycle_id),
|
||||
}
|
||||
],
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="UPDATED",
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -512,6 +611,18 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
current_instance = json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
cycle_issue = CycleIssue.objects.filter(issue_id=pk).first()
|
||||
if cycle_issue:
|
||||
# added a entry to remove from the entity issue state activity
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{"issue_id": str(issue.id), "cycle_id": str(cycle_issue.cycle_id)}
|
||||
],
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="REMOVED",
|
||||
)
|
||||
# EE code end here
|
||||
issue.delete()
|
||||
issue_activity.delay(
|
||||
type="issue.activity.deleted",
|
||||
@@ -795,12 +906,39 @@ class IssueCommentAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, issue_id, pk=None):
|
||||
external_id = request.GET.get("external_id")
|
||||
external_source = request.GET.get("external_source")
|
||||
|
||||
if external_id and external_source:
|
||||
try:
|
||||
issue_comment = IssueComment.objects.get(
|
||||
external_id=external_id,
|
||||
external_source=external_source,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
)
|
||||
serializer = IssueCommentSerializer(
|
||||
issue_comment, fields=self.fields, expand=self.expand
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except IssueComment.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Comment not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
if pk:
|
||||
issue_comment = self.get_queryset().get(pk=pk)
|
||||
serializer = IssueCommentSerializer(
|
||||
issue_comment, fields=self.fields, expand=self.expand
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
try:
|
||||
issue_comment = self.get_queryset().get(pk=pk)
|
||||
serializer = IssueCommentSerializer(
|
||||
issue_comment, fields=self.fields, expand=self.expand
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except IssueComment.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Comment not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
@@ -846,7 +984,8 @@ class IssueCommentAPIEndpoint(BaseAPIView):
|
||||
issue_comment.created_by_id = request.data.get(
|
||||
"created_by", request.user.id
|
||||
)
|
||||
issue_comment.save(update_fields=["created_at", "created_by"])
|
||||
issue_comment.actor_id = request.data.get("created_by", request.user.id)
|
||||
issue_comment.save(update_fields=["created_at", "created_by", "actor"])
|
||||
|
||||
issue_activity.delay(
|
||||
type="comment.activity.created",
|
||||
@@ -977,7 +1116,15 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
||||
# Check if the file size is greater than the limit
|
||||
if check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.FILE_SIZE_LIMIT_PRO,
|
||||
slug=slug,
|
||||
user_id=str(request.user.id),
|
||||
):
|
||||
size_limit = min(size, settings.PRO_FILE_SIZE_LIMIT)
|
||||
else:
|
||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
||||
|
||||
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||
return Response(
|
||||
@@ -1139,3 +1286,245 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
get_asset_object_metadata.delay(str(issue_attachment.id))
|
||||
issue_attachment.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class IssueAttachmentServerEndpoint(BaseAPIView):
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
model = FileAsset
|
||||
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", False)
|
||||
size = request.data.get("size")
|
||||
external_id = request.data.get("external_id")
|
||||
external_source = request.data.get("external_source")
|
||||
|
||||
# Check if the request is valid
|
||||
if not name or not size:
|
||||
return Response(
|
||||
{"error": "Invalid request.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the file size is greater than the limit
|
||||
if check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.FILE_SIZE_LIMIT_PRO,
|
||||
slug=slug,
|
||||
user_id=str(request.user.id),
|
||||
):
|
||||
size_limit = min(size, settings.PRO_FILE_SIZE_LIMIT)
|
||||
else:
|
||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
||||
|
||||
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||
return Response(
|
||||
{"error": "Invalid file type.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and FileAsset.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
issue_id=issue_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
).exists()
|
||||
):
|
||||
asset = FileAsset.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
issue_id=issue_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Issue with the same external id and external source already exists",
|
||||
"id": str(asset.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
# Create a File Asset
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={"name": name, "type": type, "size": size_limit},
|
||||
asset=asset_key,
|
||||
size=size_limit,
|
||||
workspace_id=workspace.id,
|
||||
created_by=request.user,
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
external_id=external_id,
|
||||
external_source=external_source,
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request, is_server=True)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
"upload_data": presigned_url,
|
||||
"asset_id": str(asset.id),
|
||||
"attachment": IssueAttachmentSerializer(asset).data,
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
issue_attachment.is_deleted = True
|
||||
issue_attachment.deleted_at = timezone.now()
|
||||
issue_attachment.save()
|
||||
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.deleted",
|
||||
requested_data=None,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
# Get the storage metadata
|
||||
if not issue_attachment.storage_metadata:
|
||||
get_asset_object_metadata.delay(str(issue_attachment.id))
|
||||
issue_attachment.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(self, request, slug, project_id, issue_id, pk=None):
|
||||
if pk:
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(
|
||||
id=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if not asset.is_uploaded:
|
||||
return Response(
|
||||
{"error": "The asset is not uploaded.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
storage = S3Storage(request=request)
|
||||
presigned_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
disposition="attachment",
|
||||
filename=asset.attributes.get("name"),
|
||||
)
|
||||
return HttpResponseRedirect(presigned_url)
|
||||
|
||||
# Get all the attachments
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
issue_id=issue_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
is_uploaded=True,
|
||||
)
|
||||
# Serialize the attachments
|
||||
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def patch(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = IssueAttachmentSerializer(issue_attachment)
|
||||
|
||||
# Send this activity only if the attachment is not uploaded before
|
||||
if not issue_attachment.is_uploaded:
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.created",
|
||||
requested_data=None,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
# Update the attachment
|
||||
issue_attachment.is_uploaded = True
|
||||
issue_attachment.created_by = request.user
|
||||
|
||||
# Get the storage metadata
|
||||
if not issue_attachment.storage_metadata:
|
||||
get_asset_object_metadata.delay(str(issue_attachment.id))
|
||||
issue_attachment.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class IssueSearchEndpoint(BaseAPIView):
|
||||
"""Endpoint to search across multiple fields in the issues"""
|
||||
|
||||
def get(self, request, slug):
|
||||
query = request.query_params.get("search", False)
|
||||
limit = request.query_params.get("limit", 10)
|
||||
workspace_search = request.query_params.get("workspace_search", "false")
|
||||
project_id = request.query_params.get("project_id", False)
|
||||
|
||||
if not query:
|
||||
return Response({"issues": []}, status=status.HTTP_200_OK)
|
||||
|
||||
# Build search query
|
||||
fields = ["name", "sequence_id", "project__identifier"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
if field == "sequence_id":
|
||||
# Match whole integers only (exclude decimal numbers)
|
||||
sequences = re.findall(r"\b\d+\b", query)
|
||||
for sequence_id in sequences:
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
# Filter issues
|
||||
issues = Issue.issue_objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
# Apply project filter if not searching across workspace
|
||||
if workspace_search == "false" and project_id:
|
||||
issues = issues.filter(project_id=project_id)
|
||||
|
||||
# Get results
|
||||
issue_results = issues.distinct().values(
|
||||
"name",
|
||||
"id",
|
||||
"sequence_id",
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
"type_id",
|
||||
)[: int(limit)]
|
||||
|
||||
return Response({"issues": issue_results}, status=status.HTTP_200_OK)
|
||||
|
||||
234
apiserver/plane/api/views/issue_type.py
Normal file
234
apiserver/plane/api/views/issue_type.py
Normal file
@@ -0,0 +1,234 @@
|
||||
# Python imports
|
||||
import random
|
||||
|
||||
# Django imports
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
class IssueTypeAPIEndpoint(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)
|
||||
def get(self, request, slug, project_id, type_id=None):
|
||||
# list of issue types
|
||||
if type_id is None:
|
||||
issue_types = 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_types, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# getting issue type by id
|
||||
issue_type = self.get_queryset().get(pk=type_id)
|
||||
serializer = self.serializer_class(issue_type)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# create issue type
|
||||
@check_feature_flag(FeatureFlag.ISSUE_TYPES)
|
||||
def post(self, request, slug, project_id):
|
||||
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)
|
||||
|
||||
# update issue type by id
|
||||
@check_feature_flag(FeatureFlag.ISSUE_TYPES)
|
||||
def patch(self, request, slug, project_id, type_id):
|
||||
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
|
||||
@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)
|
||||
@@ -15,7 +15,35 @@ from .base import BaseAPIView
|
||||
from plane.api.serializers import UserLiteSerializer
|
||||
from plane.db.models import User, Workspace, Project, WorkspaceMember, ProjectMember
|
||||
|
||||
from plane.app.permissions import ProjectMemberPermission
|
||||
from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission
|
||||
|
||||
from plane.payment.bgtasks.member_sync_task import member_sync_task
|
||||
|
||||
|
||||
class WorkspaceMemberAPIEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkSpaceAdminPermission]
|
||||
|
||||
# Get all the users that are present inside the workspace
|
||||
def get(self, request, slug):
|
||||
# Check if the workspace exists
|
||||
if not Workspace.objects.filter(slug=slug).exists():
|
||||
return Response(
|
||||
{"error": "Provided workspace does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug
|
||||
).select_related("member")
|
||||
|
||||
# Get all the users with their roles
|
||||
users_with_roles = []
|
||||
for workspace_member in workspace_members:
|
||||
user_data = UserLiteSerializer(workspace_member.member).data
|
||||
user_data["role"] = workspace_member.role
|
||||
users_with_roles.append(user_data)
|
||||
|
||||
return Response(users_with_roles, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
# API endpoint to get and insert users inside the workspace
|
||||
@@ -30,7 +58,6 @@ 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
|
||||
@@ -43,10 +70,7 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
|
||||
|
||||
return Response(users, status=status.HTTP_200_OK)
|
||||
|
||||
# Insert a new user inside the workspace, and assign the user to the project
|
||||
def post(self, request, slug, project_id):
|
||||
# Check if user with email already exists, and send bad request if it's
|
||||
# not present, check for workspace and valid project mandat
|
||||
# ------------------- Validation -------------------
|
||||
if (
|
||||
request.data.get("email") is None
|
||||
@@ -77,8 +101,8 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if user exists
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
workspace_member = None
|
||||
project_member = None
|
||||
|
||||
@@ -109,6 +133,7 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
|
||||
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()
|
||||
|
||||
@@ -126,6 +151,9 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -234,6 +234,21 @@ class ModuleAPIEndpoint(BaseAPIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
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,
|
||||
)
|
||||
|
||||
if pk:
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
|
||||
data = ModuleSerializer(
|
||||
|
||||
@@ -105,6 +105,20 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
def get(self, request, slug, pk=None):
|
||||
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,
|
||||
)
|
||||
|
||||
if pk is None:
|
||||
sort_order_query = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
@@ -143,6 +157,27 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
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
|
||||
@@ -275,6 +310,24 @@ class ProjectAPIEndpoint(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(
|
||||
|
||||
@@ -81,6 +81,20 @@ class StateAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, state_id=None):
|
||||
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,
|
||||
)
|
||||
if state_id:
|
||||
serializer = StateSerializer(
|
||||
self.get_queryset().get(pk=state_id),
|
||||
|
||||
17
apiserver/plane/api/views/user.py
Normal file
17
apiserver/plane/api/views/user.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import UserLiteSerializer
|
||||
from plane.api.views.base import BaseAPIView
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
class UserEndpoint(BaseAPIView):
|
||||
serializer_class = UserLiteSerializer
|
||||
model = User
|
||||
|
||||
def get(self, request):
|
||||
serializer = UserLiteSerializer(request.user)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
7
apiserver/plane/app/authentication/session.py
Normal file
7
apiserver/plane/app/authentication/session.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
|
||||
|
||||
class BaseSessionAuthentication(SessionAuthentication):
|
||||
# Disable csrf for the rest apis
|
||||
def enforce_csrf(self, request):
|
||||
return
|
||||
@@ -1,9 +1,16 @@
|
||||
from plane.db.models import WorkspaceMember, ProjectMember
|
||||
# Python imports
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from enum import Enum
|
||||
# Module imports
|
||||
from plane.db.models import WorkspaceMember, ProjectMember
|
||||
from plane.ee.models import TeamspaceProject, TeamspaceMember
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
class ROLE(Enum):
|
||||
@@ -12,14 +19,20 @@ class ROLE(Enum):
|
||||
GUEST = 5
|
||||
|
||||
|
||||
def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
||||
def allow_permission(
|
||||
allowed_roles,
|
||||
level="PROJECT",
|
||||
creator=False,
|
||||
field="created_by",
|
||||
model=None,
|
||||
):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(instance, request, *args, **kwargs):
|
||||
# Check for creator if required
|
||||
# Check for ownership if required
|
||||
if creator and model:
|
||||
obj = model.objects.filter(
|
||||
id=kwargs["pk"], created_by=request.user
|
||||
id=kwargs["pk"], **{field: request.user}
|
||||
).exists()
|
||||
if obj:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
@@ -47,6 +60,27 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
||||
is_active=True,
|
||||
).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 permission denied if no conditions are met
|
||||
return Response(
|
||||
|
||||
@@ -1,17 +1,64 @@
|
||||
# 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
|
||||
|
||||
# Permission Mappings
|
||||
Admin = 20
|
||||
Member = 15
|
||||
Guest = 5
|
||||
|
||||
# Permission Role Levels
|
||||
# These constants define the permission levels in the system
|
||||
Admin: int = 20 # Administrator level access
|
||||
Member: int = 15 # Regular member level access
|
||||
Guest: int = 5 # Guest/restricted level access
|
||||
|
||||
def check_teamspace_membership(view, request: Request) -> bool:
|
||||
"""
|
||||
Check if the user is a member of any teamspace associated with the project.
|
||||
|
||||
Args:
|
||||
view: The view instance containing workspace_slug and project_id
|
||||
request (Request): The incoming request object containing user information
|
||||
|
||||
Returns:
|
||||
bool: True if user is a member of any associated teamspace, False otherwise
|
||||
|
||||
Note:
|
||||
This function first checks if teamspaces feature is enabled for the workspace
|
||||
before performing the membership check.
|
||||
"""
|
||||
# check the user is part of the teamspace if the project is attached to any.
|
||||
if check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.TEAMSPACES,
|
||||
slug=view.workspace_slug,
|
||||
user_id=request.user.id,
|
||||
):
|
||||
## Get all the teamspace ids that the project is attached to.
|
||||
teamspace_ids = TeamspaceProject.objects.filter(
|
||||
workspace__slug=view.workspace_slug, project_id=view.project_id
|
||||
).values_list("team_space_id", flat=True)
|
||||
|
||||
# return True if the user is a member of any of the teamspace
|
||||
return TeamspaceMember.objects.filter(
|
||||
member=request.user, team_space_id__in=teamspace_ids
|
||||
).exists()
|
||||
return False
|
||||
|
||||
|
||||
class ProjectBasePermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Base permission class for project-related operations.
|
||||
|
||||
This class implements basic permission checks for project access:
|
||||
- Anonymous users are denied access
|
||||
- READ operations require workspace membership
|
||||
- CREATE operations require workspace admin/member role
|
||||
- UPDATE operations require project admin role
|
||||
"""
|
||||
def has_permission(self, request, view) -> bool:
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
@@ -41,7 +88,15 @@ class ProjectBasePermission(BasePermission):
|
||||
|
||||
|
||||
class ProjectMemberPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Permission class for project member operations.
|
||||
|
||||
Extends the base permissions with additional checks:
|
||||
- Allows teamspace members access if the project is associated with their teamspace
|
||||
- Provides different permission levels for different operations
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
@@ -60,7 +115,7 @@ class ProjectMemberPermission(BasePermission):
|
||||
).exists()
|
||||
|
||||
## Only Project Admins can update project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
is_project_member = ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
@@ -68,33 +123,59 @@ class ProjectMemberPermission(BasePermission):
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
# If the user is already an admin or member return True
|
||||
if is_project_member:
|
||||
return True
|
||||
|
||||
# check the user is part of the teamspace if the project is attached to any.
|
||||
return check_teamspace_membership(view=view, request=request)
|
||||
|
||||
|
||||
class ProjectEntityPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Permission class for project entity operations.
|
||||
|
||||
Handles permissions for project-related entities with additional features:
|
||||
- Supports project identification by identifier
|
||||
- Implements teamspace-based access control
|
||||
- Different permission levels for read/write operations
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
# Handle requests based on project__identifier
|
||||
if hasattr(view, "project__identifier") and view.project__identifier:
|
||||
if request.method in SAFE_METHODS:
|
||||
return ProjectMember.objects.filter(
|
||||
is_project_member = ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project__identifier=view.project__identifier,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
if is_project_member:
|
||||
return True
|
||||
else:
|
||||
return check_teamspace_membership(view=view, request=request)
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return ProjectMember.objects.filter(
|
||||
is_project_member = ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
if is_project_member:
|
||||
return True
|
||||
else:
|
||||
return check_teamspace_membership(view=view, request=request)
|
||||
|
||||
## Only project members or admins can create and edit the project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
is_project_member = ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
@@ -102,15 +183,32 @@ class ProjectEntityPermission(BasePermission):
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
if is_project_member:
|
||||
return True
|
||||
else:
|
||||
return check_teamspace_membership(view=view, request=request)
|
||||
|
||||
|
||||
class ProjectLitePermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Lightweight permission class for basic project access control.
|
||||
|
||||
Implements simplified permission checks:
|
||||
- Verifies project membership
|
||||
- Falls back to teamspace membership check
|
||||
"""
|
||||
def has_permission(self, request, view) -> bool:
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
return ProjectMember.objects.filter(
|
||||
is_project_member = ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
if is_project_member:
|
||||
return True
|
||||
else:
|
||||
return check_teamspace_membership(view=view, request=request)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .base import BaseSerializer
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .user import (
|
||||
UserSerializer,
|
||||
UserLiteSerializer,
|
||||
@@ -23,6 +23,7 @@ from .workspace import (
|
||||
WorkspaceRecentVisitSerializer,
|
||||
WorkspaceHomePreferenceSerializer,
|
||||
StickySerializer,
|
||||
WorkspaceUserMeSerializer,
|
||||
)
|
||||
from .project import (
|
||||
ProjectSerializer,
|
||||
@@ -33,7 +34,6 @@ from .project import (
|
||||
ProjectIdentifierSerializer,
|
||||
ProjectLiteSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
DeployBoardSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
ProjectPublicMemberSerializer,
|
||||
ProjectMemberRoleSerializer,
|
||||
@@ -45,6 +45,7 @@ from .cycle import (
|
||||
CycleIssueSerializer,
|
||||
CycleWriteSerializer,
|
||||
CycleUserPropertiesSerializer,
|
||||
EntityProgressSerializer,
|
||||
)
|
||||
from .asset import FileAssetSerializer
|
||||
from .issue import (
|
||||
@@ -92,7 +93,7 @@ from .importer import ImporterSerializer
|
||||
from .page import (
|
||||
PageSerializer,
|
||||
PageLogSerializer,
|
||||
SubPageSerializer,
|
||||
PageLiteSerializer,
|
||||
PageDetailSerializer,
|
||||
PageVersionSerializer,
|
||||
PageVersionDetailSerializer,
|
||||
@@ -128,3 +129,14 @@ from .draft import (
|
||||
DraftIssueSerializer,
|
||||
DraftIssueDetailSerializer,
|
||||
)
|
||||
from .integration import (
|
||||
IntegrationSerializer,
|
||||
WorkspaceIntegrationSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
SlackProjectSyncSerializer,
|
||||
)
|
||||
|
||||
from .deploy_board import DeployBoardSerializer
|
||||
|
||||
@@ -3,6 +3,7 @@ from rest_framework import serializers
|
||||
|
||||
class BaseSerializer(serializers.ModelSerializer):
|
||||
id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
deleted_at = serializers.ReadOnlyField()
|
||||
|
||||
|
||||
class DynamicBaseSerializer(BaseSerializer):
|
||||
|
||||
@@ -3,9 +3,11 @@ from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
|
||||
from .issue import IssueStateSerializer
|
||||
from plane.db.models import Cycle, CycleIssue, CycleUserProperties
|
||||
from plane.utils.timezone_converter import convert_to_utc
|
||||
from plane.ee.models import EntityProgress
|
||||
|
||||
|
||||
class CycleWriteSerializer(BaseSerializer):
|
||||
@@ -39,7 +41,13 @@ class CycleWriteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Cycle
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "owned_by", "archived_at"]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"owned_by",
|
||||
"archived_at",
|
||||
"version",
|
||||
]
|
||||
|
||||
|
||||
class CycleSerializer(BaseSerializer):
|
||||
@@ -102,4 +110,10 @@ class CycleUserPropertiesSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = CycleUserProperties
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "cycle" "user"]
|
||||
read_only_fields = ["workspace", "project", "cycle", "user"]
|
||||
|
||||
|
||||
class EntityProgressSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = EntityProgress
|
||||
fields = "__all__"
|
||||
|
||||
15
apiserver/plane/app/serializers/deploy_board.py
Normal file
15
apiserver/plane/app/serializers/deploy_board.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.app.serializers.project import ProjectLiteSerializer
|
||||
from plane.app.serializers.workspace import WorkspaceLiteSerializer
|
||||
from plane.db.models import DeployBoard
|
||||
|
||||
|
||||
class DeployBoardSerializer(BaseSerializer):
|
||||
project_details = ProjectLiteSerializer(read_only=True, source="project")
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
|
||||
class Meta:
|
||||
model = DeployBoard
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "anchor"]
|
||||
@@ -12,6 +12,7 @@ from plane.db.models import (
|
||||
Label,
|
||||
State,
|
||||
DraftIssue,
|
||||
IssueType,
|
||||
DraftIssueAssignee,
|
||||
DraftIssueLabel,
|
||||
DraftIssueCycle,
|
||||
@@ -27,6 +28,9 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
parent_id = serializers.PrimaryKeyRelatedField(
|
||||
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
type_id = serializers.PrimaryKeyRelatedField(
|
||||
source="type", queryset=IssueType.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
label_ids = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
|
||||
write_only=True,
|
||||
@@ -76,9 +80,23 @@ 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
|
||||
**validated_data,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
type=issue_type,
|
||||
)
|
||||
|
||||
# Issue Audit Users
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from plane.db.models import UserFavorite, Cycle, Module, Issue, IssueView, Page, Project
|
||||
from plane.ee.models import Dashboard
|
||||
|
||||
|
||||
class ProjectFavoriteLiteSerializer(serializers.ModelSerializer):
|
||||
@@ -41,6 +42,12 @@ class ViewFavoriteSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "name", "logo_props", "project_id"]
|
||||
|
||||
|
||||
class DashboardFavoriteLiteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Dashboard
|
||||
fields = ["id", "name"]
|
||||
|
||||
|
||||
def get_entity_model_and_serializer(entity_type):
|
||||
entity_map = {
|
||||
"cycle": (Cycle, CycleFavoriteLiteSerializer),
|
||||
@@ -49,6 +56,7 @@ def get_entity_model_and_serializer(entity_type):
|
||||
"view": (IssueView, ViewFavoriteSerializer),
|
||||
"page": (Page, PageFavoriteLiteSerializer),
|
||||
"project": (Project, ProjectFavoriteLiteSerializer),
|
||||
"workspace_dashboard": (Dashboard, DashboardFavoriteLiteSerializer),
|
||||
"folder": (None, None),
|
||||
}
|
||||
return entity_map.get(entity_type, (None, None))
|
||||
|
||||
8
apiserver/plane/app/serializers/integration/__init__.py
Normal file
8
apiserver/plane/app/serializers/integration/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
|
||||
from .github import (
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
from .slack import SlackProjectSyncSerializer
|
||||
18
apiserver/plane/app/serializers/integration/base.py
Normal file
18
apiserver/plane/app/serializers/integration/base.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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__"
|
||||
36
apiserver/plane/app/serializers/integration/github.py
Normal file
36
apiserver/plane/app/serializers/integration/github.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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"]
|
||||
10
apiserver/plane/app/serializers/integration/slack.py
Normal file
10
apiserver/plane/app/serializers/integration/slack.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# 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"]
|
||||
@@ -37,7 +37,11 @@ from plane.db.models import (
|
||||
IssueVersion,
|
||||
IssueDescriptionVersion,
|
||||
ProjectMember,
|
||||
IssueType,
|
||||
)
|
||||
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):
|
||||
@@ -56,6 +60,7 @@ class IssueFlatSerializer(BaseSerializer):
|
||||
"sequence_id",
|
||||
"sort_order",
|
||||
"is_draft",
|
||||
"type_id",
|
||||
]
|
||||
|
||||
|
||||
@@ -75,6 +80,9 @@ 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
|
||||
)
|
||||
@@ -120,15 +128,79 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError("Start date cannot exceed target date")
|
||||
|
||||
if attrs.get("assignee_ids", []):
|
||||
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)
|
||||
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)
|
||||
|
||||
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).
|
||||
"""
|
||||
# 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)
|
||||
@@ -137,8 +209,21 @@ 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)
|
||||
issue = Issue.objects.create(
|
||||
**validated_data, project_id=project_id, type=issue_type
|
||||
)
|
||||
|
||||
# Issue Audit Users
|
||||
created_by_id = issue.created_by_id
|
||||
@@ -163,15 +248,9 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
# 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()
|
||||
# Assign to default assignee if valid
|
||||
if default_assignee_id is not None and self._is_valid_assignee(
|
||||
default_assignee_id, project_id
|
||||
):
|
||||
try:
|
||||
IssueAssignee.objects.create(
|
||||
@@ -332,7 +411,11 @@ class IssueRelationSerializer(BaseSerializer):
|
||||
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)
|
||||
assignee_ids = serializers.ListField(
|
||||
@@ -349,6 +432,8 @@ class IssueRelationSerializer(BaseSerializer):
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"type_id",
|
||||
"is_epic",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
@@ -374,7 +459,9 @@ class RelatedIssueSerializer(BaseSerializer):
|
||||
)
|
||||
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(
|
||||
@@ -391,6 +478,8 @@ class RelatedIssueSerializer(BaseSerializer):
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"type_id",
|
||||
"is_epic",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
@@ -675,10 +764,28 @@ 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)
|
||||
@@ -721,25 +828,53 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
"type_id",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
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"]
|
||||
fields = ["id", "sequence_id", "project_id", "type_id", "is_epic"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueDetailSerializer(IssueSerializer):
|
||||
description_html = serializers.CharField()
|
||||
is_subscribed = serializers.BooleanField(read_only=True)
|
||||
is_epic = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta(IssueSerializer.Meta):
|
||||
fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"]
|
||||
fields = IssueSerializer.Meta.fields + [
|
||||
"description_html",
|
||||
"is_subscribed",
|
||||
"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")
|
||||
|
||||
@@ -24,6 +24,11 @@ class PageSerializer(BaseSerializer):
|
||||
# Many to many
|
||||
label_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||
project_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||
anchor = serializers.CharField(read_only=True)
|
||||
parent_id = serializers.PrimaryKeyRelatedField(
|
||||
source="parent", queryset=Page.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
sub_pages_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Page
|
||||
@@ -34,7 +39,6 @@ class PageSerializer(BaseSerializer):
|
||||
"access",
|
||||
"color",
|
||||
"labels",
|
||||
"parent",
|
||||
"is_favorite",
|
||||
"is_locked",
|
||||
"archived_at",
|
||||
@@ -47,8 +51,13 @@ class PageSerializer(BaseSerializer):
|
||||
"logo_props",
|
||||
"label_ids",
|
||||
"project_ids",
|
||||
"anchor",
|
||||
"external_id",
|
||||
"external_source",
|
||||
"parent_id",
|
||||
"sub_pages_count",
|
||||
]
|
||||
read_only_fields = ["workspace", "owned_by"]
|
||||
read_only_fields = ["workspace", "owned_by", "anchor"]
|
||||
|
||||
def create(self, validated_data):
|
||||
labels = validated_data.pop("labels", None)
|
||||
@@ -120,28 +129,39 @@ class PageSerializer(BaseSerializer):
|
||||
|
||||
class PageDetailSerializer(PageSerializer):
|
||||
description_html = serializers.CharField()
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
parent_id = serializers.PrimaryKeyRelatedField(
|
||||
source="parent", queryset=Page.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
|
||||
class Meta(PageSerializer.Meta):
|
||||
fields = PageSerializer.Meta.fields + ["description_html"]
|
||||
|
||||
|
||||
class SubPageSerializer(BaseSerializer):
|
||||
entity_details = serializers.SerializerMethodField()
|
||||
class PageLiteSerializer(BaseSerializer):
|
||||
project_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||
sub_pages_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PageLog
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "page"]
|
||||
|
||||
def get_entity_details(self, obj):
|
||||
entity_name = obj.entity_name
|
||||
if entity_name == "forward_link" or entity_name == "back_link":
|
||||
try:
|
||||
page = Page.objects.get(pk=obj.entity_identifier)
|
||||
return PageSerializer(page).data
|
||||
except Page.DoesNotExist:
|
||||
return None
|
||||
return None
|
||||
model = Page
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"access",
|
||||
"logo_props",
|
||||
"is_locked",
|
||||
"archived_at",
|
||||
"parent_id",
|
||||
"workspace",
|
||||
"project_ids",
|
||||
"sub_pages_count",
|
||||
"owned_by",
|
||||
"deleted_at",
|
||||
"is_description_empty",
|
||||
"updated_at",
|
||||
"moved_to_page",
|
||||
"moved_to_project",
|
||||
]
|
||||
|
||||
|
||||
class PageLogSerializer(BaseSerializer):
|
||||
@@ -184,5 +204,6 @@ class PageVersionDetailSerializer(BaseSerializer):
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"sub_pages_data",
|
||||
]
|
||||
read_only_fields = ["workspace", "page"]
|
||||
|
||||
@@ -10,14 +10,17 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
DeployBoard,
|
||||
ProjectPublicMember,
|
||||
)
|
||||
from plane.ee.models.initiative import InitiativeProject
|
||||
|
||||
|
||||
class ProjectSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
|
||||
initiative_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(), required=False, write_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
@@ -44,7 +47,26 @@ class ProjectSerializer(BaseSerializer):
|
||||
return project
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
initiative_ids = validated_data.pop("initiative_ids", [])
|
||||
|
||||
identifier = validated_data.get("identifier", "").strip().upper()
|
||||
workspace_id = self.context["workspace_id"]
|
||||
|
||||
if initiative_ids:
|
||||
InitiativeProject.objects.filter(project_id=instance.id).delete()
|
||||
|
||||
InitiativeProject.objects.bulk_create(
|
||||
[
|
||||
InitiativeProject(
|
||||
project_id=instance.id,
|
||||
initiative_id=initiative_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=self.context["user_id"],
|
||||
)
|
||||
for initiative_id in initiative_ids
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
# If identifier is not passed update the project and return
|
||||
if identifier == "":
|
||||
@@ -67,6 +89,7 @@ class ProjectSerializer(BaseSerializer):
|
||||
# If found check if the project_id to be updated and identifier project id is same
|
||||
if project_identifier.project_id == instance.id:
|
||||
# If same pass update
|
||||
|
||||
project = super().update(instance, validated_data)
|
||||
return project
|
||||
|
||||
@@ -92,10 +115,17 @@ class ProjectLiteSerializer(BaseSerializer):
|
||||
class ProjectListSerializer(DynamicBaseSerializer):
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
sort_order = serializers.FloatField(read_only=True)
|
||||
member_role = serializers.IntegerField(read_only=True)
|
||||
member_role = serializers.SerializerMethodField()
|
||||
anchor = serializers.CharField(read_only=True)
|
||||
members = serializers.SerializerMethodField()
|
||||
cover_image_url = serializers.CharField(read_only=True)
|
||||
# EE: project_grouping starts
|
||||
state_id = serializers.UUIDField(read_only=True)
|
||||
priority = serializers.CharField(read_only=True)
|
||||
start_date = serializers.DateTimeField(read_only=True)
|
||||
target_date = serializers.DateTimeField(read_only=True)
|
||||
initiative_ids = serializers.SerializerMethodField(read_only=True)
|
||||
# EE: project_grouping ends
|
||||
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
|
||||
|
||||
def get_members(self, obj):
|
||||
@@ -105,10 +135,33 @@ class ProjectListSerializer(DynamicBaseSerializer):
|
||||
return [
|
||||
member.member_id
|
||||
for member in project_members
|
||||
if member.is_active and not member.member.is_bot
|
||||
if member.is_active and (member.member and not member.member.is_bot)
|
||||
]
|
||||
return []
|
||||
|
||||
def get_member_role(self, obj):
|
||||
project_members = getattr(obj, "members_list", None)
|
||||
current_user_id = (
|
||||
self.context["request"].user.id if self.context.get("request") else None
|
||||
)
|
||||
# calculate the current member role
|
||||
if project_members is not None and current_user_id:
|
||||
current_member = next(
|
||||
(
|
||||
member
|
||||
for member in project_members
|
||||
if member.member_id == current_user_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
return current_member.role if current_member else None
|
||||
return None
|
||||
|
||||
def get_initiative_ids(self, obj):
|
||||
if obj.initiatives.all():
|
||||
return [initiative.initiative_id for initiative in obj.initiatives.all()]
|
||||
return []
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = "__all__"
|
||||
@@ -129,9 +182,6 @@ class ProjectDetailSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ProjectMemberSerializer(BaseSerializer):
|
||||
workspace = WorkspaceLiteSerializer(read_only=True)
|
||||
project = ProjectLiteSerializer(read_only=True)
|
||||
member = UserLiteSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
@@ -148,8 +198,8 @@ class ProjectMemberAdminSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
|
||||
original_role = serializers.IntegerField(source='role', read_only=True)
|
||||
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
|
||||
original_role = serializers.IntegerField(source="role", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
@@ -182,16 +232,6 @@ class ProjectMemberLiteSerializer(BaseSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
class ProjectPublicMemberSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = ProjectPublicMember
|
||||
|
||||
@@ -110,7 +110,11 @@ class UserMeSettingsSerializer(BaseSerializer):
|
||||
workspace_member__member=obj.id,
|
||||
workspace_member__is_active=True,
|
||||
).first()
|
||||
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
|
||||
logo_asset_url = (
|
||||
workspace.logo_asset.asset_url
|
||||
if workspace.logo_asset is not None
|
||||
else ""
|
||||
)
|
||||
return {
|
||||
"last_workspace_id": profile.last_workspace_id,
|
||||
"last_workspace_slug": (
|
||||
|
||||
@@ -9,6 +9,7 @@ from plane.utils.issue_filters import issue_filters
|
||||
|
||||
class IssueViewSerializer(DynamicBaseSerializer):
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
anchor = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueView
|
||||
|
||||
@@ -3,6 +3,9 @@ import socket
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -45,6 +48,12 @@ class WebhookSerializer(DynamicBaseSerializer):
|
||||
{"url": "URL resolves to a blocked IP address."}
|
||||
)
|
||||
|
||||
# if in cloud environment, private IP addresses are also not allowed
|
||||
if settings.IS_MULTI_TENANT and ip.is_private:
|
||||
raise serializers.ValidationError(
|
||||
{"url": "URL resolves to a blocked IP address."}
|
||||
)
|
||||
|
||||
# Additional validation for multiple request domains and their subdomains
|
||||
request = self.context.get("request")
|
||||
disallowed_domains = ["plane.so"] # Add your disallowed domains here
|
||||
@@ -93,6 +102,12 @@ class WebhookSerializer(DynamicBaseSerializer):
|
||||
{"url": "URL resolves to a blocked IP address."}
|
||||
)
|
||||
|
||||
# if in cloud environment, private IP addresses are also not allowed
|
||||
if settings.IS_MULTI_TENANT and ip.is_private:
|
||||
raise serializers.ValidationError(
|
||||
{"url": "URL resolves to a blocked IP address."}
|
||||
)
|
||||
|
||||
# Additional validation for multiple request domains and their subdomains
|
||||
request = self.context.get("request")
|
||||
disallowed_domains = ["plane.so"] # Add your disallowed domains here
|
||||
|
||||
@@ -67,6 +67,31 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class WorkspaceUserMeSerializer(DynamicBaseSerializer):
|
||||
owner = UserLiteSerializer(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
logo_url = serializers.CharField(read_only=True)
|
||||
current_plan = serializers.CharField(read_only=True)
|
||||
role = serializers.IntegerField(read_only=True)
|
||||
is_on_trial = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Workspace
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"owner",
|
||||
"logo_url",
|
||||
"role",
|
||||
"is_on_trial",
|
||||
]
|
||||
|
||||
|
||||
class WorkspaceLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Workspace
|
||||
@@ -85,6 +110,7 @@ class WorkSpaceMemberSerializer(DynamicBaseSerializer):
|
||||
|
||||
class WorkspaceMemberMeSerializer(BaseSerializer):
|
||||
draft_issue_count = serializers.IntegerField(read_only=True)
|
||||
active_cycles_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceMember
|
||||
@@ -197,6 +223,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_identifier = serializers.SerializerMethodField()
|
||||
assignees = serializers.SerializerMethodField()
|
||||
is_epic = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
@@ -210,6 +237,7 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"project_identifier",
|
||||
"is_epic",
|
||||
]
|
||||
|
||||
def get_project_identifier(self, obj):
|
||||
@@ -223,6 +251,9 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
)
|
||||
|
||||
def get_is_epic(self, obj):
|
||||
return obj.type.is_epic if obj.type else False
|
||||
|
||||
|
||||
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_members = serializers.SerializerMethodField()
|
||||
@@ -239,6 +270,12 @@ class ProjectRecentVisitSerializer(serializers.ModelSerializer):
|
||||
return members
|
||||
|
||||
|
||||
class WorkspacePageRecentVisitSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Page
|
||||
fields = ["id", "name", "logo_props", "owned_by"]
|
||||
|
||||
|
||||
class PageRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_id = serializers.SerializerMethodField()
|
||||
project_identifier = serializers.SerializerMethodField()
|
||||
@@ -272,6 +309,7 @@ def get_entity_model_and_serializer(entity_type):
|
||||
"issue": (Issue, IssueRecentVisitSerializer),
|
||||
"page": (Page, PageRecentVisitSerializer),
|
||||
"project": (Project, ProjectRecentVisitSerializer),
|
||||
"workspace_page": (Page, WorkspacePageRecentVisitSerializer),
|
||||
}
|
||||
return entity_map.get(entity_type, (None, None))
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@ from .webhook import urlpatterns as webhook_urls
|
||||
from .workspace import urlpatterns as workspace_urls
|
||||
from .timezone import urlpatterns as timezone_urls
|
||||
|
||||
# Integrations URLS
|
||||
from .importer import urlpatterns as importer_urls
|
||||
from .integration import urlpatterns as integration_urls
|
||||
|
||||
# url patterns
|
||||
from plane.ee.urls.app import urlpatterns as ee_urls
|
||||
|
||||
urlpatterns = [
|
||||
*analytic_urls,
|
||||
*asset_urls,
|
||||
@@ -38,4 +45,8 @@ urlpatterns = [
|
||||
*api_urls,
|
||||
*webhook_urls,
|
||||
*timezone_urls,
|
||||
# ee
|
||||
*integration_urls,
|
||||
*importer_urls,
|
||||
*ee_urls,
|
||||
]
|
||||
|
||||
@@ -12,6 +12,8 @@ from plane.app.views import (
|
||||
AssetRestoreEndpoint,
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
# Unlimited Asset Endpoints
|
||||
SiloAssetsEndpoint,
|
||||
AssetCheckEndpoint,
|
||||
WorkspaceAssetDownloadEndpoint,
|
||||
ProjectAssetDownloadEndpoint,
|
||||
@@ -101,4 +103,14 @@ urlpatterns = [
|
||||
ProjectAssetDownloadEndpoint.as_view(),
|
||||
name="project-asset-download",
|
||||
),
|
||||
path(
|
||||
"assets/silo/workspaces/<str:slug>/",
|
||||
SiloAssetsEndpoint.as_view(),
|
||||
name="silo-assets",
|
||||
),
|
||||
path(
|
||||
"assets/silo/workspaces/<str:slug>/<uuid:asset_id>/",
|
||||
SiloAssetsEndpoint.as_view(),
|
||||
name="silo-assets-detail",
|
||||
),
|
||||
]
|
||||
|
||||
43
apiserver/plane/app/urls/importer.py
Normal file
43
apiserver/plane/app/urls/importer.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import (
|
||||
ServiceIssueImportSummaryEndpoint,
|
||||
ImportServiceEndpoint,
|
||||
UpdateServiceImportStatusEndpoint,
|
||||
BulkImportIssuesEndpoint,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/importers/<str:service>/",
|
||||
ServiceIssueImportSummaryEndpoint.as_view(),
|
||||
name="importer-summary",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/importers/<str:service>/",
|
||||
ImportServiceEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/importers/",
|
||||
ImportServiceEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/importers/<str:service>/<uuid:pk>/",
|
||||
ImportServiceEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/service/<str:service>/importers/<uuid:importer_id>/",
|
||||
UpdateServiceImportStatusEndpoint.as_view(),
|
||||
name="importer-status",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-issues/<str:service>/",
|
||||
BulkImportIssuesEndpoint.as_view(),
|
||||
name="bulk-import-issues",
|
||||
),
|
||||
]
|
||||
88
apiserver/plane/app/urls/integration.py
Normal file
88
apiserver/plane/app/urls/integration.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import (
|
||||
IntegrationViewSet,
|
||||
WorkspaceIntegrationViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
BulkCreateGithubIssueSyncEndpoint,
|
||||
SlackProjectSyncViewSet,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"integrations/",
|
||||
IntegrationViewSet.as_view({"get": "list", "post": "create"}),
|
||||
name="integrations",
|
||||
),
|
||||
path(
|
||||
"integrations/<uuid:pk>/",
|
||||
IntegrationViewSet.as_view(
|
||||
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
|
||||
),
|
||||
name="integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/",
|
||||
WorkspaceIntegrationViewSet.as_view({"get": "list"}),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<str:provider>/",
|
||||
WorkspaceIntegrationViewSet.as_view({"post": "create"}),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<uuid:pk>/provider/",
|
||||
WorkspaceIntegrationViewSet.as_view({"get": "retrieve", "delete": "destroy"}),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
# Github Integrations
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<uuid:workspace_integration_id>/github-repositories/",
|
||||
GithubRepositoriesEndpoint.as_view(),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/",
|
||||
GithubRepositorySyncViewSet.as_view({"get": "list", "post": "create"}),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/<uuid:pk>/",
|
||||
GithubRepositorySyncViewSet.as_view({"get": "retrieve", "delete": "destroy"}),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/",
|
||||
GithubIssueSyncViewSet.as_view({"post": "create", "get": "list"}),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/bulk-create-github-issue-sync/",
|
||||
BulkCreateGithubIssueSyncEndpoint.as_view(),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
|
||||
GithubIssueSyncViewSet.as_view({"get": "retrieve", "delete": "destroy"}),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/",
|
||||
GithubCommentSyncViewSet.as_view({"post": "create", "get": "list"}),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/<uuid:pk>/",
|
||||
GithubCommentSyncViewSet.as_view({"get": "retrieve", "delete": "destroy"}),
|
||||
),
|
||||
## End Github Integrations
|
||||
# Slack Integration
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/",
|
||||
SlackProjectSyncViewSet.as_view({"post": "create", "get": "list"}),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/<uuid:pk>/",
|
||||
SlackProjectSyncViewSet.as_view({"delete": "destroy", "get": "retrieve"}),
|
||||
),
|
||||
## End Slack Integration
|
||||
]
|
||||
@@ -18,7 +18,6 @@ from plane.app.views import (
|
||||
IssueUserDisplayPropertyEndpoint,
|
||||
IssueViewSet,
|
||||
LabelViewSet,
|
||||
BulkArchiveIssuesEndpoint,
|
||||
DeletedIssuesListViewSet,
|
||||
IssuePaginatedViewSet,
|
||||
IssueDetailEndpoint,
|
||||
@@ -92,11 +91,6 @@ urlpatterns = [
|
||||
BulkDeleteIssuesEndpoint.as_view(),
|
||||
name="project-issues-bulk",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-archive-issues/",
|
||||
BulkArchiveIssuesEndpoint.as_view(),
|
||||
name="bulk-archive-issues",
|
||||
),
|
||||
##
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
|
||||
|
||||
@@ -5,7 +5,6 @@ from plane.app.views import (
|
||||
PageViewSet,
|
||||
PageFavoriteViewSet,
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageVersionEndpoint,
|
||||
PageDuplicateEndpoint,
|
||||
@@ -25,12 +24,28 @@ urlpatterns = [
|
||||
),
|
||||
name="project-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/sub-pages/",
|
||||
PageViewSet.as_view({"get": "sub_pages"}),
|
||||
name="project-sub-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/parent-pages/",
|
||||
PageViewSet.as_view({"get": "parent_pages"}),
|
||||
name="project-parent-pages",
|
||||
),
|
||||
# favorite pages
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:pk>/",
|
||||
PageFavoriteViewSet.as_view({"post": "create", "delete": "destroy"}),
|
||||
name="user-favorite-pages",
|
||||
),
|
||||
# Lock
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/lock/",
|
||||
PageViewSet.as_view({"post": "lock", "delete": "unlock"}),
|
||||
name="project-page-lock-unlock",
|
||||
),
|
||||
# archived pages
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/archive/",
|
||||
@@ -59,11 +74,6 @@ urlpatterns = [
|
||||
PageLogEndpoint.as_view(),
|
||||
name="page-transactions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/sub-pages/",
|
||||
SubPagesEndpoint.as_view(),
|
||||
name="sub-page",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/description/",
|
||||
PagesDescriptionViewSet.as_view({"get": "retrieve", "patch": "partial_update"}),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint
|
||||
from plane.app.views import (
|
||||
GlobalSearchEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
SearchEndpoint,
|
||||
WorkspaceSearchEndpoint,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
@@ -20,4 +25,9 @@ urlpatterns = [
|
||||
SearchEndpoint.as_view(),
|
||||
name="entity-search",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/app-search/",
|
||||
WorkspaceSearchEndpoint.as_view(),
|
||||
name="app-search",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ from plane.app.views import (
|
||||
UpdateUserTourCompletedEndpoint,
|
||||
UserActivityEndpoint,
|
||||
UserActivityGraphEndpoint,
|
||||
UserTokenVerificationEndpoint,
|
||||
## User
|
||||
UserEndpoint,
|
||||
UserIssueCompletedGraphEndpoint,
|
||||
@@ -55,6 +56,9 @@ urlpatterns = [
|
||||
path(
|
||||
"users/me/activities/", UserActivityEndpoint.as_view(), name="user-activities"
|
||||
),
|
||||
path(
|
||||
"users/me/verify/", UserTokenVerificationEndpoint.as_view(), name="user-verify"
|
||||
),
|
||||
# user workspaces
|
||||
path(
|
||||
"users/me/workspaces/", UserWorkSpacesEndpoint.as_view(), name="user-workspace"
|
||||
|
||||
@@ -97,7 +97,6 @@ from .cycle.base import (
|
||||
)
|
||||
from .cycle.issue import CycleIssueViewSet
|
||||
from .cycle.archive import CycleArchiveUnarchiveEndpoint
|
||||
|
||||
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||
from .asset.v2 import (
|
||||
WorkspaceFileAssetEndpoint,
|
||||
@@ -110,6 +109,8 @@ from .asset.v2 import (
|
||||
WorkspaceAssetDownloadEndpoint,
|
||||
ProjectAssetDownloadEndpoint,
|
||||
)
|
||||
from .asset.silo import SiloAssetsEndpoint
|
||||
|
||||
from .issue.base import (
|
||||
IssueListEndpoint,
|
||||
IssueViewSet,
|
||||
@@ -125,7 +126,7 @@ from .issue.base import (
|
||||
|
||||
from .issue.activity import IssueActivityEndpoint
|
||||
|
||||
from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint
|
||||
from .issue.archive import IssueArchiveViewSet
|
||||
|
||||
from .issue.attachment import (
|
||||
IssueAttachmentEndpoint,
|
||||
@@ -164,17 +165,17 @@ from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
|
||||
from .page.base import (
|
||||
PageViewSet,
|
||||
PageFavoriteViewSet,
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageFavoriteViewSet,
|
||||
PageDuplicateEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
)
|
||||
from .page.version import PageVersionEndpoint
|
||||
|
||||
from .search.base import GlobalSearchEndpoint, SearchEndpoint
|
||||
from .search.issue import IssueSearchEndpoint
|
||||
|
||||
from .search.workspace import WorkspaceSearchEndpoint
|
||||
|
||||
from .external.base import (
|
||||
GPTIntegrationEndpoint,
|
||||
@@ -231,7 +232,33 @@ from .webhook.base import (
|
||||
|
||||
from .error_404 import custom_404_view
|
||||
|
||||
from .importer.base import (
|
||||
ServiceIssueImportSummaryEndpoint,
|
||||
ImportServiceEndpoint,
|
||||
UpdateServiceImportStatusEndpoint,
|
||||
BulkImportIssuesEndpoint,
|
||||
BulkImportModulesEndpoint,
|
||||
)
|
||||
|
||||
from .integration.base import IntegrationViewSet, WorkspaceIntegrationViewSet
|
||||
|
||||
from .integration.github import (
|
||||
GithubRepositoriesEndpoint,
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
BulkCreateGithubIssueSyncEndpoint,
|
||||
)
|
||||
|
||||
from .integration.slack import SlackProjectSyncViewSet
|
||||
|
||||
from .notification.base import MarkAllReadNotificationViewSet
|
||||
from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint
|
||||
|
||||
from .user.base import (
|
||||
AccountEndpoint,
|
||||
ProfileEndpoint,
|
||||
UserSessionEndpoint,
|
||||
UserTokenVerificationEndpoint,
|
||||
)
|
||||
|
||||
from .timezone.base import TimezoneEndpoint
|
||||
|
||||
0
apiserver/plane/app/views/analytic/__init__.py
Normal file
0
apiserver/plane/app/views/analytic/__init__.py
Normal file
@@ -1,6 +1,6 @@
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Optional, Union
|
||||
from django.db.models import QuerySet, Q, Count
|
||||
from django.http import HttpRequest
|
||||
from django.db.models.functions import TruncMonth
|
||||
@@ -16,14 +16,17 @@ from plane.db.models import (
|
||||
IssueView,
|
||||
ProjectPage,
|
||||
Workspace,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.ee.models import EntityUpdates, ProjectAttribute
|
||||
from plane.utils.build_chart import build_analytics_chart
|
||||
from plane.utils.date_utils import (
|
||||
get_analytics_filters,
|
||||
)
|
||||
from django.db.models import Max
|
||||
from django.db import models
|
||||
from django.db.models import Case, When, Value, F, CharField
|
||||
from django.db.models.functions import Concat
|
||||
|
||||
|
||||
class AdvanceAnalyticsBaseView(BaseAPIView):
|
||||
@@ -73,14 +76,16 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
||||
|
||||
def get_overview_data(self) -> Dict[str, Dict[str, int]]:
|
||||
members_query = WorkspaceMember.objects.filter(
|
||||
workspace__slug=self._workspace_slug, is_active=True
|
||||
workspace__slug=self._workspace_slug,
|
||||
is_active=True,
|
||||
member__is_bot=False,
|
||||
)
|
||||
|
||||
if self.request.GET.get("project_ids", None):
|
||||
project_ids = self.request.GET.get("project_ids", None)
|
||||
project_ids = [str(project_id) for project_id in project_ids.split(",")]
|
||||
members_query = ProjectMember.objects.filter(
|
||||
project_id__in=project_ids, is_active=True
|
||||
project_id__in=project_ids, is_active=True, member__is_bot=False
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -129,6 +134,116 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
||||
),
|
||||
}
|
||||
|
||||
def get_users_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
members_query = WorkspaceMember.objects.filter(
|
||||
workspace__slug=self._workspace_slug,
|
||||
is_active=True,
|
||||
member__is_bot=False,
|
||||
)
|
||||
|
||||
if self.request.GET.get("project_ids", None):
|
||||
project_ids = self.request.GET.get("project_ids", None)
|
||||
project_ids = [str(project_id) for project_id in project_ids.split(",")]
|
||||
members_query = ProjectMember.objects.filter(
|
||||
project_id__in=project_ids, is_active=True, member__is_bot=False
|
||||
)
|
||||
|
||||
return {
|
||||
"total_users": self.get_filtered_counts(members_query),
|
||||
"total_admins": self.get_filtered_counts(
|
||||
members_query.filter(role=ROLE.ADMIN.value)
|
||||
),
|
||||
"total_members": self.get_filtered_counts(
|
||||
members_query.filter(role=ROLE.MEMBER.value)
|
||||
),
|
||||
"total_guests": self.get_filtered_counts(
|
||||
members_query.filter(role=ROLE.GUEST.value)
|
||||
),
|
||||
}
|
||||
|
||||
def get_projects_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
latest_updates = EntityUpdates.objects.filter(
|
||||
**self.filters["base_filters"],
|
||||
entity_type="PROJECT",
|
||||
created_at__in=EntityUpdates.objects.filter(
|
||||
**self.filters["base_filters"], entity_type="PROJECT"
|
||||
)
|
||||
.values("project_id")
|
||||
.annotate(latest_created_at=Max("created_at"))
|
||||
.values("latest_created_at"),
|
||||
).order_by("project_id", "-created_at")
|
||||
|
||||
return {
|
||||
"total_projects": self.get_filtered_counts(
|
||||
Project.objects.filter(**self.filters["project_filters"]),
|
||||
),
|
||||
"on_track_updates": self.get_filtered_counts(
|
||||
latest_updates.filter(status="ON-TRACK"),
|
||||
),
|
||||
"off_track_updates": self.get_filtered_counts(
|
||||
latest_updates.filter(status="OFF-TRACK"),
|
||||
),
|
||||
"at_risk_updates": self.get_filtered_counts(
|
||||
latest_updates.filter(status="AT-RISK"),
|
||||
),
|
||||
}
|
||||
|
||||
def get_cycles_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = Cycle.objects.filter(**self.filters["base_filters"])
|
||||
|
||||
return {
|
||||
"total_cycles": self.get_filtered_counts(base_queryset),
|
||||
"current_cycles": self.get_filtered_counts(
|
||||
base_queryset.filter(
|
||||
start_date__lte=timezone.now(), end_date__gte=timezone.now()
|
||||
),
|
||||
),
|
||||
"upcoming_cycles": self.get_filtered_counts(
|
||||
base_queryset.filter(start_date__gte=timezone.now())
|
||||
),
|
||||
"completed_cycles": self.get_filtered_counts(
|
||||
base_queryset.filter(end_date__lte=timezone.now())
|
||||
),
|
||||
}
|
||||
|
||||
def get_module_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = Module.objects.filter(**self.filters["base_filters"])
|
||||
|
||||
return {
|
||||
"total_modules": self.get_filtered_counts(base_queryset),
|
||||
"completed_modules": self.get_filtered_counts(
|
||||
base_queryset.filter(status="completed")
|
||||
),
|
||||
"in_progress_modules": self.get_filtered_counts(
|
||||
base_queryset.filter(status="in-progress")
|
||||
),
|
||||
"planned_modules": self.get_filtered_counts(
|
||||
base_queryset.filter(status="planned")
|
||||
),
|
||||
"paused_modules": self.get_filtered_counts(
|
||||
base_queryset.filter(status="paused")
|
||||
),
|
||||
}
|
||||
|
||||
def get_intake_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = Issue.objects.filter(**self.filters["base_filters"]).filter(
|
||||
issue_intake__isnull=False,
|
||||
issue_intake__status__in=[1, -1, 2],
|
||||
)
|
||||
|
||||
return {
|
||||
"total_intake": self.get_filtered_counts(base_queryset),
|
||||
"accepted_intake": self.get_filtered_counts(
|
||||
base_queryset.filter(issue_intake__status=1)
|
||||
),
|
||||
"rejected_intake": self.get_filtered_counts(
|
||||
base_queryset.filter(issue_intake__status=-1)
|
||||
),
|
||||
"duplicate_intake": self.get_filtered_counts(
|
||||
base_queryset.filter(issue_intake__status=2)
|
||||
),
|
||||
}
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request: HttpRequest, slug: str) -> Response:
|
||||
self.initialize_workspace(slug, type="analytics")
|
||||
@@ -144,6 +259,37 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
||||
self.get_work_items_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif tab == "users":
|
||||
return Response(
|
||||
self.get_users_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif tab == "projects":
|
||||
return Response(
|
||||
self.get_projects_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif tab == "work-items":
|
||||
return Response(
|
||||
self.get_work_items_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif tab == "cycles":
|
||||
return Response(
|
||||
self.get_cycles_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif tab == "modules":
|
||||
return Response(
|
||||
self.get_module_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif tab == "intake":
|
||||
return Response(
|
||||
self.get_intake_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -160,7 +306,8 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
|
||||
)
|
||||
|
||||
return (
|
||||
base_queryset.values("project_id", "project__name").annotate(
|
||||
base_queryset.values("project_id", "project__name")
|
||||
.annotate(
|
||||
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
|
||||
completed_work_items=Count("id", filter=Q(state__group="completed")),
|
||||
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
|
||||
@@ -170,11 +317,10 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
|
||||
.order_by("project_id")
|
||||
)
|
||||
|
||||
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
def get_work_items_stats(self) -> QuerySet:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
return (
|
||||
base_queryset
|
||||
.values("project_id", "project__name")
|
||||
base_queryset.values("project_id", "project__name")
|
||||
.annotate(
|
||||
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
|
||||
completed_work_items=Count("id", filter=Q(state__group="completed")),
|
||||
@@ -185,6 +331,392 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
|
||||
.order_by("project_id")
|
||||
)
|
||||
|
||||
def get_project_stats(self) -> List[Dict[str, Any]]:
|
||||
# Get all project stats in a single query using annotations
|
||||
projects = (
|
||||
Project.objects.filter(**self.filters["project_filters"])
|
||||
.annotate(
|
||||
# Issue stats
|
||||
total_work_items=Count(
|
||||
"project_issue",
|
||||
filter=Q(
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
total_epics=Count(
|
||||
"project_issue",
|
||||
filter=Q(
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
project_issue__type__is_epic=True,
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
total_intake=Count(
|
||||
"project_issue",
|
||||
filter=Q(
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
project_issue__issue_intake__isnull=False,
|
||||
project_issue__issue_intake__status__in=[1, -1, 2],
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
# Cycle stats
|
||||
total_cycles=Count(
|
||||
"project_cycle",
|
||||
filter=Q(project_cycle__archived_at__isnull=True),
|
||||
distinct=True,
|
||||
),
|
||||
# Module stats
|
||||
total_modules=Count(
|
||||
"project_module",
|
||||
filter=Q(project_module__archived_at__isnull=True),
|
||||
distinct=True,
|
||||
),
|
||||
# Member stats
|
||||
total_members=Count(
|
||||
"project_projectmember",
|
||||
filter=Q(
|
||||
project_projectmember__is_active=True,
|
||||
project_projectmember__member__is_bot=False,
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
# Page stats
|
||||
total_pages=Count(
|
||||
"project_pages",
|
||||
filter=Q(project_pages__page__archived_at__isnull=True),
|
||||
distinct=True,
|
||||
),
|
||||
# View stats
|
||||
total_views=Count(
|
||||
"project_issueview",
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
"id",
|
||||
"name",
|
||||
"identifier",
|
||||
"total_work_items",
|
||||
"total_epics",
|
||||
"total_intake",
|
||||
"total_cycles",
|
||||
"total_modules",
|
||||
"total_members",
|
||||
"total_pages",
|
||||
"total_views",
|
||||
)
|
||||
)
|
||||
|
||||
return list(projects)
|
||||
|
||||
def get_users_stats(self) -> QuerySet:
|
||||
# First get all project members
|
||||
if self.request.GET.get("project_ids", None):
|
||||
project_ids = [
|
||||
str(project_id)
|
||||
for project_id in self.request.GET.get("project_ids", "").split(",")
|
||||
]
|
||||
member_ids = ProjectMember.objects.filter(
|
||||
project_id__in=project_ids, is_active=True, member__is_bot=False
|
||||
).values_list("member_id", flat=True)
|
||||
else:
|
||||
member_ids = WorkspaceMember.objects.filter(
|
||||
workspace__slug=self._workspace_slug,
|
||||
is_active=True,
|
||||
member__is_bot=False,
|
||||
).values_list("member_id", flat=True)
|
||||
|
||||
# Get stats for all members in a single query
|
||||
return (
|
||||
Issue.issue_objects.filter(
|
||||
**self.filters["base_filters"],
|
||||
)
|
||||
.annotate(
|
||||
display_name=Case(
|
||||
When(
|
||||
Q(created_by__in=member_ids), then=F("created_by__display_name")
|
||||
),
|
||||
When(
|
||||
Q(assignees__in=member_ids), then=F("assignees__display_name")
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
),
|
||||
user_id=Case(
|
||||
When(Q(created_by__in=member_ids), then=F("created_by")),
|
||||
When(Q(assignees__in=member_ids), then=F("assignees__id")),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
),
|
||||
avatar=Case(
|
||||
When(Q(created_by__in=member_ids), then=F("created_by__avatar")),
|
||||
When(Q(assignees__in=member_ids), then=F("assignees__avatar")),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
),
|
||||
avatar_url=Case(
|
||||
When(
|
||||
Q(created_by__in=member_ids)
|
||||
& Q(created_by__avatar_asset__isnull=False),
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"created_by__avatar_asset",
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
When(
|
||||
Q(assignees__in=member_ids)
|
||||
& Q(assignees__avatar_asset__isnull=False),
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset",
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
When(Q(created_by__in=member_ids), then=F("created_by__avatar")),
|
||||
When(Q(assignees__in=member_ids), then=F("assignees__avatar")),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
),
|
||||
)
|
||||
.values("display_name", "user_id", "avatar_url")
|
||||
.annotate(
|
||||
cancelled_work_items=Count(
|
||||
"id",
|
||||
filter=Q(state__group="cancelled") & Q(assignees__in=member_ids),
|
||||
distinct=True,
|
||||
),
|
||||
completed_work_items=Count(
|
||||
"id",
|
||||
filter=Q(state__group="completed") & Q(assignees__in=member_ids),
|
||||
distinct=True,
|
||||
),
|
||||
backlog_work_items=Count(
|
||||
"id",
|
||||
filter=Q(state__group="backlog") & Q(assignees__in=member_ids),
|
||||
distinct=True,
|
||||
),
|
||||
un_started_work_items=Count(
|
||||
"id",
|
||||
filter=Q(state__group="unstarted") & Q(assignees__in=member_ids),
|
||||
distinct=True,
|
||||
),
|
||||
started_work_items=Count(
|
||||
"id",
|
||||
filter=Q(state__group="started") & Q(assignees__in=member_ids),
|
||||
distinct=True,
|
||||
),
|
||||
created_work_items=Count(
|
||||
"id", filter=Q(created_by__in=member_ids), distinct=True
|
||||
),
|
||||
)
|
||||
.order_by("display_name"),
|
||||
)
|
||||
|
||||
def get_cycle_stats(self, filters: Dict[str, Any]) -> QuerySet:
|
||||
qs = Cycle.objects.filter(
|
||||
**self.filters["base_filters"],
|
||||
)
|
||||
|
||||
return (
|
||||
qs.values(
|
||||
"id", "name", "project__name", "owned_by_id", "start_date", "end_date"
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group__in=["cancelled"],
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
un_started_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group__in=["unstarted"],
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group__in=["started"],
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group__in=["backlog"],
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
|
||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||
When(
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
),
|
||||
default=Value("DRAFT"),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def get_module_stats(self, filters: Dict[str, Any]) -> QuerySet:
|
||||
qs = Module.objects.filter(
|
||||
**self.filters["base_filters"],
|
||||
)
|
||||
|
||||
return (
|
||||
qs.values(
|
||||
"id", "name", "project__name", "start_date", "target_date", "lead_id"
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_module__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_module__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="completed",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_module__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_module__issue__state__group__in=["cancelled"],
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
un_started_issues=Count(
|
||||
"issue_module__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_module__issue__state__group__in=["unstarted"],
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_module__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_module__issue__state__group__in=["started"],
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_module__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_module__issue__state__group__in=["backlog"],
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def get_intake_stats(self, filters: Dict[str, Any]) -> QuerySet:
|
||||
qs = Issue.objects.filter(
|
||||
**self.filters["base_filters"],
|
||||
).filter(issue_intake__isnull=False, issue_intake__status__in=[1, -1, 2])
|
||||
|
||||
return qs.values("project_id", "project__name").annotate(
|
||||
total_work_items=Count("issue_intake__issue_id"),
|
||||
accepted_intake=Count(
|
||||
"issue_intake__issue_id", filter=Q(issue_intake__status=1)
|
||||
),
|
||||
rejected_intake=Count(
|
||||
"issue_intake__issue_id", filter=Q(issue_intake__status=-1)
|
||||
),
|
||||
duplicate_intake=Count(
|
||||
"issue_intake__issue_id", filter=Q(issue_intake__status=2)
|
||||
),
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request: HttpRequest, slug: str) -> Response:
|
||||
self.initialize_workspace(slug, type="chart")
|
||||
@@ -195,7 +727,31 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
|
||||
self.get_work_items_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
elif type == "users":
|
||||
return Response(
|
||||
self.get_users_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif type == "projects":
|
||||
return Response(
|
||||
self.get_project_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif type == "cycles":
|
||||
return Response(
|
||||
self.get_cycle_stats(self.filters["base_filters"]),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif type == "modules":
|
||||
return Response(
|
||||
self.get_module_stats(self.filters["base_filters"]),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif type == "intake":
|
||||
return Response(
|
||||
self.get_intake_stats(self.filters["base_filters"]),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -224,7 +780,10 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
||||
issue_intake__isnull=False, **self.filters["base_filters"], **date_filter
|
||||
).count()
|
||||
total_members = WorkspaceMember.objects.filter(
|
||||
workspace__slug=self._workspace_slug, is_active=True, **date_filter
|
||||
workspace__slug=self._workspace_slug,
|
||||
is_active=True,
|
||||
member__is_bot=False,
|
||||
**date_filter,
|
||||
).count()
|
||||
total_pages = ProjectPage.objects.filter(
|
||||
**self.filters["base_filters"], **date_filter
|
||||
@@ -294,12 +853,12 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
||||
|
||||
# Generate monthly data (ensure months with 0 count are included)
|
||||
data = []
|
||||
# include the current date at the end
|
||||
end_date = timezone.now().date()
|
||||
last_month = end_date.replace(day=1)
|
||||
current_year = timezone.now().year
|
||||
start_date = timezone.datetime(current_year, 1, 1).date()
|
||||
end_date = timezone.datetime(current_year, 12, 1).date()
|
||||
current_month = start_date
|
||||
|
||||
while current_month <= last_month:
|
||||
while current_month <= end_date:
|
||||
date_str = current_month.strftime("%Y-%m-%d")
|
||||
stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0})
|
||||
data.append(
|
||||
@@ -313,11 +872,8 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
||||
)
|
||||
# Move to next month
|
||||
if current_month.month == 12:
|
||||
current_month = current_month.replace(
|
||||
year=current_month.year + 1, month=1
|
||||
)
|
||||
else:
|
||||
current_month = current_month.replace(month=current_month.month + 1)
|
||||
break
|
||||
current_month = current_month.replace(month=current_month.month + 1)
|
||||
|
||||
schema = {
|
||||
"completed_issues": "completed_issues",
|
||||
@@ -326,6 +882,184 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
||||
|
||||
return {"data": data, "schema": schema}
|
||||
|
||||
def project_status_chart(self) -> Dict[str, Any]:
|
||||
# Get the base queryset
|
||||
queryset = (
|
||||
ProjectAttribute.objects.filter(**self.filters["base_filters"])
|
||||
.values("state__group")
|
||||
.annotate(
|
||||
count=Count("project", distinct=True),
|
||||
states=Count("state", distinct=True),
|
||||
)
|
||||
.order_by("state__group")
|
||||
)
|
||||
|
||||
# Transform data into required format
|
||||
data = [
|
||||
{
|
||||
"key": item["state__group"],
|
||||
"name": item["state__group"],
|
||||
"count": item["count"],
|
||||
}
|
||||
for item in queryset
|
||||
]
|
||||
|
||||
return {"data": data, "schema": {}}
|
||||
|
||||
def cycle_chart(self) -> Dict[str, Any]:
|
||||
# Get the base queryset
|
||||
queryset = (
|
||||
Cycle.objects.filter(**self.filters["base_filters"])
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate completion percentage for each cycle
|
||||
data = []
|
||||
for cycle in queryset:
|
||||
total_issues = cycle.total_issues or 0
|
||||
completed_issues = cycle.completed_issues or 0
|
||||
completion_percentage = (
|
||||
(completed_issues / total_issues * 100) if total_issues > 0 else 0
|
||||
)
|
||||
|
||||
data.append(
|
||||
{
|
||||
"key": cycle.name,
|
||||
"name": cycle.name,
|
||||
"count": round(completion_percentage, 2),
|
||||
}
|
||||
)
|
||||
|
||||
return {"data": data, "schema": {}}
|
||||
|
||||
def module_chart(self) -> Dict[str, Any]:
|
||||
# Get the base queryset with module stats
|
||||
queryset = (
|
||||
Module.objects.filter(**self.filters["base_filters"])
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_module__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
),
|
||||
),
|
||||
completed_issues=Count(
|
||||
"issue_module__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="completed",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
.values("id", "name", "project__name", "total_issues", "completed_issues")
|
||||
)
|
||||
|
||||
# Calculate completion percentage for each module
|
||||
data = []
|
||||
for module in queryset:
|
||||
total_issues = module["total_issues"] or 0
|
||||
completed_issues = module["completed_issues"] or 0
|
||||
completion_percentage = (
|
||||
(completed_issues / total_issues * 100) if total_issues > 0 else 0
|
||||
)
|
||||
|
||||
data.append(
|
||||
{
|
||||
"key": module["name"],
|
||||
"name": module["name"],
|
||||
"count": round(completion_percentage, 2),
|
||||
}
|
||||
)
|
||||
|
||||
return {"data": data, "schema": {}}
|
||||
|
||||
def intake_chart(self) -> Dict[str, Any]:
|
||||
# Get the base queryset
|
||||
queryset = (
|
||||
Issue.objects.filter(**self.filters["base_filters"])
|
||||
.filter(issue_intake__isnull=False, issue_intake__status__in=[1, -1])
|
||||
.select_related("workspace", "issue_intake")
|
||||
)
|
||||
|
||||
# Annotate by month and count accepted/rejected intakes
|
||||
monthly_stats = (
|
||||
queryset.annotate(month=TruncMonth("created_at"))
|
||||
.values("month")
|
||||
.annotate(
|
||||
accepted_count=Count("id", filter=Q(issue_intake__status=1)),
|
||||
rejected_count=Count("id", filter=Q(issue_intake__status=-1)),
|
||||
)
|
||||
.order_by("month")
|
||||
)
|
||||
|
||||
# Create dictionary of month -> counts
|
||||
stats_dict = {
|
||||
stat["month"].strftime("%Y-%m-%d"): {
|
||||
"accepted_count": stat["accepted_count"],
|
||||
"rejected_count": stat["rejected_count"],
|
||||
}
|
||||
for stat in monthly_stats
|
||||
}
|
||||
|
||||
# Generate monthly data (ensure months with 0 count are included)
|
||||
data = []
|
||||
current_year = timezone.now().year
|
||||
start_date = timezone.datetime(current_year, 1, 1).date()
|
||||
end_date = timezone.datetime(current_year, 12, 1).date()
|
||||
current_month = start_date
|
||||
|
||||
while current_month <= end_date:
|
||||
date_str = current_month.strftime("%Y-%m-%d")
|
||||
stats = stats_dict.get(date_str, {"accepted_count": 0, "rejected_count": 0})
|
||||
data.append(
|
||||
{
|
||||
"key": date_str,
|
||||
"name": date_str,
|
||||
"count": stats["accepted_count"] + stats["rejected_count"],
|
||||
"accepted_count": stats["accepted_count"],
|
||||
"rejected_count": stats["rejected_count"],
|
||||
}
|
||||
)
|
||||
# Move to next month
|
||||
if current_month.month == 12:
|
||||
break
|
||||
current_month = current_month.replace(month=current_month.month + 1)
|
||||
|
||||
schema = {
|
||||
"accepted_count": "accepted_count",
|
||||
"rejected_count": "rejected_count",
|
||||
}
|
||||
|
||||
return {"data": data, "schema": schema}
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request: HttpRequest, slug: str) -> Response:
|
||||
self.initialize_workspace(slug, type="chart")
|
||||
@@ -362,5 +1096,25 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
||||
self.work_item_completion_chart(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif type == "project-status":
|
||||
return Response(
|
||||
self.project_status_chart(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif type == "cycles":
|
||||
return Response(
|
||||
self.cycle_chart(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif type == "modules":
|
||||
return Response(
|
||||
self.module_chart(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif type == "intake":
|
||||
return Response(
|
||||
self.intake_chart(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -109,6 +109,7 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
labels__id__isnull=False,
|
||||
label_issue__deleted_at__isnull=True,
|
||||
)
|
||||
.filter(Q(type__isnull=True) | Q(type__is_epic=False))
|
||||
.distinct("labels__id")
|
||||
.order_by("labels__id")
|
||||
.values("labels__id", "labels__color", "labels__name")
|
||||
@@ -486,7 +487,8 @@ class ProjectStatsEndpoint(BaseAPIView):
|
||||
if "completed_issues" in requested_fields:
|
||||
annotations["completed_issues"] = (
|
||||
Issue.issue_objects.filter(
|
||||
project_id=OuterRef("pk"), state__group="completed"
|
||||
project_id=OuterRef("pk"),
|
||||
state__group__in=["completed", "cancelled"],
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -520,4 +522,5 @@ class ProjectStatsEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
projects = projects.annotate(**annotations).values("id", *requested_fields)
|
||||
|
||||
return Response(projects, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import timedelta
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from typing import Dict, Any
|
||||
@@ -5,7 +6,9 @@ from django.db.models import QuerySet, Q, Count
|
||||
from django.http import HttpRequest
|
||||
from django.db.models.functions import TruncMonth
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.db import models
|
||||
from django.db.models import F, Case, When, Value
|
||||
from django.db.models.functions import Concat
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.db.models import (
|
||||
@@ -16,13 +19,13 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models import F, Case, When, Value
|
||||
from django.db.models.functions import Concat
|
||||
|
||||
from plane.utils.build_chart import build_analytics_chart
|
||||
from plane.utils.date_utils import (
|
||||
get_analytics_filters,
|
||||
)
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
|
||||
@@ -56,7 +59,7 @@ class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
}
|
||||
|
||||
def get_work_items_stats(
|
||||
self, project_id, cycle_id=None, module_id=None
|
||||
self, project_id, cycle_id=None, module_id=None, epic=False
|
||||
) -> Dict[str, Dict[str, int]]:
|
||||
"""
|
||||
Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
|
||||
@@ -72,6 +75,10 @@ class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
|
||||
elif epic:
|
||||
base_queryset = Issue.objects.filter(
|
||||
**self.filters["base_filters"], project_id=project_id
|
||||
).filter(Q(type__isnull=False) & Q(type__is_epic=True))
|
||||
else:
|
||||
base_queryset = Issue.issue_objects.filter(
|
||||
**self.filters["base_filters"], project_id=project_id
|
||||
@@ -100,9 +107,14 @@ class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
epic = request.GET.get("epic", False) and check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.EPICS,
|
||||
slug=slug,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
return Response(
|
||||
self.get_work_items_stats(
|
||||
cycle_id=cycle_id, module_id=module_id, project_id=project_id
|
||||
cycle_id=cycle_id, module_id=module_id, project_id=project_id, epic=epic
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -133,7 +145,7 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
)
|
||||
|
||||
def get_work_items_stats(
|
||||
self, project_id, cycle_id=None, module_id=None
|
||||
self, project_id, cycle_id=None, module_id=None, epic=False
|
||||
) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = None
|
||||
if cycle_id is not None:
|
||||
@@ -146,6 +158,10 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
|
||||
elif epic:
|
||||
base_queryset = Issue.objects.filter(
|
||||
**self.filters["base_filters"], project_id=project_id
|
||||
).filter(Q(type__isnull=False) & Q(type__is_epic=True))
|
||||
else:
|
||||
base_queryset = Issue.issue_objects.filter(
|
||||
**self.filters["base_filters"], project_id=project_id
|
||||
@@ -203,9 +219,17 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
epic = request.GET.get("epic", False) and check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.EPICS,
|
||||
slug=slug,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
return Response(
|
||||
self.get_work_items_stats(
|
||||
project_id=project_id, cycle_id=cycle_id, module_id=module_id
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
module_id=module_id,
|
||||
epic=epic,
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -215,17 +239,9 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
|
||||
class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
def work_item_completion_chart(
|
||||
self, project_id, cycle_id=None, module_id=None
|
||||
self, project_id, cycle_id=None, module_id=None, epic=False
|
||||
) -> Dict[str, Any]:
|
||||
# Get the base queryset
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
.filter(project_id=project_id)
|
||||
.select_related("workspace", "state", "parent")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
|
||||
)
|
||||
)
|
||||
queryset = None
|
||||
|
||||
if cycle_id is not None:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
@@ -251,7 +267,28 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
return {"data": [], "schema": {}}
|
||||
queryset = module_issues
|
||||
|
||||
elif epic:
|
||||
queryset = Issue.objects.filter(
|
||||
**self.filters["base_filters"], project_id=project_id
|
||||
).filter(Q(type__isnull=False) & Q(type__is_epic=True))
|
||||
|
||||
project = Project.objects.filter(id=project_id).first()
|
||||
if project.created_at:
|
||||
start_date = project.created_at.date().replace(day=1)
|
||||
else:
|
||||
return {"data": [], "schema": {}}
|
||||
|
||||
else:
|
||||
# Get the base queryset
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
.filter(project_id=project_id)
|
||||
.select_related("workspace", "state", "parent")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
|
||||
)
|
||||
)
|
||||
|
||||
project = Project.objects.filter(id=project_id).first()
|
||||
if project.created_at:
|
||||
start_date = project.created_at.date().replace(day=1)
|
||||
@@ -370,7 +407,11 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
x_axis = request.GET.get("x_axis", "PRIORITY")
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
|
||||
epic = request.GET.get("epic", False) and check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.EPICS,
|
||||
slug=slug,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
if type == "custom-work-items":
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
@@ -394,6 +435,19 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
).values_list("issue_id", flat=True)
|
||||
queryset = queryset.filter(id__in=module_issues)
|
||||
|
||||
elif epic:
|
||||
queryset = (
|
||||
Issue.objects.filter(
|
||||
**self.filters["base_filters"], project_id=project_id
|
||||
)
|
||||
.filter(Q(type__isnull=False) & Q(type__is_epic=True))
|
||||
.select_related("workspace", "state", "parent")
|
||||
.prefetch_related(
|
||||
"assignees",
|
||||
"labels",
|
||||
)
|
||||
)
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
@@ -407,13 +461,12 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
)
|
||||
|
||||
elif type == "work-items":
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
|
||||
return Response(
|
||||
self.work_item_completion_chart(
|
||||
project_id=project_id, cycle_id=cycle_id, module_id=module_id
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
module_id=module_id,
|
||||
epic=epic,
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Python import
|
||||
from uuid import uuid4
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party
|
||||
from rest_framework.response import Response
|
||||
@@ -74,7 +78,10 @@ class ServiceApiTokenEndpoint(BaseAPIView):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
api_token = APIToken.objects.filter(
|
||||
workspace=workspace, is_service=True
|
||||
workspace=workspace,
|
||||
is_service=True,
|
||||
user=request.user,
|
||||
expired_at__isnull=True,
|
||||
).first()
|
||||
|
||||
if api_token:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user