fix: merge conflicts with duplicate page

This commit is contained in:
sriram veeraghanta
2025-05-06 01:34:26 +05:30
4070 changed files with 345905 additions and 10240 deletions
+13 -1
View File
@@ -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
View 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/[email protected]
with:
context: ${{ inputs.build-context }}
file: ${{ inputs.dockerfile-path }}
platforms: ${{ inputs.buildx-platforms }}
tags: ${{ env.DOCKER_TAGS }}
push: true
build-args: ${{ inputs.build-args }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ inputs.docker-username }}
DOCKER_PASSWORD: ${{ inputs.dockerhub-token }}
+168
View File
@@ -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/[email protected]
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 }}
+204
View 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/[email protected]
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/[email protected]
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
View 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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }}
build-context: .
dockerfile-path: ./admin/Dockerfile.admin
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
build-args: ${{ needs.branch_build_setup.outputs.build_args }}
branch_build_push_web:
name: Build-Push Web Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Load Vault Secrets
run: |
echo ${{ needs.branch_build_setup.outputs.vault_secrets }} | base64 -d > vault_secrets.json
jq -r 'to_entries|map("\(.key)=\(.value|tostring)")|.[]' vault_secrets.json >> $GITHUB_ENV
- name: Web Build and Push
uses: makeplane/actions/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }}
build-context: .
dockerfile-path: ./web/Dockerfile.web
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
build-args: ${{ needs.branch_build_setup.outputs.build_args }}
branch_build_push_space:
name: Build-Push Space Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Load Vault Secrets
run: |
echo ${{ needs.branch_build_setup.outputs.vault_secrets }} | base64 -d > vault_secrets.json
jq -r 'to_entries|map("\(.key)=\(.value|tostring)")|.[]' vault_secrets.json >> $GITHUB_ENV
- name: Space Build and Push
uses: makeplane/actions/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }}
build-context: .
dockerfile-path: ./space/Dockerfile.space
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
build-args: ${{ needs.branch_build_setup.outputs.build_args }}
branch_build_push_live:
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Live Build and Push
uses: makeplane/actions/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }}
build-context: .
dockerfile-path: ./live/Dockerfile.live
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_silo:
name: Build-Push Silo Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Silo Build and Push
uses: makeplane/actions/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_silo }}
build-context: .
dockerfile-path: ./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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }}
build-context: ./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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_email }}
build-context: ./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/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ env.REL_VERSION }}
name: ${{ env.REL_VERSION }}
draft: false
prerelease: ${{ env.IS_PRERELEASE }}
generate_release_notes: true
+498
View File
@@ -0,0 +1,498 @@
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
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 }}
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 }}
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
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
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
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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
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/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
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 }}
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/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ env.REL_VERSION }}
name: ${{ env.REL_VERSION }}
draft: false
prerelease: ${{ env.IS_PRERELEASE }}
generate_release_notes: true
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
View 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 "[email protected]"
- 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
View File
@@ -0,0 +1,55 @@
name: Sync from Community Repo
on:
# schedule:
# - cron: "*/30 * * * *" # Runs every 30 minutes
workflow_dispatch:
inputs:
source_branch:
description: "Source branch in Community repo"
required: true
default: "preview"
target_branch:
description: "Target branch in Enterprise repo"
required: true
default: "preview"
jobs:
sync-from-community-repo:
runs-on: ubuntu-latest
steps:
- name: Checkout enterprise repository
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set branch names
run: |
echo "SOURCE_BRANCH=${{ github.event.inputs.source_branch || 'preview' }}" >> $GITHUB_ENV
echo "TARGET_BRANCH=${{ github.event.inputs.target_branch || 'preview' }}" >> $GITHUB_ENV
echo "SYNC_BRANCH=sync-${{ github.run_id }}" >> $GITHUB_ENV
- name: Create sync branch
run: git checkout -b ${{ env.SYNC_BRANCH }}
- name: Fetch from community repository
run: |
git config user.name github-actions
git config user.email [email protected]
git remote add community https://github.com/makeplane/plane.git
git reset --hard community/${{ env.SOURCE_BRANCH }}
- name: Create Pull Request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_TITLE="Sync changes from community repo"
EXISTING_PR=$(gh pr list --base ${{ env.TARGET_BRANCH }} --head ${{ env.SYNC_BRANCH }} --json number --jq '.[0].number')
if [ -z "$EXISTING_PR" ]; then
pr_url=$(gh pr create --base ${{ env.TARGET_BRANCH }} --head ${{ env.SYNC_BRANCH }} --title "$PR_TITLE" --body "This PR syncs changes from the community repository's ${{ env.SOURCE_BRANCH }} branch.")
echo "New Pull Request created: $pr_url"
else
echo "Pull Request already exists with number: $EXISTING_PR"
gh pr edit $EXISTING_PR --title "$PR_TITLE" --body "This PR syncs changes from the community repository's ${{ env.SOURCE_BRANCH }} branch. (Updated)"
echo "Existing Pull Request updated"
fi
+5 -1
View File
@@ -41,12 +41,16 @@ jobs:
- name: Create PR to Target Branch
run: |
# Determine target branch based on current branch prefix
TARGET_BRANCH="preview"
PR_TITLE="${{vars.SYNC_PR_TITLE}}"
# get all pull requests and check if there is already a PR
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number')
if [ -n "$PR_EXISTS" ]; then
echo "Pull Request already exists: $PR_EXISTS"
else
echo "Creating new pull request"
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "${{ vars.SYNC_PR_TITLE }}" --body "")
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "$PR_TITLE" --body "")
echo "Pull Request created: $PR_URL"
fi
+1 -1
View File
@@ -36,8 +36,8 @@ jobs:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target-origin-a "https://[email protected]/$TARGET_REPO.git"
+8
View File
@@ -82,6 +82,7 @@ tmp/
## packages
dist
.flatfile
.temp/
deploy/selfhost/plane-app/
@@ -93,3 +94,10 @@ dev-editor
# Redis
*.rdb
*.rdb.gz
# Monitor
monitor/prime.key
monitor/prime.key.pub
monitor.db
.cursorrules
+1 -1
View File
@@ -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
+8 -8
View File
@@ -26,16 +26,16 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
formState: { errors, isSubmitting },
} = useForm<AIFormValues>({
defaultValues: {
OPENAI_API_KEY: config["OPENAI_API_KEY"],
GPT_ENGINE: config["GPT_ENGINE"],
LLM_API_KEY: config["LLM_API_KEY"],
LLM_MODEL: config["LLM_MODEL"],
},
});
const aiFormFields: TControllerInputFormField[] = [
{
key: "GPT_ENGINE",
key: "LLM_MODEL",
type: "text",
label: "GPT_ENGINE",
label: "LLM Model",
description: (
<>
Choose an OpenAI engine.{" "}
@@ -49,12 +49,12 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
</a>
</>
),
placeholder: "gpt-3.5-turbo",
error: Boolean(errors.GPT_ENGINE),
placeholder: "gpt-4o-mini",
error: Boolean(errors.LLM_MODEL),
required: false,
},
{
key: "OPENAI_API_KEY",
key: "LLM_API_KEY",
type: "password",
label: "API key",
description: (
@@ -71,7 +71,7 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
</>
),
placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd",
error: Boolean(errors.OPENAI_API_KEY),
error: Boolean(errors.LLM_API_KEY),
required: false,
},
];
+240
View 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
View 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
View 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
View 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>
+12 -1
View File
@@ -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";
@@ -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>
);
@@ -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
View File
@@ -1 +1 @@
export * from "ce/components/common";
export * from "./upgrade-button";
@@ -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>
</>
);
};
@@ -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
View 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;
};
@@ -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;
}
};
}
+29 -1
View File
@@ -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();
}
}
+1 -1
View File
@@ -31,7 +31,7 @@
"mobx-react": "^9.1.1",
"next": "^14.2.28",
"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",
+2 -1
View File
@@ -6,7 +6,8 @@
"paths": {
"@/*": ["core/*"],
"@/public/*": ["public/*"],
"@/plane-admin/*": ["ce/*"]
"@/plane-admin/*": ["ee/*"],
"@/ce/*": ["ce/*"]
}
},
"include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+3
View File
@@ -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
View File
@@ -0,0 +1 @@
dump.rdb
+3 -2
View File
@@ -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 -2
View File
@@ -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
View File
@@ -0,0 +1 @@
# API SERVER
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
export SKIP_ENV_VAR=0
python manage.py wait_for_db
# Wait for migrations
python manage.py wait_for_migrations
# Clear Cache before starting to remove stale values
python manage.py clear_cache
# Register instance if INSTANCE_ADMIN_EMAIL is set
if [ -n "$INSTANCE_ADMIN_EMAIL" ]; then
python manage.py setup_instance $INSTANCE_ADMIN_EMAIL
fi
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
set -e
python manage.py wait_for_db
# Wait for migrations
python manage.py wait_for_migrations
# Create the default bucket
#!/bin/bash
# Collect system information
HOSTNAME=$(hostname)
MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1)
CPU_INFO=$(cat /proc/cpuinfo)
MEMORY_INFO=$(free -h)
DISK_INFO=$(df -h)
# Concatenate information and compute SHA-256 hash
SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}')
# Export the variables
MACHINE_SIGNATURE=${MACHINE_SIGNATURE:-$SIGNATURE}
export SKIP_ENV_VAR=1
# Register instance
python manage.py register_instance_ee "$MACHINE_SIGNATURE"
# Load the configuration variable
python manage.py configure_instance
# Create the default bucket
python manage.py create_bucket
# Clear Cache before starting to remove stale values
python manage.py clear_cache
# Fetch latest information from monitor and update all licenses
python manage.py update_licenses
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
+1 -1
View File
@@ -32,4 +32,4 @@ python manage.py create_bucket
# Clear Cache before starting to remove stale values
python manage.py clear_cache
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
+2 -1
View File
@@ -15,4 +15,5 @@ from .state import StateLiteSerializer, StateSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
from .intake import IntakeIssueSerializer
from .estimate import EstimatePointSerializer
from .estimate import EstimatePointSerializer
from .issue_type import IssueTypeAPISerializer, ProjectIssueTypeAPISerializer
+11 -8
View File
@@ -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 (
@@ -160,12 +160,15 @@ class IssueSerializer(BaseSerializer):
else:
try:
# 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():
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()
):
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
@@ -402,7 +405,7 @@ class IssueCommentSerializer(BaseSerializer):
"created_at",
"updated_at",
]
exclude = ["comment_stripped", "comment_json"]
exclude = ["comment_json"]
def validate(self, data):
try:
@@ -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",
]
+9 -2
View File
@@ -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)
+10
View File
@@ -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
View 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",
),
]
+17
View File
@@ -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
View 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 ========================
]
+7 -2
View File
@@ -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",
),
]
+5
View File
@@ -0,0 +1,5 @@
from django.urls import path
from plane.api.views import UserEndpoint
urlpatterns = [path("users/me/", UserEndpoint.as_view(), name="users")]
+7 -1
View File
@@ -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
+414
View File
@@ -0,0 +1,414 @@
# 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
)
+16 -4
View File
@@ -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
+98 -6
View File
@@ -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",
+48 -5
View File
@@ -2,12 +2,12 @@
import json
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.db.models import Q, Value, UUIDField
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Q, UUIDField, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
# Third party imports
from rest_framework import status
@@ -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 = {
+396 -7
View File
@@ -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.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",
@@ -789,12 +900,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()),
@@ -840,7 +978,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",
@@ -971,7 +1110,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(
@@ -1133,3 +1280,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
View 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)
+34 -6
View File
@@ -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
+17
View 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)
@@ -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
+9 -4
View File
@@ -2,7 +2,6 @@ from plane.db.models import WorkspaceMember, ProjectMember
from functools import wraps
from rest_framework.response import Response
from rest_framework import status
from enum import Enum
@@ -12,14 +11,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)
+15 -3
View File
@@ -1,4 +1,4 @@
from .base import BaseSerializer
from .base import BaseSerializer, DynamicBaseSerializer
from .user import (
UserSerializer,
UserLiteSerializer,
@@ -23,6 +23,7 @@ from .workspace import (
WorkspaceRecentVisitSerializer,
WorkspaceHomePreferenceSerializer,
StickySerializer,
WorkspaceUserMeSerializer,
)
from .project import (
ProjectSerializer,
@@ -33,7 +34,6 @@ from .project import (
ProjectIdentifierSerializer,
ProjectLiteSerializer,
ProjectMemberLiteSerializer,
DeployBoardSerializer,
ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer,
ProjectMemberRoleSerializer,
@@ -45,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
+1
View File
@@ -3,6 +3,7 @@ from rest_framework import serializers
class BaseSerializer(serializers.ModelSerializer):
id = serializers.PrimaryKeyRelatedField(read_only=True)
deleted_at = serializers.ReadOnlyField()
class DynamicBaseSerializer(BaseSerializer):
+10 -2
View File
@@ -3,9 +3,11 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import IssueStateSerializer
from plane.db.models import Cycle, CycleIssue, CycleUserProperties
from plane.utils.timezone_converter import convert_to_utc
from plane.ee.models import EntityProgress
class CycleWriteSerializer(BaseSerializer):
@@ -39,7 +41,7 @@ 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 +104,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__"
@@ -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"]
+19 -1
View File
@@ -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,10 +56,12 @@ 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))
class UserFavoriteSerializer(serializers.ModelSerializer):
entity_data = serializers.SerializerMethodField()
@@ -0,0 +1,8 @@
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
from .github import (
GithubRepositorySerializer,
GithubRepositorySyncSerializer,
GithubIssueSyncSerializer,
GithubCommentSyncSerializer,
)
from .slack import SlackProjectSyncSerializer
@@ -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__"
@@ -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"]
@@ -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"]
+79 -3
View File
@@ -37,7 +37,11 @@ from plane.db.models import (
IssueVersion,
IssueDescriptionVersion,
ProjectMember,
IssueType,
)
from plane.ee.models import Customer
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
)
@@ -137,8 +145,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
@@ -332,7 +353,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 +374,8 @@ class IssueRelationSerializer(BaseSerializer):
"sequence_id",
"relation_type",
"name",
"type_id",
"is_epic",
"state_id",
"priority",
"assignee_ids",
@@ -374,7 +401,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 +420,8 @@ class RelatedIssueSerializer(BaseSerializer):
"sequence_id",
"relation_type",
"name",
"type_id",
"is_epic",
"state_id",
"priority",
"assignee_ids",
@@ -675,10 +706,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 +770,52 @@ 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
)
class IssuePublicSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
+36 -17
View File
@@ -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,11 @@ class PageSerializer(BaseSerializer):
"logo_props",
"label_ids",
"project_ids",
"anchor",
"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 +127,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 +202,6 @@ class PageVersionDetailSerializer(BaseSerializer):
"updated_at",
"created_by",
"updated_by",
"sub_pages_data",
]
read_only_fields = ["workspace", "page"]
+7 -12
View File
@@ -10,7 +10,6 @@ from plane.db.models import (
ProjectMember,
ProjectMemberInvite,
ProjectIdentifier,
DeployBoard,
ProjectPublicMember,
)
@@ -96,6 +95,12 @@ class ProjectListSerializer(DynamicBaseSerializer):
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)
# EE: project_grouping ends
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
def get_members(self, obj):
@@ -105,7 +110,7 @@ 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 []
@@ -179,16 +184,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
+1
View File
@@ -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
+42 -6
View File
@@ -56,6 +56,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
@@ -74,6 +99,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
@@ -148,7 +174,6 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
return value
def create(self, validated_data):
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
@@ -157,7 +182,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
workspace_user_link = WorkspaceUserLink.objects.filter(
url=url,
workspace_id=validated_data.get("workspace_id"),
owner_id=validated_data.get("owner_id")
owner_id=validated_data.get("owner_id"),
)
if workspace_user_link.exists():
@@ -173,10 +198,8 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
url = validated_data.get("url")
workspace_user_link = WorkspaceUserLink.objects.filter(
url=url,
workspace_id=instance.workspace_id,
owner=instance.owner
)
url=url, workspace_id=instance.workspace_id, owner=instance.owner
)
if workspace_user_link.exclude(pk=instance.id).exists():
raise serializers.ValidationError(
@@ -185,8 +208,10 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
return super().update(instance, validated_data)
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
is_epic = serializers.SerializerMethodField()
class Meta:
model = Issue
@@ -200,6 +225,7 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
"sequence_id",
"project_id",
"project_identifier",
"is_epic",
]
def get_project_identifier(self, obj):
@@ -207,6 +233,9 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
return project.identifier if project else None
def get_is_epic(self, obj):
return obj.type.is_epic if obj.type else False
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
project_members = serializers.SerializerMethodField()
@@ -223,6 +252,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()
@@ -256,6 +291,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))
+11
View File
@@ -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,
]
+1
View File
@@ -99,4 +99,5 @@ urlpatterns = [
CycleAnalyticsEndpoint.as_view(),
name="project-cycle",
),
]
+43
View 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
View 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
]
-6
View File
@@ -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/",
+17 -6
View File
@@ -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"}),
@@ -84,4 +94,5 @@ urlpatterns = [
PageDuplicateEndpoint.as_view(),
name="page-duplicate",
),
]
+11 -1
View File
@@ -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",
)
]
+4
View File
@@ -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"
+31 -6
View File
@@ -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,
@@ -122,7 +121,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,
@@ -161,17 +160,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,
@@ -216,7 +215,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
+4 -1
View File
@@ -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)
+8 -1
View File
@@ -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:
+6
View File
@@ -2,11 +2,13 @@
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework_simplejwt.authentication import JWTAuthentication
# Module imports
from ..base import BaseAPIView, BaseViewSet
from plane.db.models import FileAsset, Workspace
from plane.app.serializers import FileAssetSerializer
from plane.authentication.session import BaseSessionAuthentication
class FileAssetEndpoint(BaseAPIView):
@@ -16,6 +18,8 @@ class FileAssetEndpoint(BaseAPIView):
A viewset for viewing and editing task instances.
"""
authentication_classes = [JWTAuthentication, BaseSessionAuthentication]
def get(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
files = FileAsset.objects.filter(asset=asset_key)
@@ -50,6 +54,8 @@ class FileAssetEndpoint(BaseAPIView):
class FileAssetViewSet(BaseViewSet):
authentication_classes = [JWTAuthentication, BaseSessionAuthentication]
def restore(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
+72 -6
View File
@@ -11,14 +11,19 @@ from django.db import IntegrityError
from rest_framework import status
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.authentication import JWTAuthentication
# Module imports
from ..base import BaseAPIView
from plane.db.models import FileAsset, Workspace, Project, User
from plane.ee.models import Customer
from plane.settings.storage import S3Storage
from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
from plane.payment.flags.flag import FeatureFlag
from plane.authentication.session import BaseSessionAuthentication
class UserAssetsV2Endpoint(BaseAPIView):
@@ -206,7 +211,10 @@ class UserAssetsV2Endpoint(BaseAPIView):
class WorkspaceFileAssetEndpoint(BaseAPIView):
"""This endpoint is used to upload cover images/logos etc for workspace, projects and users."""
"""
This endpoint is used to upload cover images/logos
etc for workspace, projects and users.
"""
def get_entity_id_field(self, entity_type, entity_id):
# Workspace Logo
@@ -238,6 +246,24 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
# Comment Description
if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
return {"comment_id": entity_id}
if entity_type in (
FileAsset.EntityTypeContext.TEAM_SPACE_DESCRIPTION,
FileAsset.EntityTypeContext.TEAM_SPACE_COMMENT_DESCRIPTION,
FileAsset.EntityTypeContext.OAUTH_APP_DESCRIPTION,
FileAsset.EntityTypeContext.OAUTH_APP_LOGO,
FileAsset.EntityTypeContext.INITIATIVE_DESCRIPTION,
FileAsset.EntityTypeContext.INITIATIVE_ATTACHMENT,
FileAsset.EntityTypeContext.INITIATIVE_COMMENT_DESCRIPTION,
FileAsset.EntityTypeContext.CUSTOMER_REQUEST_ATTACHMENT,
FileAsset.EntityTypeContext.CUSTOMER_LOGO,
FileAsset.EntityTypeContext.CUSTOMER_DESCRIPTION,
FileAsset.EntityTypeContext.CUSTOMER_REQUEST_DESCRIPTION,
FileAsset.EntityTypeContext.WORKITEM_TEMPLATE_DESCRIPTION,
FileAsset.EntityTypeContext.PAGE_TEMPLATE_DESCRIPTION,
):
return {"entity_identifier": entity_id}
return {}
def asset_delete(self, asset_id):
@@ -291,7 +317,16 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
project.cover_image_asset_id = asset_id
project.save()
return
else:
elif entity_type == FileAsset.EntityTypeContext.CUSTOMER_LOGO:
customer = Customer.objects.filter(id=asset.entity_identifier).first()
if customer is None:
return
# Delete the previous logo
if customer.logo_asset_id:
self.asset_delete(customer.logo_asset_id)
# Save the new logo
customer.logo_asset_id = asset_id
customer.save()
return
def entity_asset_delete(self, entity_type, asset, request):
@@ -357,8 +392,21 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Get the size limit
size_limit = min(settings.FILE_SIZE_LIMIT, size)
if entity_type in [
FileAsset.EntityTypeContext.WORKSPACE_LOGO,
FileAsset.EntityTypeContext.PROJECT_COVER,
FileAsset.EntityTypeContext.CUSTOMER_LOGO
]:
size_limit = min(size, settings.FILE_SIZE_LIMIT)
else:
if settings.IS_MULTI_TENANT and 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)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
@@ -468,6 +516,8 @@ class StaticFileAssetEndpoint(BaseAPIView):
FileAsset.EntityTypeContext.USER_COVER,
FileAsset.EntityTypeContext.WORKSPACE_LOGO,
FileAsset.EntityTypeContext.PROJECT_COVER,
FileAsset.EntityTypeContext.OAUTH_APP_LOGO,
FileAsset.EntityTypeContext.CUSTOMER_LOGO
]:
return Response(
{"error": "Invalid entity type.", "status": False},
@@ -485,6 +535,8 @@ class StaticFileAssetEndpoint(BaseAPIView):
class AssetRestoreEndpoint(BaseAPIView):
"""Endpoint to restore a deleted assets."""
authentication_classes = [JWTAuthentication, BaseSessionAuthentication]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def post(self, request, slug, asset_id):
asset = FileAsset.all_objects.get(id=asset_id, workspace__slug=slug)
@@ -497,6 +549,8 @@ class AssetRestoreEndpoint(BaseAPIView):
class ProjectAssetEndpoint(BaseAPIView):
"""This endpoint is used to upload cover images/logos etc for workspace, projects and users."""
authentication_classes = [JWTAuthentication, BaseSessionAuthentication]
def get_entity_id_field(self, entity_type, entity_id):
if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO:
return {"workspace_id": entity_id}
@@ -558,8 +612,20 @@ class ProjectAssetEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Get the size limit
size_limit = min(settings.FILE_SIZE_LIMIT, size)
if entity_type in [
FileAsset.EntityTypeContext.WORKSPACE_LOGO,
FileAsset.EntityTypeContext.PROJECT_COVER,
]:
size_limit = min(size, settings.FILE_SIZE_LIMIT)
else:
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)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
+3 -1
View File
@@ -34,7 +34,7 @@ class TimezoneMixin:
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if request.user.is_authenticated:
if request.user and request.user.is_authenticated:
timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone))
else:
timezone.deactivate()
@@ -75,6 +75,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
else print("Server Error")
)
if isinstance(e, IntegrityError):
log_exception(e)
return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
@@ -175,6 +176,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
return response
except Exception as e:
if isinstance(e, IntegrityError):
log_exception(e)
return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
+41 -4
View File
@@ -30,6 +30,8 @@ from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import (
CycleSerializer,
@@ -55,7 +57,9 @@ from plane.utils.host import base_host
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
from plane.ee.bgtasks.entity_issue_state_progress_task import (
entity_issue_state_activity_task,
)
class CycleViewSet(BaseViewSet):
serializer_class = CycleSerializer
@@ -285,7 +289,7 @@ class CycleViewSet(BaseViewSet):
data=request.data, context={"project_id": project_id}
)
if serializer.is_valid():
serializer.save(project_id=project_id, owned_by=request.user)
serializer.save(project_id=project_id, owned_by=request.user, version=2)
cycle = (
self.get_queryset()
.filter(pk=serializer.data["id"])
@@ -1042,6 +1046,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
project_id=project_id,
workspace__slug=slug,
issue__state__group__in=["backlog", "unstarted", "started"],
issue__deleted_at__isnull=True,
)
updated_cycles = []
@@ -1057,8 +1062,34 @@ class TransferCycleIssueEndpoint(BaseAPIView):
}
)
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
_ = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
# Trigger the entity issue state activity task for removal of issues
entity_issue_state_activity_task.delay(
issue_cycle_data=[
{
"issue_id": str(cycle_issue.issue_id),
"cycle_id": str(cycle_id),
}
for cycle_issue in cycle_issues
],
user_id=str(self.request.user.id),
slug=slug,
action="REMOVED",
)
# trigger the entity issue state activity task for adding issues
entity_issue_state_activity_task.delay(
issue_cycle_data=[
{
"issue_id": str(cycle_issue.issue_id),
"cycle_id": str(new_cycle_id),
}
for cycle_issue in cycle_issues
],
user_id=str(self.request.user.id),
slug=slug,
action="ADDED",
)
# Capture Issue Activity
@@ -1285,6 +1316,12 @@ class CycleAnalyticsEndpoint(BaseAPIView):
.first()
)
if not cycle:
return Response(
{"error": "Cycle not found"},
status=status.HTTP_404_NOT_FOUND,
)
if not cycle.start_date or not cycle.end_date:
return Response(
{"error": "Cycle has no start or end date"},
+63 -2
View File
@@ -16,18 +16,28 @@ from rest_framework.response import Response
# Module imports
from .. import BaseViewSet
from plane.app.serializers import CycleIssueSerializer
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import Cycle, CycleIssue, Issue, FileAsset, IssueLink
from plane.db.models import (
Cycle,
Issue,
FileAsset,
CycleIssue,
IssueLink,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.app.permissions import allow_permission, ROLE
from plane.utils.host import base_host
from plane.ee.bgtasks.entity_issue_state_progress_task import (
entity_issue_state_activity_task,
)
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
@@ -237,6 +247,27 @@ class CycleIssueViewSet(BaseViewSet):
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(
[
@@ -250,6 +281,7 @@ class CycleIssueViewSet(BaseViewSet):
)
for issue in new_issues
],
ignore_conflicts=True,
batch_size=10,
)
@@ -274,6 +306,22 @@ class CycleIssueViewSet(BaseViewSet):
# 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",
@@ -303,6 +351,7 @@ class CycleIssueViewSet(BaseViewSet):
project_id=project_id,
cycle_id=cycle_id,
)
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
@@ -320,4 +369,16 @@ class CycleIssueViewSet(BaseViewSet):
origin=base_host(request=request, is_app=True),
)
cycle_issue.delete()
# 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)
+88 -2
View File
@@ -1,18 +1,21 @@
# Python imports
import random
import string
import json
# Django imports
from django.utils import timezone
from django.db.models import F
# Third party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from ..base import BaseViewSet, BaseAPIView
from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE
from plane.db.models import Project, Estimate, EstimatePoint, Issue
from plane.db.models import Project, Estimate, EstimatePoint, Issue, Cycle
from plane.app.serializers import (
EstimateSerializer,
EstimatePointSerializer,
@@ -20,7 +23,9 @@ from plane.app.serializers import (
)
from plane.utils.cache import invalidate_cache
from plane.bgtasks.issue_activities_task import issue_activity
from plane.ee.bgtasks.entity_issue_state_progress_task import (
entity_issue_state_activity_task,
)
def generate_random_name(length=10):
letters = string.ascii_lowercase
@@ -202,6 +207,41 @@ class EstimatePointEndpoint(BaseViewSet):
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
serializer.save()
# also track the entity state change
if Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists():
cycle = Cycle.objects.filter(
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
project_id=project_id,
workspace__slug=slug,
).first()
# If cycle exists, proceed with the logic
if cycle:
cycle_id = str(cycle.id)
issues = Issue.objects.annotate(cycle_id=F("issue_cycle__cycle_id")).filter(
estimate_point_id=estimate_point_id, cycle_id=cycle.id
)
# 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),
}
for issue in issues
],
user_id=str(request.user.id),
slug=slug,
action="UPDATED",
)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@@ -266,6 +306,7 @@ class EstimatePointEndpoint(BaseViewSet):
),
epoch=int(timezone.now().timestamp()),
)
issues.update(estimate_point_id=None)
# delete the estimate point
old_estimate_point = EstimatePoint.objects.filter(pk=estimate_point_id).first()
@@ -280,9 +321,54 @@ class EstimatePointEndpoint(BaseViewSet):
EstimatePoint.objects.bulk_update(
updated_estimate_points, ["key"], batch_size=10
)
# also track the entity state change
if Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists():
cycle = Cycle.objects.filter(
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
project_id=project_id,
workspace__slug=slug,
).first()
if cycle:
cycle_id = str(cycle.id)
new_estimate_value = (
EstimatePoint.objects.filter(pk=new_estimate_id)
.values_list("value", flat=True)
.first()
if new_estimate_id
else None
)
issues = Issue.objects.filter(
estimate_point_id=(
new_estimate_id if new_estimate_id else estimate_point_id
),
issue_cycle__cycle_id=cycle.id,
)
# 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),
}
for issue in issues
],
user_id=str(request.user.id),
slug=slug,
action="UPDATED",
)
old_estimate_point.delete()
# TODO: track the issue activity as well if the estimate point is deleted
return Response(
EstimatePointSerializer(updated_estimate_points, many=True).data,
status=status.HTTP_200_OK,
+2 -3
View File
@@ -5,7 +5,6 @@ from typing import List, Dict, Tuple
# Third party import
from openai import OpenAI
import requests
from rest_framework import status
from rest_framework.response import Response
@@ -113,8 +112,8 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
final_text = task + "\n" + prompt
try:
# For Gemini, prepend provider name to model
if provider.lower() == "gemini":
model = f"gemini/{model}"
if provider.lower() != "openai":
return None, f"Unsupported provider: {provider}"
client = OpenAI(api_key=api_key)
chat_completion = client.chat.completions.create(
+518
View File
@@ -0,0 +1,518 @@
# Python imports
import uuid
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Django imports
from django.db.models import Max, Q
# Module imports
from plane.app.views import BaseAPIView
from plane.db.models import (
WorkspaceIntegration,
Importer,
APIToken,
Project,
State,
IssueSequence,
Issue,
IssueActivity,
IssueComment,
IssueLink,
IssueLabel,
Workspace,
IssueAssignee,
Module,
ModuleLink,
ModuleIssue,
Label,
)
from plane.app.serializers import (
ImporterSerializer,
IssueFlatSerializer,
ModuleSerializer,
)
from plane.utils.integrations.github import get_github_repo_details
from plane.utils.importers.jira import jira_project_issue_summary, is_allowed_hostname
from plane.bgtasks.importer_task import service_importer
from plane.utils.html_processor import strip_tags
from plane.app.permissions import WorkSpaceAdminPermission
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
def get(self, request, slug, service):
if service == "github":
owner = request.GET.get("owner", False)
repo = request.GET.get("repo", False)
if not owner or not repo:
return Response(
{"error": "Owner and repo are required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace_integration = WorkspaceIntegration.objects.get(
integration__provider="github", workspace__slug=slug
)
access_tokens_url = workspace_integration.metadata.get(
"access_tokens_url", False
)
if not access_tokens_url:
return Response(
{
"error": "There was an error during the installation of the GitHub app. To resolve this issue, we recommend reinstalling the GitHub app."
},
status=status.HTTP_400_BAD_REQUEST,
)
issue_count, labels, collaborators = get_github_repo_details(
access_tokens_url, owner, repo
)
return Response(
{
"issue_count": issue_count,
"labels": labels,
"collaborators": collaborators,
},
status=status.HTTP_200_OK,
)
if service == "jira":
# Check for all the keys
params = {
"project_key": "Project key is required",
"api_token": "API token is required",
"email": "Email is required",
"cloud_hostname": "Cloud hostname is required",
}
for key, error_message in params.items():
if not request.GET.get(key, False):
return Response(
{"error": error_message}, status=status.HTTP_400_BAD_REQUEST
)
project_key = request.GET.get("project_key", "")
api_token = request.GET.get("api_token", "")
email = request.GET.get("email", "")
cloud_hostname = request.GET.get("cloud_hostname", "")
response = jira_project_issue_summary(
email, api_token, project_key, cloud_hostname
)
if "error" in response:
return Response(response, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(response, status=status.HTTP_200_OK)
return Response(
{"error": "Service not supported yet"}, status=status.HTTP_400_BAD_REQUEST
)
class ImportServiceEndpoint(BaseAPIView):
permission_classes = [WorkSpaceAdminPermission]
def post(self, request, slug, service):
project_id = request.data.get("project_id", False)
if not project_id:
return Response(
{"error": "Project ID is required"}, status=status.HTTP_400_BAD_REQUEST
)
workspace = Workspace.objects.get(slug=slug)
if service == "github":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
if not data or not metadata or not config:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user, label="Importer", workspace=workspace
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
if service == "jira":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
cloud_hostname = metadata.get("cloud_hostname", False)
if not cloud_hostname:
return Response(
{"error": "Cloud hostname is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not is_allowed_hostname(cloud_hostname):
return Response(
{"error": "Hostname is not a valid hostname."},
status=status.HTTP_400_BAD_REQUEST,
)
if not data or not metadata:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user, label="Importer", workspace=workspace
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
{"error": "Servivce not supported yet"}, status=status.HTTP_400_BAD_REQUEST
)
def get(self, request, slug):
imports = (
Importer.objects.filter(workspace__slug=slug)
.order_by("-created_at")
.select_related("initiated_by", "project", "workspace")
)
serializer = ImporterSerializer(imports, many=True)
return Response(serializer.data)
def delete(self, request, slug, service, pk):
importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug)
if importer.imported_data is not None:
# Delete all imported Issues
imported_issues = importer.imported_data.get("issues", [])
Issue.issue_objects.filter(id__in=imported_issues).delete()
# Delete all imported Labels
imported_labels = importer.imported_data.get("labels", [])
Label.objects.filter(id__in=imported_labels).delete()
if importer.service == "jira":
imported_modules = importer.imported_data.get("modules", [])
Module.objects.filter(id__in=imported_modules).delete()
importer.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, service, pk):
importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug)
serializer = ImporterSerializer(importer, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class UpdateServiceImportStatusEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service, importer_id):
importer = Importer.objects.get(
pk=importer_id, workspace__slug=slug, project_id=project_id, service=service
)
importer.status = request.data.get("status", "processing")
importer.save()
return Response(status.HTTP_200_OK)
class BulkImportIssuesEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service):
# Get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
# Get the default state
default_state = State.objects.filter(
~Q(name="Triage"), project_id=project_id, default=True
).first()
# if there is no default state assign any random state
if default_state is None:
default_state = State.objects.filter(
~Q(name="Triage"), project_id=project_id
).first()
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(project_id=project_id).aggregate(
largest=Max("sequence")
)["largest"]
last_id = 1 if last_id is None else last_id + 1
# Get the maximum sort order
largest_sort_order = Issue.objects.filter(
project_id=project_id, state=default_state
).aggregate(largest=Max("sort_order"))["largest"]
largest_sort_order = (
65535 if largest_sort_order is None else largest_sort_order + 10000
)
# Get the issues_data
issues_data = request.data.get("issues_data", [])
if not len(issues_data):
return Response(
{"error": "Issue data is required"}, status=status.HTTP_400_BAD_REQUEST
)
# Issues
bulk_issues = []
for issue_data in issues_data:
bulk_issues.append(
Issue(
project_id=project_id,
workspace_id=project.workspace_id,
state_id=(
issue_data.get("state")
if issue_data.get("state", False)
else default_state.id
),
name=issue_data.get("name", "Issue Created through Bulk"),
description_html=issue_data.get("description_html", "<p></p>"),
description_stripped=(
None
if (
issue_data.get("description_html") == ""
or issue_data.get("description_html") is None
)
else strip_tags(issue_data.get("description_html"))
),
sequence_id=last_id,
sort_order=largest_sort_order,
start_date=issue_data.get("start_date", None),
target_date=issue_data.get("target_date", None),
priority=issue_data.get("priority", "none"),
created_by=request.user,
)
)
largest_sort_order = largest_sort_order + 10000
last_id = last_id + 1
issues = Issue.objects.bulk_create(
bulk_issues, batch_size=100, ignore_conflicts=True
)
# Sequences
_ = IssueSequence.objects.bulk_create(
[
IssueSequence(
issue=issue,
sequence=issue.sequence_id,
project_id=project_id,
workspace_id=project.workspace_id,
)
for issue in issues
],
batch_size=100,
)
# Attach Labels
bulk_issue_labels = []
for issue, issue_data in zip(issues, issues_data):
labels_list = issue_data.get("labels_list", [])
bulk_issue_labels = bulk_issue_labels + [
IssueLabel(
issue=issue,
label_id=label_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for label_id in labels_list
]
_ = IssueLabel.objects.bulk_create(
bulk_issue_labels, batch_size=100, ignore_conflicts=True
)
# Attach Assignees
bulk_issue_assignees = []
for issue, issue_data in zip(issues, issues_data):
assignees_list = issue_data.get("assignees_list", [])
bulk_issue_assignees = bulk_issue_assignees + [
IssueAssignee(
issue=issue,
assignee_id=assignee_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for assignee_id in assignees_list
]
_ = IssueAssignee.objects.bulk_create(
bulk_issue_assignees, batch_size=100, ignore_conflicts=True
)
# Track the issue activities
IssueActivity.objects.bulk_create(
[
IssueActivity(
issue=issue,
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
comment=f"imported the issue from {service}",
verb="created",
created_by=request.user,
)
for issue in issues
],
batch_size=100,
)
# Create Comments
bulk_issue_comments = []
for issue, issue_data in zip(issues, issues_data):
comments_list = issue_data.get("comments_list", [])
bulk_issue_comments = bulk_issue_comments + [
IssueComment(
issue=issue,
comment_html=comment.get("comment_html", "<p></p>"),
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for comment in comments_list
]
_ = IssueComment.objects.bulk_create(bulk_issue_comments, batch_size=100)
# Attach Links
_ = IssueLink.objects.bulk_create(
[
IssueLink(
issue=issue,
url=issue_data.get("link", {}).get("url", "https://github.com"),
title=issue_data.get("link", {}).get("title", "Original Issue"),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for issue, issue_data in zip(issues, issues_data)
]
)
return Response(
{"issues": IssueFlatSerializer(issues, many=True).data},
status=status.HTTP_201_CREATED,
)
class BulkImportModulesEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service):
modules_data = request.data.get("modules_data", [])
project = Project.objects.get(pk=project_id, workspace__slug=slug)
modules = Module.objects.bulk_create(
[
Module(
name=module.get("name", uuid.uuid4().hex),
description=module.get("description", ""),
start_date=module.get("start_date", None),
target_date=module.get("target_date", None),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for module in modules_data
],
batch_size=100,
ignore_conflicts=True,
)
modules = Module.objects.filter(id__in=[module.id for module in modules])
if len(modules) == len(modules_data):
_ = ModuleLink.objects.bulk_create(
[
ModuleLink(
module=module,
url=module_data.get("link", {}).get("url", "https://plane.so"),
title=module_data.get("link", {}).get(
"title", "Original Issue"
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for module, module_data in zip(modules, modules_data)
],
batch_size=100,
ignore_conflicts=True,
)
bulk_module_issues = []
for module, module_data in zip(modules, modules_data):
module_issues_list = module_data.get("module_issues_list", [])
bulk_module_issues = bulk_module_issues + [
ModuleIssue(
issue_id=issue,
module=module,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for issue in module_issues_list
]
_ = ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=100, ignore_conflicts=True
)
serializer = ModuleSerializer(modules, many=True)
return Response(
{"modules": serializer.data}, status=status.HTTP_201_CREATED
)
else:
return Response(
{"message": "Modules created but issues could not be imported"},
status=status.HTTP_200_OK,
)
+48
View File
@@ -45,6 +45,8 @@ from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.global_paginator import paginate
from plane.utils.host import base_host
from plane.db.models.intake import SourceType
from plane.ee.models import IntakeSetting
from plane.ee.utils.workflow import WorkflowStateManager
class IntakeViewSet(BaseViewSet):
@@ -103,6 +105,7 @@ class IntakeIssueViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
.filter(Q(type__isnull=True) | Q(type__is_epic=False))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
@@ -247,6 +250,20 @@ class IntakeIssueViewSet(BaseViewSet):
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
)
intake = Intake.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
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,
)
# Check for valid priority
if request.data.get("issue", {}).get("priority", "none") not in [
"low",
@@ -269,6 +286,18 @@ class IntakeIssueViewSet(BaseViewSet):
"default_assignee_id": project.default_assignee_id,
},
)
# EE start
workflow_state_manager = WorkflowStateManager(project_id=project_id, slug=slug)
if workflow_state_manager.validate_issue_creation(
state_id=request.data.get("issue", None).get("state_id", None),
user_id=request.user.id,
):
return Response(
{"error": "You cannot create a intake issue in this state"},
status=status.HTTP_403_FORBIDDEN,
)
# EE end
if serializer.is_valid():
serializer.save()
intake_id = Intake.objects.filter(
@@ -391,6 +420,25 @@ class IntakeIssueViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())),
),
).get(pk=intake_issue.issue_id, workspace__slug=slug, project_id=project_id)
# EE start
# Check if state is updated then is the transition allowed
workflow_state_manager = WorkflowStateManager(
project_id=project_id, slug=slug
)
if issue_data.get(
"state_id", None
) and not workflow_state_manager.validate_state_transition(
issue=issue,
new_state_id=issue_data.get("state_id", None),
user_id=request.user.id,
):
return Response(
{"error": "State transition is not allowed"},
status=status.HTTP_403_FORBIDDEN,
)
# EE end
# Only allow guests to edit name and description
if project_member.role <= 5:
issue_data = {
@@ -0,0 +1,9 @@
from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
from .github import (
GithubRepositorySyncViewSet,
GithubIssueSyncViewSet,
BulkCreateGithubIssueSyncEndpoint,
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
)
from .slack import SlackProjectSyncViewSet
@@ -0,0 +1,165 @@
# Python improts
import uuid
# Django imports
from django.contrib.auth.hashers import make_password
# Third party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from plane.app.views import BaseViewSet
from plane.db.models import (
Integration,
WorkspaceIntegration,
Workspace,
User,
WorkspaceMember,
APIToken,
)
from plane.app.serializers import IntegrationSerializer, WorkspaceIntegrationSerializer
from plane.utils.integrations.github import (
get_github_metadata,
delete_github_installation,
)
from plane.app.permissions import WorkSpaceAdminPermission
from plane.utils.integrations.slack import slack_oauth
class IntegrationViewSet(BaseViewSet):
serializer_class = IntegrationSerializer
model = Integration
def create(self, request):
serializer = IntegrationSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, pk):
integration = Integration.objects.get(pk=pk)
if integration.verified:
return Response(
{"error": "Verified integrations cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IntegrationSerializer(integration, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, pk):
integration = Integration.objects.get(pk=pk)
if integration.verified:
return Response(
{"error": "Verified integrations cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
integration.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class WorkspaceIntegrationViewSet(BaseViewSet):
serializer_class = WorkspaceIntegrationSerializer
model = WorkspaceIntegration
permission_classes = [WorkSpaceAdminPermission]
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("integration")
)
def create(self, request, slug, provider):
workspace = Workspace.objects.get(slug=slug)
integration = Integration.objects.get(provider=provider)
config = {}
if provider == "github":
installation_id = request.data.get("installation_id", None)
if not installation_id:
return Response(
{"error": "Installation ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
metadata = get_github_metadata(installation_id)
config = {"installation_id": installation_id}
if provider == "slack":
code = request.data.get("code", False)
if not code:
return Response(
{"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST
)
slack_response = slack_oauth(code=code)
metadata = slack_response
access_token = metadata.get("access_token", False)
team_id = metadata.get("team", {}).get("id", False)
if not metadata or not access_token or not team_id:
return Response(
{"error": "Slack could not be installed. Please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
config = {"team_id": team_id, "access_token": access_token}
# Create a bot user
bot_user = User.objects.create(
email=f"{uuid.uuid4().hex}@plane.so",
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
is_bot=True,
first_name=integration.title,
avatar=(
integration.avatar_url if integration.avatar_url is not None else ""
),
)
# Create an API Token for the bot user
api_token = APIToken.objects.create(
user=bot_user,
user_type=1, # bot user
workspace=workspace,
)
workspace_integration = WorkspaceIntegration.objects.create(
workspace=workspace,
integration=integration,
actor=bot_user,
api_token=api_token,
metadata=metadata,
config=config,
)
# Add bot user as a member of workspace
_ = WorkspaceMember.objects.create(
workspace=workspace_integration.workspace, member=bot_user, role=20
)
return Response(
WorkspaceIntegrationSerializer(workspace_integration).data,
status=status.HTTP_201_CREATED,
)
def destroy(self, request, slug, pk):
workspace_integration = WorkspaceIntegration.objects.get(
pk=pk, workspace__slug=slug
)
if workspace_integration.integration.provider == "github":
installation_id = workspace_integration.config.get("installation_id", False)
if installation_id:
delete_github_installation(installation_id=installation_id)
workspace_integration.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,187 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.views import BaseViewSet, BaseAPIView
from plane.db.models import (
GithubIssueSync,
GithubRepositorySync,
GithubRepository,
WorkspaceIntegration,
ProjectMember,
Label,
GithubCommentSync,
Project,
)
from plane.app.serializers import (
GithubIssueSyncSerializer,
GithubRepositorySyncSerializer,
GithubCommentSyncSerializer,
)
from plane.utils.integrations.github import get_github_repos
from plane.app.permissions import ProjectBasePermission, ProjectEntityPermission
class GithubRepositoriesEndpoint(BaseAPIView):
permission_classes = [ProjectBasePermission]
def get(self, request, slug, workspace_integration_id):
page = request.GET.get("page", 1)
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
)
if workspace_integration.integration.provider != "github":
return Response(
{"error": "Not a github integration"},
status=status.HTTP_400_BAD_REQUEST,
)
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
repositories_url = (
workspace_integration.metadata["repositories_url"]
+ f"?per_page=100&page={page}"
)
repositories = get_github_repos(access_tokens_url, repositories_url)
return Response(repositories, status=status.HTTP_200_OK)
class GithubRepositorySyncViewSet(BaseViewSet):
permission_classes = [ProjectBasePermission]
serializer_class = GithubRepositorySyncSerializer
model = GithubRepositorySync
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
def create(self, request, slug, project_id, workspace_integration_id):
name = request.data.get("name", False)
url = request.data.get("url", False)
config = request.data.get("config", {})
repository_id = request.data.get("repository_id", False)
owner = request.data.get("owner", False)
if not name or not url or not repository_id or not owner:
return Response(
{"error": "Name, url, repository_id and owner are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace integration
workspace_integration = WorkspaceIntegration.objects.get(
pk=workspace_integration_id
)
# Delete the old repository object
GithubRepositorySync.objects.filter(
project_id=project_id, workspace__slug=slug
).delete()
GithubRepository.objects.filter(
project_id=project_id, workspace__slug=slug
).delete()
# Create repository
repo = GithubRepository.objects.create(
name=name,
url=url,
config=config,
repository_id=repository_id,
owner=owner,
project_id=project_id,
)
# Create a Label for github
label = Label.objects.filter(name="GitHub", project_id=project_id).first()
if label is None:
label = Label.objects.create(
name="GitHub",
project_id=project_id,
description="Label to sync Plane issues with GitHub issues",
color="#003773",
)
# Create repo sync
repo_sync = GithubRepositorySync.objects.create(
repository=repo,
workspace_integration=workspace_integration,
actor=workspace_integration.actor,
credentials=request.data.get("credentials", {}),
project_id=project_id,
label=label,
)
# Add bot as a member in the project
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor, role=20, project_id=project_id
)
# Return Response
return Response(
GithubRepositorySyncSerializer(repo_sync).data,
status=status.HTTP_201_CREATED,
)
class GithubIssueSyncViewSet(BaseViewSet):
permission_classes = [ProjectEntityPermission]
serializer_class = GithubIssueSyncSerializer
model = GithubIssueSync
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
repository_sync_id=self.kwargs.get("repo_sync_id"),
)
class BulkCreateGithubIssueSyncEndpoint(BaseAPIView):
def post(self, request, slug, project_id, repo_sync_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
github_issue_syncs = request.data.get("github_issue_syncs", [])
github_issue_syncs = GithubIssueSync.objects.bulk_create(
[
GithubIssueSync(
issue_id=github_issue_sync.get("issue"),
repo_issue_id=github_issue_sync.get("repo_issue_id"),
issue_url=github_issue_sync.get("issue_url"),
github_issue_id=github_issue_sync.get("github_issue_id"),
repository_sync_id=repo_sync_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for github_issue_sync in github_issue_syncs
],
batch_size=100,
ignore_conflicts=True,
)
serializer = GithubIssueSyncSerializer(github_issue_syncs, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class GithubCommentSyncViewSet(BaseViewSet):
permission_classes = [ProjectEntityPermission]
serializer_class = GithubCommentSyncSerializer
model = GithubCommentSync
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
issue_sync_id=self.kwargs.get("issue_sync_id"),
)

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