Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3f5636ba |
@@ -16,48 +16,3 @@ out/
|
||||
**/out/
|
||||
dist/
|
||||
**/dist/
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor settings
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Coverage and test output
|
||||
coverage/
|
||||
**/coverage/
|
||||
*.lcov
|
||||
.junit/
|
||||
test-results/
|
||||
|
||||
# Caches and build artifacts
|
||||
.cache/
|
||||
**/.cache/
|
||||
storybook-static/
|
||||
*storybook.log
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local env and secrets
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.secrets
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Database/cache dumps
|
||||
*.rdb
|
||||
*.rdb.gz
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
@@ -15,15 +15,12 @@ RABBITMQ_USER="plane"
|
||||
RABBITMQ_PASSWORD="plane"
|
||||
RABBITMQ_VHOST="plane"
|
||||
|
||||
LISTEN_HTTP_PORT=80
|
||||
LISTEN_HTTPS_PORT=443
|
||||
|
||||
# AWS Settings
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
AWS_SECRET_ACCESS_KEY="secret-key"
|
||||
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
|
||||
# Changing this requires change in the proxy config for uploads if using minio setup
|
||||
# Changing this requires change in the nginx.conf for uploads if using minio setup
|
||||
AWS_S3_BUCKET_NAME="uploads"
|
||||
# Maximum file upload limit
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
@@ -39,15 +36,8 @@ DOCKERIZED=1 # deprecated
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
USE_MINIO=1
|
||||
|
||||
# If SSL Cert to be generated, set CERT_EMAIl="email <EMAIL_ADDRESS>"
|
||||
CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory
|
||||
TRUSTED_PROXIES=0.0.0.0/0
|
||||
SITE_ADDRESS=:80
|
||||
CERT_EMAIL=
|
||||
|
||||
# For DNS Challenge based certificate generation, set the CERT_ACME_DNS, CERT_EMAIL
|
||||
# CERT_ACME_DNS="acme_dns <CERT_DNS_PROVIDER> <CERT_DNS_PROVIDER_API_KEY>"
|
||||
CERT_ACME_DNS=
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
|
||||
# Force HTTPS for handling SSL Termination
|
||||
MINIO_ENDPOINT_SSL=0
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
name: Build AIO Base Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_tag_name:
|
||||
description: 'Base Tag Name'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.ref_name }}
|
||||
|
||||
jobs:
|
||||
base_build_setup:
|
||||
name: Build Preparation
|
||||
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 }}
|
||||
image_tag: ${{ steps.set_env_variables.outputs.IMAGE_TAG }}
|
||||
|
||||
steps:
|
||||
- id: set_env_variables
|
||||
name: Set Environment Variables
|
||||
run: |
|
||||
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
|
||||
echo "IMAGE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
echo "IMAGE_TAG=latest" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then
|
||||
echo "IMAGE_TAG=preview" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "IMAGE_TAG=develop" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; 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
|
||||
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
full_base_build_push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [base_build_setup]
|
||||
env:
|
||||
BASE_IMG_TAG: makeplane/plane-aio-base:full-${{ needs.base_build_setup.outputs.image_tag }}
|
||||
BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }}
|
||||
BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }}
|
||||
BUILDX_ENDPOINT: ${{ needs.base_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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: Build and Push to Docker Hub
|
||||
uses: docker/build-push-action@v6.9.0
|
||||
with:
|
||||
context: ./aio
|
||||
file: ./aio/Dockerfile-base-full
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.BASE_IMG_TAG }}
|
||||
push: true
|
||||
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_base_build_push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [base_build_setup]
|
||||
env:
|
||||
BASE_IMG_TAG: makeplane/plane-aio-base:slim-${{ needs.base_build_setup.outputs.image_tag }}
|
||||
BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }}
|
||||
BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }}
|
||||
BUILDX_ENDPOINT: ${{ needs.base_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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: Build and Push to Docker Hub
|
||||
uses: docker/build-push-action@v6.9.0
|
||||
with:
|
||||
context: ./aio
|
||||
file: ./aio/Dockerfile-base-slim
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.BASE_IMG_TAG }}
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -0,0 +1,207 @@
|
||||
name: Branch Build AIO
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full:
|
||||
description: 'Run full build'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
slim:
|
||||
description: 'Run slim build'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
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 }}
|
||||
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 }}
|
||||
flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }}
|
||||
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 [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; 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_name}}" == "workflow_dispatch" ] && [ "${{ 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
|
||||
|
||||
FLAT_BRANCH_NAME=$(echo "${{ env.TARGET_BRANCH }}" | sed 's/[^a-zA-Z0-9]/-/g')
|
||||
echo "FLAT_BRANCH_NAME=$FLAT_BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
- 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:full-${{ needs.branch_build_setup.outputs.flat_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:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }}
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-latest
|
||||
else
|
||||
TAG=${{ env.AIO_IMAGE_TAGS }}
|
||||
fi
|
||||
echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push to Docker Hub
|
||||
uses: docker/build-push-action@v6.9.0
|
||||
with:
|
||||
context: .
|
||||
file: ./aio/Dockerfile-app
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.AIO_IMAGE_TAGS }}
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_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:slim-${{ needs.branch_build_setup.outputs.flat_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:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }}
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-latest
|
||||
else
|
||||
TAG=${{ env.AIO_IMAGE_TAGS }}
|
||||
fi
|
||||
echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push to Docker Hub
|
||||
uses: docker/build-push-action@v6.9.0
|
||||
with:
|
||||
context: .
|
||||
file: ./aio/Dockerfile-app
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.AIO_IMAGE_TAGS }}
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_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 }}
|
||||
@@ -25,11 +25,6 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
aio_build:
|
||||
description: "Build for AIO docker image"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
push:
|
||||
branches:
|
||||
- preview
|
||||
@@ -41,7 +36,6 @@ env:
|
||||
BUILD_TYPE: ${{ github.event.inputs.build_type }}
|
||||
RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }}
|
||||
IS_PRERELEASE: ${{ github.event.inputs.isPrerelease }}
|
||||
AIO_BUILD: ${{ github.event.inputs.aio_build }}
|
||||
|
||||
jobs:
|
||||
branch_build_setup:
|
||||
@@ -60,13 +54,11 @@ jobs:
|
||||
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_aio: ${{ steps.set_env_variables.outputs.DH_IMG_AIO }}
|
||||
|
||||
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 }}
|
||||
aio_build: ${{ steps.set_env_variables.outputs.AIO_BUILD }}
|
||||
|
||||
steps:
|
||||
- id: set_env_variables
|
||||
@@ -92,15 +84,12 @@ jobs:
|
||||
echo "DH_IMG_LIVE=plane-live" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_BACKEND=plane-backend" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_PROXY=plane-proxy" >> $GITHUB_OUTPUT
|
||||
echo "DH_IMG_AIO=plane-aio-community" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "BUILD_TYPE=${{env.BUILD_TYPE}}" >> $GITHUB_OUTPUT
|
||||
BUILD_RELEASE=false
|
||||
BUILD_PRERELEASE=false
|
||||
RELVERSION="latest"
|
||||
|
||||
BUILD_AIO=${{ env.AIO_BUILD }}
|
||||
|
||||
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
|
||||
@@ -119,14 +108,10 @@ jobs:
|
||||
if [ "${{ env.IS_PRERELEASE }}" == "true" ]; then
|
||||
BUILD_PRERELEASE=true
|
||||
fi
|
||||
|
||||
BUILD_AIO=true
|
||||
fi
|
||||
|
||||
echo "BUILD_RELEASE=${BUILD_RELEASE}" >> $GITHUB_OUTPUT
|
||||
echo "BUILD_PRERELEASE=${BUILD_PRERELEASE}" >> $GITHUB_OUTPUT
|
||||
echo "RELEASE_VERSION=${RELVERSION}" >> $GITHUB_OUTPUT
|
||||
echo "AIO_BUILD=${BUILD_AIO}" >> $GITHUB_OUTPUT
|
||||
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
@@ -220,7 +205,7 @@ jobs:
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
|
||||
branch_build_push_api:
|
||||
branch_build_push_apiserver:
|
||||
name: Build-Push API Server Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
@@ -235,8 +220,8 @@ jobs:
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }}
|
||||
build-context: ./apps/api
|
||||
dockerfile-path: ./apps/api/Dockerfile.api
|
||||
build-context: ./apps/server
|
||||
dockerfile-path: ./apps/server/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 }}
|
||||
@@ -257,102 +242,13 @@ jobs:
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
|
||||
build-context: ./apps/proxy
|
||||
dockerfile-path: ./apps/proxy/Dockerfile.ce
|
||||
build-context: ./nginx
|
||||
dockerfile-path: ./nginx/Dockerfile
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
|
||||
branch_build_push_aio:
|
||||
if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }}
|
||||
name: Build-Push AIO Docker Image
|
||||
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_api,
|
||||
branch_build_push_proxy
|
||||
]
|
||||
steps:
|
||||
- name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare AIO Assets
|
||||
id: prepare_aio_assets
|
||||
run: |
|
||||
cd deployments/aio/community
|
||||
|
||||
if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then
|
||||
aio_version=${{ needs.branch_build_setup.outputs.release_version }}
|
||||
else
|
||||
aio_version=${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
fi
|
||||
bash ./build.sh --release $aio_version
|
||||
echo "AIO_BUILD_VERSION=${aio_version}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AIO Assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
path: ./deployments/aio/community/dist
|
||||
name: aio-assets-dist
|
||||
|
||||
- name: AIO Build and Push
|
||||
uses: makeplane/actions/build-push@v1.1.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_aio }}
|
||||
build-context: ./deployments/aio/community
|
||||
dockerfile-path: ./deployments/aio/community/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 }}
|
||||
additional-assets: aio-assets-dist
|
||||
additional-assets-dir: ./deployments/aio/community/dist
|
||||
build-args: |
|
||||
PLANE_VERSION=${{ steps.prepare_aio_assets.outputs.AIO_BUILD_VERSION }}
|
||||
|
||||
upload_build_assets:
|
||||
name: Upload Build Assets
|
||||
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_api, branch_build_push_proxy]
|
||||
steps:
|
||||
- name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update Assets
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then
|
||||
REL_VERSION=${{ needs.branch_build_setup.outputs.release_version }}
|
||||
else
|
||||
REL_VERSION=${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
fi
|
||||
|
||||
cp ./deployments/cli/community/install.sh deployments/cli/community/setup.sh
|
||||
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deployments/cli/community/docker-compose.yml
|
||||
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env
|
||||
|
||||
- name: Upload Assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: community-assets
|
||||
path: |
|
||||
./deployments/cli/community/setup.sh
|
||||
./deployments/cli/community/restore.sh
|
||||
./deployments/cli/community/restore-airgapped.sh
|
||||
./deployments/cli/community/docker-compose.yml
|
||||
./deployments/cli/community/variables.env
|
||||
./deployments/swarm/community/swarm.sh
|
||||
|
||||
publish_release:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Build Release
|
||||
@@ -364,7 +260,7 @@ jobs:
|
||||
branch_build_push_web,
|
||||
branch_build_push_space,
|
||||
branch_build_push_live,
|
||||
branch_build_push_api,
|
||||
branch_build_push_apiserver,
|
||||
branch_build_push_proxy,
|
||||
]
|
||||
env:
|
||||
@@ -375,9 +271,9 @@ jobs:
|
||||
|
||||
- name: Update Assets
|
||||
run: |
|
||||
cp ./deployments/cli/community/install.sh deployments/cli/community/setup.sh
|
||||
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deployments/cli/community/docker-compose.yml
|
||||
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env
|
||||
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
|
||||
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deploy/selfhost/docker-compose.yml
|
||||
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
@@ -391,10 +287,9 @@ jobs:
|
||||
prerelease: ${{ env.IS_PRERELEASE }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
${{ github.workspace }}/deployments/cli/community/setup.sh
|
||||
${{ github.workspace }}/deployments/cli/community/restore.sh
|
||||
${{ github.workspace }}/deployments/cli/community/restore-airgapped.sh
|
||||
${{ github.workspace }}/deployments/cli/community/docker-compose.yml
|
||||
${{ github.workspace }}/deployments/cli/community/variables.env
|
||||
${{ github.workspace }}/deployments/swarm/community/swarm.sh
|
||||
|
||||
${{ github.workspace }}/deploy/selfhost/setup.sh
|
||||
${{ github.workspace }}/deploy/selfhost/swarm.sh
|
||||
${{ github.workspace }}/deploy/selfhost/restore.sh
|
||||
${{ github.workspace }}/deploy/selfhost/restore-airgapped.sh
|
||||
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
|
||||
${{ github.workspace }}/deploy/selfhost/variables.env
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
name: Build and Lint on Pull Request
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: ["opened", "synchronize", "ready_for_review"]
|
||||
|
||||
jobs:
|
||||
lint-apiserver:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x" # Specify the Python version you need
|
||||
- name: Install Pylint
|
||||
run: python -m pip install ruff
|
||||
- name: Install Apiserver Dependencies
|
||||
run: cd apiserver && pip install -r requirements.txt
|
||||
- name: Lint apiserver
|
||||
run: ruff check --fix apiserver
|
||||
|
||||
lint-admin:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=admin
|
||||
|
||||
lint-space:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=space
|
||||
|
||||
lint-web:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=web
|
||||
|
||||
build-admin:
|
||||
needs: lint-admin
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=admin
|
||||
|
||||
build-space:
|
||||
needs: lint-space
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=space
|
||||
|
||||
build-web:
|
||||
needs: lint-web
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=web
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Build and lint API
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: ["preview"]
|
||||
types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"]
|
||||
paths:
|
||||
- "apps/api/**"
|
||||
|
||||
jobs:
|
||||
lint-api:
|
||||
name: Lint API
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
if: |
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.requested_reviewers != null
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Install Pylint
|
||||
run: python -m pip install ruff
|
||||
- name: Install API Dependencies
|
||||
run: cd apps/api && pip install -r requirements.txt
|
||||
- name: Lint apps/api
|
||||
run: ruff check --fix apps/api
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Build and lint web apps
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: ["preview"]
|
||||
types:
|
||||
[
|
||||
"opened",
|
||||
"synchronize",
|
||||
"ready_for_review",
|
||||
"review_requested",
|
||||
"reopened",
|
||||
]
|
||||
paths:
|
||||
- "**.tsx?"
|
||||
- "**.jsx?"
|
||||
- "**.css"
|
||||
- "**.json"
|
||||
- "!apps/api/**"
|
||||
|
||||
jobs:
|
||||
build-and-lint:
|
||||
name: Build and lint web apps
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
if: |
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.requested_reviewers != null
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Enable Corepack and pnpm
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint web apps
|
||||
run: pnpm run check:lint
|
||||
|
||||
- name: Check format
|
||||
run: pnpm run check:format
|
||||
|
||||
- name: Build apps
|
||||
run: pnpm run build
|
||||
@@ -24,13 +24,11 @@ out/
|
||||
.DS_Store
|
||||
*.pem
|
||||
.history
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Local env files
|
||||
@@ -62,7 +60,6 @@ node_modules/
|
||||
assets/dist/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
pnpm-debug.log
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
@@ -78,9 +75,10 @@ package-lock.json
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
|
||||
|
||||
|
||||
.npmrc
|
||||
.secrets
|
||||
tmp/
|
||||
|
||||
@@ -97,5 +95,3 @@ dev-editor
|
||||
# Redis
|
||||
*.rdb
|
||||
*.rdb.gz
|
||||
|
||||
storybook-static
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# Enforce pnpm workspace behavior and allow Turbo's lifecycle hooks if scripts are disabled
|
||||
# This repo uses pnpm with workspaces.
|
||||
|
||||
# Prefer linking local workspace packages when available
|
||||
prefer-workspace-packages=true
|
||||
link-workspace-packages=true
|
||||
shared-workspace-lockfile=true
|
||||
|
||||
# Make peer installs smoother across the monorepo
|
||||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
|
||||
# If scripts are disabled (e.g., CI with --ignore-scripts), allowlisted packages can still run their hooks
|
||||
# Turbo occasionally performs postinstall tasks for optimal performance
|
||||
# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo)
|
||||
|
||||
public-hoist-pattern[]=eslint
|
||||
public-hoist-pattern[]=prettier
|
||||
public-hoist-pattern[]=typescript
|
||||
|
||||
# Reproducible installs across CI and dev
|
||||
prefer-frozen-lockfile=true
|
||||
|
||||
# Prefer resolving to highest versions in monorepo to reduce duplication
|
||||
resolution-mode=highest
|
||||
|
||||
# Speed up native module builds by caching side effects
|
||||
side-effects-cache=true
|
||||
|
||||
# Speed up local dev by reusing local store when possible
|
||||
prefer-offline=true
|
||||
|
||||
# Ensure workspace protocol is used when adding internal deps
|
||||
save-workspace-protocol=true
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -25,7 +25,6 @@ When opening a new issue, please use a clear and concise title that follows this
|
||||
- For documentation: `📘 Docs: [short description]`
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `🐛 Bug: API token expiry time not saving correctly`
|
||||
- `📘 Docs: Clarify RAM requirement for local setup`
|
||||
- `🚀 Feature: Allow custom time selection for token expiration`
|
||||
@@ -48,7 +47,7 @@ This helps us triage and manage issues more efficiently.
|
||||
|
||||
The project is a monorepo, with backend api and frontend in a single repo.
|
||||
|
||||
The backend is a django project which is kept inside apps/api
|
||||
The backend is a django project which is kept inside apiserver
|
||||
|
||||
1. Clone the repo
|
||||
|
||||
@@ -73,7 +72,7 @@ docker compose -f docker-compose-local.yml up
|
||||
4. Start web apps:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
yarn dev
|
||||
```
|
||||
|
||||
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
|
||||
@@ -106,13 +105,11 @@ To ensure consistency throughout the source code, please keep these rules in min
|
||||
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
|
||||
|
||||
## Contributing to language support
|
||||
|
||||
This guide is designed to help contributors understand how to add or update translations in the application.
|
||||
|
||||
### Understanding translation structure
|
||||
|
||||
#### File organization
|
||||
|
||||
Translations are organized by language in the locales directory. Each language has its own folder containing JSON files for translations. Here's how it looks:
|
||||
|
||||
```
|
||||
@@ -125,9 +122,7 @@ packages/i18n/src/locales/
|
||||
└── [language]/
|
||||
└── translations.json
|
||||
```
|
||||
|
||||
#### Nested structure
|
||||
|
||||
To keep translations organized, we use a nested structure for keys. This makes it easier to manage and locate specific translations. For example:
|
||||
|
||||
```json
|
||||
@@ -142,37 +137,32 @@ To keep translations organized, we use a nested structure for keys. This makes i
|
||||
```
|
||||
|
||||
### Translation formatting guide
|
||||
|
||||
We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) to handle dynamic content, such as variables and pluralization. Here's how to format your translations:
|
||||
|
||||
#### Examples
|
||||
|
||||
- **Simple variables**
|
||||
|
||||
```json
|
||||
{
|
||||
```json
|
||||
{
|
||||
"greeting": "Hello, {name}!"
|
||||
}
|
||||
```
|
||||
}
|
||||
```
|
||||
|
||||
- **Pluralization**
|
||||
```json
|
||||
{
|
||||
```json
|
||||
{
|
||||
"items": "{count, plural, one {Work item} other {Work items}}"
|
||||
}
|
||||
```
|
||||
}
|
||||
```
|
||||
|
||||
### Contributing guidelines
|
||||
|
||||
#### Updating existing translations
|
||||
|
||||
1. Locate the key in `locales/<language>/translations.json`.
|
||||
|
||||
2. Update the value while ensuring the key structure remains intact.
|
||||
3. Preserve any existing ICU formats (e.g., variables, pluralization).
|
||||
|
||||
#### Adding new translation keys
|
||||
|
||||
1. When introducing a new key, ensure it is added to **all** language files, even if translations are not immediately available. Use English as a placeholder if needed.
|
||||
|
||||
2. Keep the nesting structure consistent across all languages.
|
||||
@@ -180,48 +170,48 @@ We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/)
|
||||
3. If the new key requires dynamic content (e.g., variables or pluralization), ensure the ICU format is applied uniformly across all languages.
|
||||
|
||||
### Adding new languages
|
||||
|
||||
Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully:
|
||||
|
||||
1. **Update type definitions**
|
||||
Add the new language to the TLanguage type in the language definitions file:
|
||||
1. **Update type definitions**
|
||||
Add the new language to the TLanguage type in the language definitions file:
|
||||
|
||||
```ts
|
||||
// packages/i18n/src/types/language.ts
|
||||
export type TLanguage = "en" | "fr" | "your-lang";
|
||||
```
|
||||
```typescript
|
||||
// types/language.ts
|
||||
export type TLanguage = "en" | "fr" | "your-lang";
|
||||
```
|
||||
|
||||
1. **Add language configuration**
|
||||
Include the new language in the list of supported languages:
|
||||
```ts
|
||||
// packages/i18n/src/constants/language.ts
|
||||
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Your Language", value: "your-lang" }
|
||||
];
|
||||
```
|
||||
2. **Add language configuration**
|
||||
Include the new language in the list of supported languages:
|
||||
|
||||
2. **Create translation files**
|
||||
1. Create a new folder for your language under locales (e.g., `locales/your-lang/`).
|
||||
```typescript
|
||||
// constants/language.ts
|
||||
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Your Language", value: "your-lang" }
|
||||
];
|
||||
```
|
||||
|
||||
3. **Create translation files**
|
||||
1. Create a new folder for your language under locales (e.g., `locales/your-lang/`).
|
||||
|
||||
2. Add a `translations.json` file inside the folder.
|
||||
|
||||
3. Copy the structure from an existing translation file and translate all keys.
|
||||
|
||||
3. **Update import logic**
|
||||
Modify the language import logic to include your new language:
|
||||
```ts
|
||||
private importLanguageFile(language: TLanguage): Promise<any> {
|
||||
switch (language) {
|
||||
case "your-lang":
|
||||
return import("../locales/your-lang/translations.json");
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
4. **Update import logic**
|
||||
Modify the language import logic to include your new language:
|
||||
|
||||
```typescript
|
||||
private importLanguageFile(language: TLanguage): Promise<any> {
|
||||
switch (language) {
|
||||
case "your-lang":
|
||||
return import("../locales/your-lang/translations.json");
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Quality checklist
|
||||
|
||||
Before submitting your contribution, please ensure the following:
|
||||
|
||||
- All translation keys exist in every language file.
|
||||
@@ -232,7 +222,6 @@ Before submitting your contribution, please ensure the following:
|
||||
- There are no missing or untranslated keys.
|
||||
|
||||
#### Pro tips
|
||||
|
||||
- When in doubt, refer to the English translations for context.
|
||||
- Verify pluralization works with different numbers.
|
||||
- Ensure dynamic values (e.g., `{name}`) are correctly interpolated.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Environment Variables
|
||||
|
||||
Environment variables are distributed in various files. Please refer them carefully.
|
||||
|
||||
## {PROJECT_FOLDER}/.env
|
||||
|
||||
File is available in the project root folder
|
||||
|
||||
```
|
||||
# Database Settings
|
||||
POSTGRES_USER="plane"
|
||||
POSTGRES_PASSWORD="plane"
|
||||
POSTGRES_DB="plane"
|
||||
PGDATA="/var/lib/postgresql/data"
|
||||
# Redis Settings
|
||||
REDIS_HOST="plane-redis"
|
||||
REDIS_PORT="6379"
|
||||
# AWS Settings
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
AWS_SECRET_ACCESS_KEY="secret-key"
|
||||
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
|
||||
# Changing this requires change in the nginx.conf for uploads if using minio setup
|
||||
AWS_S3_BUCKET_NAME="uploads"
|
||||
# Maximum file upload limit
|
||||
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
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1 # deprecated
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
USE_MINIO=1
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
```
|
||||
|
||||
## {PROJECT_FOLDER}/apiserver/.env
|
||||
|
||||
```
|
||||
# Backend
|
||||
# Debug value for api server use it as 0 for production use
|
||||
DEBUG=0
|
||||
CORS_ALLOWED_ORIGINS="http://localhost"
|
||||
# Database Settings
|
||||
POSTGRES_USER="plane"
|
||||
POSTGRES_PASSWORD="plane"
|
||||
POSTGRES_HOST="plane-db"
|
||||
POSTGRES_DB="plane"
|
||||
POSTGRES_PORT=5432
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
# Redis Settings
|
||||
REDIS_HOST="plane-redis"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_URL="redis://${REDIS_HOST}:6379/"
|
||||
# AWS Settings
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
AWS_SECRET_ACCESS_KEY="secret-key"
|
||||
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
|
||||
# Changing this requires change in the nginx.conf for uploads if using minio setup
|
||||
AWS_S3_BUCKET_NAME="uploads"
|
||||
# Maximum file upload limit
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1 # deprecated
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
USE_MINIO=1
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
# Email redirections and minio domain settings
|
||||
WEB_URL="http://localhost"
|
||||
# Gunicorn Workers
|
||||
GUNICORN_WORKERS=2
|
||||
# Base URLs
|
||||
ADMIN_BASE_URL=
|
||||
SPACE_BASE_URL=
|
||||
APP_BASE_URL=
|
||||
SECRET_KEY="gxoytl7dmnc1y37zahah820z5iq3iozu38cnfjtu3yaau9cd9z"
|
||||
```
|
||||
|
||||
## Updates
|
||||
|
||||
- The naming convention for containers and images has been updated.
|
||||
- The plane-worker image will no longer be maintained, as it has been merged with plane-backend.
|
||||
- The Tiptap pro-extension dependency has been removed, eliminating the need for Tiptap API keys.
|
||||
- The image name for Plane deployment has been changed to plane-space.
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://plane.so">
|
||||
<img src="https://media.docs.plane.so/logo/plane_github_readme.png" alt="Plane Logo" width="400">
|
||||
<img src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_logo_.webp" alt="Plane Logo" width="70">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center"><b>Modern project management for all teams</b></p>
|
||||
<h1 align="center"><b>Plane</b></h1>
|
||||
<p align="center"><b>Open-source project management that unlocks customer value</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.com/invite/A92xrEGCge">
|
||||
@@ -24,7 +25,14 @@
|
||||
<p>
|
||||
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
|
||||
<img
|
||||
src="https://media.docs.plane.so/GitHub-readme/github-top.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_screen.webp"
|
||||
alt="Plane Screens"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_screens_dark_mode.webp"
|
||||
alt="Plane Screens"
|
||||
width="100%"
|
||||
/>
|
||||
@@ -40,13 +48,13 @@ Meet [Plane](https://plane.so/), an open-source project management tool to track
|
||||
Getting started with Plane is simple. Choose the setup that works best for you:
|
||||
|
||||
- **Plane Cloud**
|
||||
Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure.
|
||||
Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure.
|
||||
|
||||
- **Self-host Plane**
|
||||
Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started.
|
||||
Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started.
|
||||
|
||||
| Installation methods | Docs link |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Installation methods | Docs link |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Docker | [](https://developers.plane.so/self-hosting/methods/docker-compose) |
|
||||
| Kubernetes | [](https://developers.plane.so/self-hosting/methods/kubernetes) |
|
||||
|
||||
@@ -55,50 +63,50 @@ Getting started with Plane is simple. Choose the setup that works best for you:
|
||||
## 🌟 Features
|
||||
|
||||
- **Issues**
|
||||
Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues.
|
||||
Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues.
|
||||
|
||||
- **Cycles**
|
||||
Maintain your team’s momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
|
||||
Maintain your team’s momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
|
||||
|
||||
- **Modules**
|
||||
Simplify complex projects by dividing them into smaller, manageable modules.
|
||||
Simplify complex projects by dividing them into smaller, manageable modules.
|
||||
|
||||
- **Views**
|
||||
Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease.
|
||||
Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease.
|
||||
|
||||
- **Pages**
|
||||
Capture and organize ideas using Plane Pages, complete with AI capabilities and a rich text editor. Format text, insert images, add hyperlinks, or convert your notes into actionable items.
|
||||
Capture and organize ideas using Plane Pages, complete with AI capabilities and a rich text editor. Format text, insert images, add hyperlinks, or convert your notes into actionable items.
|
||||
|
||||
- **Analytics**
|
||||
Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward.
|
||||
Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward.
|
||||
|
||||
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
|
||||
|
||||
|
||||
## 🛠️ Local development
|
||||
|
||||
See [CONTRIBUTING](./CONTRIBUTING.md)
|
||||
|
||||
## ⚙️ Built with
|
||||
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.djangoproject.com/)
|
||||
[](https://nodejs.org/en)
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://media.docs.plane.so/GitHub-readme/github-work-items.webp"
|
||||
src="https://ik.imagekit.io/w2okwbtu2/Issues_rNZjrGgFl.png?updatedAt=1709298765880"
|
||||
alt="Plane Views"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://media.docs.plane.so/GitHub-readme/github-cycles.webp"
|
||||
src="https://ik.imagekit.io/w2okwbtu2/Cycles_jCDhqmTl9.png?updatedAt=1709298780697"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
@@ -106,7 +114,7 @@ See [CONTRIBUTING](./CONTRIBUTING.md)
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://media.docs.plane.so/GitHub-readme/github-modules.webp"
|
||||
src="https://ik.imagekit.io/w2okwbtu2/Modules_PSCVsbSfI.png?updatedAt=1709298796783"
|
||||
alt="Plane Cycles and Modules"
|
||||
width="100%"
|
||||
/>
|
||||
@@ -115,7 +123,7 @@ See [CONTRIBUTING](./CONTRIBUTING.md)
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://media.docs.plane.so/GitHub-readme/github-views.webp"
|
||||
src="https://ik.imagekit.io/w2okwbtu2/Views_uxXsRatS4.png?updatedAt=1709298834522"
|
||||
alt="Plane Analytics"
|
||||
width="100%"
|
||||
/>
|
||||
@@ -124,16 +132,25 @@ See [CONTRIBUTING](./CONTRIBUTING.md)
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://media.docs.plane.so/GitHub-readme/github-analytics.webp"
|
||||
src="https://ik.imagekit.io/w2okwbtu2/Analytics_0o22gLRtp.png?updatedAt=1709298834389"
|
||||
alt="Plane Pages"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/w2okwbtu2/Drive_LlfeY4xn3.png?updatedAt=1709298837917"
|
||||
alt="Plane Command Menu"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
## 📝 Documentation
|
||||
|
||||
Explore Plane's [product documentation](https://docs.plane.so/) and [developer documentation](https://developers.plane.so/) to learn about features, setup, and usage.
|
||||
|
||||
## ❤️ Community
|
||||
@@ -169,6 +186,6 @@ Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CON
|
||||
<img src="https://contrib.rocks/image?repo=makeplane/plane" />
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
## License
|
||||
This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt).
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
ARG BASE_TAG=develop
|
||||
ARG BUILD_TYPE=full
|
||||
# *****************************************************************************
|
||||
# STAGE 1: Build the project
|
||||
# *****************************************************************************
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune --scope=web --scope=space --scope=admin --docker
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 2: Install dependencies & build the project
|
||||
# *****************************************************************************
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install
|
||||
|
||||
# # Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_WEB_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN yarn turbo run build --filter=web --filter=space --filter=admin
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 3: Copy the project and start it
|
||||
# *****************************************************************************
|
||||
FROM makeplane/plane-aio-base:${BUILD_TYPE}-${BASE_TAG} AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
SHELL [ "/bin/bash", "-c" ]
|
||||
|
||||
# PYTHON APPLICATION SETUP
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
COPY apiserver/requirements.txt ./api/
|
||||
COPY apiserver/requirements ./api/requirements
|
||||
|
||||
RUN pip install -r ./api/requirements.txt --compile --no-cache-dir
|
||||
|
||||
# Add in Django deps and generate Django's static files
|
||||
COPY apiserver/manage.py ./api/manage.py
|
||||
COPY apiserver/plane ./api/plane/
|
||||
COPY apiserver/templates ./api/templates/
|
||||
COPY package.json ./api/package.json
|
||||
|
||||
COPY apiserver/bin ./api/bin/
|
||||
|
||||
RUN chmod +x ./api/bin/*
|
||||
RUN chmod -R 777 ./api/
|
||||
|
||||
# NEXTJS BUILDS
|
||||
COPY --from=installer /app/web/next.config.js ./web/
|
||||
COPY --from=installer /app/web/package.json ./web/
|
||||
COPY --from=installer /app/web/.next/standalone ./web
|
||||
COPY --from=installer /app/web/.next/static ./web/web/.next/static
|
||||
COPY --from=installer /app/web/public ./web/web/public
|
||||
|
||||
COPY --from=installer /app/space/next.config.js ./space/
|
||||
COPY --from=installer /app/space/package.json ./space/
|
||||
COPY --from=installer /app/space/.next/standalone ./space
|
||||
COPY --from=installer /app/space/.next/static ./space/space/.next/static
|
||||
COPY --from=installer /app/space/public ./space/space/public
|
||||
|
||||
COPY --from=installer /app/admin/next.config.js ./admin/
|
||||
COPY --from=installer /app/admin/package.json ./admin/
|
||||
COPY --from=installer /app/admin/.next/standalone ./admin
|
||||
COPY --from=installer /app/admin/.next/static ./admin/admin/.next/static
|
||||
COPY --from=installer /app/admin/public ./admin/admin/public
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_WEB_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
ARG BUILD_TYPE=full
|
||||
ENV BUILD_TYPE=$BUILD_TYPE
|
||||
|
||||
COPY aio/supervisord-${BUILD_TYPE}-base /app/supervisord.conf
|
||||
COPY aio/supervisord-app /app/supervisord-app
|
||||
RUN cat /app/supervisord-app >> /app/supervisord.conf && \
|
||||
rm /app/supervisord-app
|
||||
|
||||
COPY ./aio/nginx.conf /etc/nginx/nginx.conf.template
|
||||
|
||||
# if build type is full, run the below copy pg-setup.sh
|
||||
COPY aio/postgresql.conf /etc/postgresql/postgresql.conf
|
||||
COPY aio/pg-setup.sh /app/pg-setup.sh
|
||||
RUN chmod +x /app/pg-setup.sh
|
||||
|
||||
# *****************************************************************************
|
||||
# APPLICATION ENVIRONMENT SETTINGS
|
||||
# *****************************************************************************
|
||||
ENV APP_DOMAIN=localhost
|
||||
ENV WEB_URL=http://${APP_DOMAIN}
|
||||
ENV DEBUG=0
|
||||
ENV CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN}
|
||||
# Secret Key
|
||||
ENV SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
|
||||
# Gunicorn Workers
|
||||
ENV GUNICORN_WORKERS=1
|
||||
|
||||
ENV POSTGRES_USER="plane"
|
||||
ENV POSTGRES_PASSWORD="plane"
|
||||
ENV POSTGRES_DB="plane"
|
||||
ENV POSTGRES_HOST="localhost"
|
||||
ENV POSTGRES_PORT="5432"
|
||||
ENV DATABASE_URL="postgresql://plane:plane@localhost:5432/plane"
|
||||
|
||||
ENV REDIS_HOST="localhost"
|
||||
ENV REDIS_PORT="6379"
|
||||
ENV REDIS_URL="redis://localhost:6379"
|
||||
|
||||
ENV USE_MINIO="1"
|
||||
ENV AWS_REGION=""
|
||||
ENV AWS_ACCESS_KEY_ID="access-key"
|
||||
ENV AWS_SECRET_ACCESS_KEY="secret-key"
|
||||
ENV AWS_S3_ENDPOINT_URL="http://localhost:9000"
|
||||
ENV AWS_S3_BUCKET_NAME="uploads"
|
||||
ENV MINIO_ROOT_USER="access-key"
|
||||
ENV MINIO_ROOT_PASSWORD="secret-key"
|
||||
ENV BUCKET_NAME="uploads"
|
||||
ENV FILE_SIZE_LIMIT="5242880"
|
||||
|
||||
# *****************************************************************************
|
||||
|
||||
RUN /app/pg-setup.sh
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"]
|
||||
@@ -0,0 +1,73 @@
|
||||
FROM --platform=$BUILDPLATFORM tonistiigi/binfmt AS binfmt
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Set environment variables to non-interactive for apt
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV BUILD_TYPE=full
|
||||
|
||||
SHELL [ "/bin/bash", "-c" ]
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p /app/{data,logs} && \
|
||||
mkdir -p /app/data/{redis,pg,minio,nginx} && \
|
||||
mkdir -p /app/logs/{access,error} && \
|
||||
mkdir -p /etc/supervisor/conf.d
|
||||
|
||||
# Update the package list and install prerequisites
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
gnupg2 curl ca-certificates lsb-release software-properties-common \
|
||||
build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
|
||||
libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev xz-utils \
|
||||
tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu \
|
||||
sudo lsof net-tools libpq-dev procps gettext
|
||||
|
||||
# Install Redis 7.2
|
||||
RUN echo "deb http://deb.debian.org/debian $(lsb_release -cs)-backports main" > /etc/apt/sources.list.d/backports.list && \
|
||||
curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" > /etc/apt/sources.list.d/redis.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y redis-server
|
||||
|
||||
# Install PostgreSQL 15
|
||||
ENV POSTGRES_VERSION=15
|
||||
RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg-archive-keyring.gpg && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/pgdg-archive-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y postgresql-$POSTGRES_VERSION postgresql-client-$POSTGRES_VERSION && \
|
||||
mkdir -p /var/lib/postgresql/data && \
|
||||
chown -R postgres:postgres /var/lib/postgresql
|
||||
COPY postgresql.conf /etc/postgresql/postgresql.conf
|
||||
RUN sudo -u postgres /usr/lib/postgresql/$POSTGRES_VERSION/bin/initdb -D /var/lib/postgresql/data
|
||||
|
||||
# Install MinIO
|
||||
ARG TARGETARCH
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
curl -fSl https://dl.min.io/server/minio/release/linux-amd64/minio -o /usr/local/bin/minio; \
|
||||
elif [ "$TARGETARCH" = "arm64" ]; then \
|
||||
curl -fSl https://dl.min.io/server/minio/release/linux-arm64/minio -o /usr/local/bin/minio; \
|
||||
else \
|
||||
echo "Unsupported architecture: $TARGETARCH"; exit 1; \
|
||||
fi && \
|
||||
chmod +x /usr/local/bin/minio
|
||||
|
||||
# Install Node.js 18
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
python -m pip install --upgrade pip && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create Supervisor configuration file
|
||||
COPY supervisord-full-base /app/supervisord.conf
|
||||
COPY nginx.conf /etc/nginx/nginx.conf.template
|
||||
COPY env.sh /app/nginx-start.sh
|
||||
RUN chmod +x /app/nginx-start.sh
|
||||
|
||||
# Expose ports for Redis, PostgreSQL, and MinIO
|
||||
EXPOSE 6379 5432 9000 80 443
|
||||
|
||||
# Start Supervisor
|
||||
CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"]
|
||||
@@ -0,0 +1,45 @@
|
||||
FROM --platform=$BUILDPLATFORM tonistiigi/binfmt AS binfmt
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Set environment variables to non-interactive for apt
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV BUILD_TYPE=slim
|
||||
|
||||
SHELL [ "/bin/bash", "-c" ]
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p /app/{data,logs} && \
|
||||
mkdir -p /app/data/{nginx} && \
|
||||
mkdir -p /app/logs/{access,error} && \
|
||||
mkdir -p /etc/supervisor/conf.d
|
||||
|
||||
# Update the package list and install prerequisites
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
gnupg2 curl ca-certificates lsb-release software-properties-common \
|
||||
build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
|
||||
libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev xz-utils \
|
||||
tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu \
|
||||
sudo lsof net-tools libpq-dev procps gettext
|
||||
|
||||
# Install Node.js 18
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
|
||||
apt-get install -y nodejs
|
||||
|
||||
RUN python -m pip install --upgrade pip && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create Supervisor configuration file
|
||||
COPY supervisord-slim-base /app/supervisord.conf
|
||||
COPY nginx.conf /etc/nginx/nginx.conf.template
|
||||
COPY env.sh /app/nginx-start.sh
|
||||
RUN chmod +x /app/nginx-start.sh
|
||||
|
||||
# Expose ports for Redis, PostgreSQL, and MinIO
|
||||
EXPOSE 80 443
|
||||
|
||||
# Start Supervisor
|
||||
CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"]
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
export dollar="$"
|
||||
export http_upgrade="http_upgrade"
|
||||
export scheme="scheme"
|
||||
envsubst < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
|
||||
exec nginx -g 'daemon off;'
|
||||
@@ -0,0 +1,72 @@
|
||||
events {
|
||||
}
|
||||
|
||||
http {
|
||||
sendfile on;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
root /www/data/;
|
||||
access_log /var/log/nginx/access.log;
|
||||
|
||||
client_max_body_size ${FILE_SIZE_LIMIT};
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Permissions-Policy "interest-cohort=()" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Forwarded-Proto "${dollar}scheme";
|
||||
add_header X-Forwarded-Host "${dollar}host";
|
||||
add_header X-Forwarded-For "${dollar}proxy_add_x_forwarded_for";
|
||||
add_header X-Real-IP "${dollar}remote_addr";
|
||||
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host ${dollar}http_host;
|
||||
proxy_pass http://localhost:3001/;
|
||||
}
|
||||
|
||||
location /spaces/ {
|
||||
rewrite ^/spaces/?$ /spaces/login break;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host ${dollar}http_host;
|
||||
proxy_pass http://localhost:3002/spaces/;
|
||||
}
|
||||
|
||||
location /god-mode/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host ${dollar}http_host;
|
||||
proxy_pass http://localhost:3003/god-mode/;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host ${dollar}http_host;
|
||||
proxy_pass http://localhost:8000/api/;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host ${dollar}http_host;
|
||||
proxy_pass http://localhost:8000/auth/;
|
||||
}
|
||||
|
||||
location /${BUCKET_NAME}/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host ${dollar}http_host;
|
||||
proxy_pass http://localhost:9000/uploads/;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ "$BUILD_TYPE" == "full" ]; then
|
||||
|
||||
export PGHOST=localhost
|
||||
|
||||
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data start
|
||||
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "CREATE USER $POSTGRES_USER WITH SUPERUSER PASSWORD '$POSTGRES_PASSWORD';" && \
|
||||
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/createdb" -O "$POSTGRES_USER" "$POSTGRES_DB" && \
|
||||
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;" && \
|
||||
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data stop
|
||||
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,815 @@
|
||||
# -----------------------------
|
||||
# PostgreSQL configuration file
|
||||
# -----------------------------
|
||||
#
|
||||
# This file consists of lines of the form:
|
||||
#
|
||||
# name = value
|
||||
#
|
||||
# (The "=" is optional.) Whitespace may be used. Comments are introduced with
|
||||
# "#" anywhere on a line. The complete list of parameter names and allowed
|
||||
# values can be found in the PostgreSQL documentation.
|
||||
#
|
||||
# The commented-out settings shown in this file represent the default values.
|
||||
# Re-commenting a setting is NOT sufficient to revert it to the default value;
|
||||
# you need to reload the server.
|
||||
#
|
||||
# This file is read on server startup and when the server receives a SIGHUP
|
||||
# signal. If you edit the file on a running system, you have to SIGHUP the
|
||||
# server for the changes to take effect, run "pg_ctl reload", or execute
|
||||
# "SELECT pg_reload_conf()". Some parameters, which are marked below,
|
||||
# require a server shutdown and restart to take effect.
|
||||
#
|
||||
# Any parameter can also be given as a command-line option to the server, e.g.,
|
||||
# "postgres -c log_connections=on". Some parameters can be changed at run time
|
||||
# with the "SET" SQL command.
|
||||
#
|
||||
# Memory units: B = bytes Time units: us = microseconds
|
||||
# kB = kilobytes ms = milliseconds
|
||||
# MB = megabytes s = seconds
|
||||
# GB = gigabytes min = minutes
|
||||
# TB = terabytes h = hours
|
||||
# d = days
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# FILE LOCATIONS
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# The default values of these variables are driven from the -D command-line
|
||||
# option or PGDATA environment variable, represented here as ConfigDir.
|
||||
|
||||
data_directory = '/var/lib/postgresql/data' # use data in another directory
|
||||
# (change requires restart)
|
||||
hba_file = '/etc/postgresql/15/main/pg_hba.conf' # host-based authentication file
|
||||
# (change requires restart)
|
||||
ident_file = '/etc/postgresql/15/main/pg_ident.conf' # ident configuration file
|
||||
# (change requires restart)
|
||||
|
||||
# If external_pid_file is not explicitly set, no extra PID file is written.
|
||||
external_pid_file = '/var/run/postgresql/15-main.pid' # write an extra PID file
|
||||
# (change requires restart)
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# CONNECTIONS AND AUTHENTICATION
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# - Connection Settings -
|
||||
|
||||
listen_addresses = 'localhost' # what IP address(es) to listen on;
|
||||
# comma-separated list of addresses;
|
||||
# defaults to 'localhost'; use '*' for all
|
||||
# (change requires restart)
|
||||
port = 5432 # (change requires restart)
|
||||
max_connections = 200 # (change requires restart)
|
||||
#superuser_reserved_connections = 3 # (change requires restart)
|
||||
unix_socket_directories = '/var/run/postgresql' # comma-separated list of directories
|
||||
# (change requires restart)
|
||||
#unix_socket_group = '' # (change requires restart)
|
||||
#unix_socket_permissions = 0777 # begin with 0 to use octal notation
|
||||
# (change requires restart)
|
||||
#bonjour = off # advertise server via Bonjour
|
||||
# (change requires restart)
|
||||
#bonjour_name = '' # defaults to the computer name
|
||||
# (change requires restart)
|
||||
|
||||
# - TCP settings -
|
||||
# see "man tcp" for details
|
||||
|
||||
#tcp_keepalives_idle = 0 # TCP_KEEPIDLE, in seconds;
|
||||
# 0 selects the system default
|
||||
#tcp_keepalives_interval = 0 # TCP_KEEPINTVL, in seconds;
|
||||
# 0 selects the system default
|
||||
#tcp_keepalives_count = 0 # TCP_KEEPCNT;
|
||||
# 0 selects the system default
|
||||
#tcp_user_timeout = 0 # TCP_USER_TIMEOUT, in milliseconds;
|
||||
# 0 selects the system default
|
||||
|
||||
#client_connection_check_interval = 0 # time between checks for client
|
||||
# disconnection while running queries;
|
||||
# 0 for never
|
||||
|
||||
# - Authentication -
|
||||
|
||||
#authentication_timeout = 1min # 1s-600s
|
||||
#password_encryption = scram-sha-256 # scram-sha-256 or md5
|
||||
#db_user_namespace = off
|
||||
|
||||
# GSSAPI using Kerberos
|
||||
#krb_server_keyfile = 'FILE:${sysconfdir}/krb5.keytab'
|
||||
#krb_caseins_users = off
|
||||
|
||||
# - SSL -
|
||||
|
||||
ssl = on
|
||||
#ssl_ca_file = ''
|
||||
ssl_cert_file = '/etc/ssl/certs/ssl-cert-snakeoil.pem'
|
||||
#ssl_crl_file = ''
|
||||
#ssl_crl_dir = ''
|
||||
ssl_key_file = '/etc/ssl/private/ssl-cert-snakeoil.key'
|
||||
#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers
|
||||
#ssl_prefer_server_ciphers = on
|
||||
#ssl_ecdh_curve = 'prime256v1'
|
||||
#ssl_min_protocol_version = 'TLSv1.2'
|
||||
#ssl_max_protocol_version = ''
|
||||
#ssl_dh_params_file = ''
|
||||
#ssl_passphrase_command = ''
|
||||
#ssl_passphrase_command_supports_reload = off
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# RESOURCE USAGE (except WAL)
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# - Memory -
|
||||
|
||||
shared_buffers = 256MB # min 128kB
|
||||
# (change requires restart)
|
||||
#huge_pages = try # on, off, or try
|
||||
# (change requires restart)
|
||||
#huge_page_size = 0 # zero for system default
|
||||
# (change requires restart)
|
||||
#temp_buffers = 8MB # min 800kB
|
||||
#max_prepared_transactions = 0 # zero disables the feature
|
||||
# (change requires restart)
|
||||
# Caution: it is not advisable to set max_prepared_transactions nonzero unless
|
||||
# you actively intend to use prepared transactions.
|
||||
#work_mem = 4MB # min 64kB
|
||||
#hash_mem_multiplier = 2.0 # 1-1000.0 multiplier on hash table work_mem
|
||||
#maintenance_work_mem = 64MB # min 1MB
|
||||
#autovacuum_work_mem = -1 # min 1MB, or -1 to use maintenance_work_mem
|
||||
#logical_decoding_work_mem = 64MB # min 64kB
|
||||
#max_stack_depth = 2MB # min 100kB
|
||||
#shared_memory_type = mmap # the default is the first option
|
||||
# supported by the operating system:
|
||||
# mmap
|
||||
# sysv
|
||||
# windows
|
||||
# (change requires restart)
|
||||
dynamic_shared_memory_type = posix # the default is usually the first option
|
||||
# supported by the operating system:
|
||||
# posix
|
||||
# sysv
|
||||
# windows
|
||||
# mmap
|
||||
# (change requires restart)
|
||||
#min_dynamic_shared_memory = 0MB # (change requires restart)
|
||||
|
||||
# - Disk -
|
||||
|
||||
#temp_file_limit = -1 # limits per-process temp file space
|
||||
# in kilobytes, or -1 for no limit
|
||||
|
||||
# - Kernel Resources -
|
||||
|
||||
#max_files_per_process = 1000 # min 64
|
||||
# (change requires restart)
|
||||
|
||||
# - Cost-Based Vacuum Delay -
|
||||
|
||||
#vacuum_cost_delay = 0 # 0-100 milliseconds (0 disables)
|
||||
#vacuum_cost_page_hit = 1 # 0-10000 credits
|
||||
#vacuum_cost_page_miss = 2 # 0-10000 credits
|
||||
#vacuum_cost_page_dirty = 20 # 0-10000 credits
|
||||
#vacuum_cost_limit = 200 # 1-10000 credits
|
||||
|
||||
# - Background Writer -
|
||||
|
||||
#bgwriter_delay = 200ms # 10-10000ms between rounds
|
||||
#bgwriter_lru_maxpages = 100 # max buffers written/round, 0 disables
|
||||
#bgwriter_lru_multiplier = 2.0 # 0-10.0 multiplier on buffers scanned/round
|
||||
#bgwriter_flush_after = 512kB # measured in pages, 0 disables
|
||||
|
||||
# - Asynchronous Behavior -
|
||||
|
||||
#backend_flush_after = 0 # measured in pages, 0 disables
|
||||
#effective_io_concurrency = 1 # 1-1000; 0 disables prefetching
|
||||
#maintenance_io_concurrency = 10 # 1-1000; 0 disables prefetching
|
||||
#max_worker_processes = 8 # (change requires restart)
|
||||
#max_parallel_workers_per_gather = 2 # limited by max_parallel_workers
|
||||
#max_parallel_maintenance_workers = 2 # limited by max_parallel_workers
|
||||
#max_parallel_workers = 8 # number of max_worker_processes that
|
||||
# can be used in parallel operations
|
||||
#parallel_leader_participation = on
|
||||
#old_snapshot_threshold = -1 # 1min-60d; -1 disables; 0 is immediate
|
||||
# (change requires restart)
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# WRITE-AHEAD LOG
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# - Settings -
|
||||
|
||||
#wal_level = replica # minimal, replica, or logical
|
||||
# (change requires restart)
|
||||
#fsync = on # flush data to disk for crash safety
|
||||
# (turning this off can cause
|
||||
# unrecoverable data corruption)
|
||||
#synchronous_commit = on # synchronization level;
|
||||
# off, local, remote_write, remote_apply, or on
|
||||
#wal_sync_method = fsync # the default is the first option
|
||||
# supported by the operating system:
|
||||
# open_datasync
|
||||
# fdatasync (default on Linux and FreeBSD)
|
||||
# fsync
|
||||
# fsync_writethrough
|
||||
# open_sync
|
||||
#full_page_writes = on # recover from partial page writes
|
||||
#wal_log_hints = off # also do full page writes of non-critical updates
|
||||
# (change requires restart)
|
||||
#wal_compression = off # enables compression of full-page writes;
|
||||
# off, pglz, lz4, zstd, or on
|
||||
#wal_init_zero = on # zero-fill new WAL files
|
||||
#wal_recycle = on # recycle WAL files
|
||||
#wal_buffers = -1 # min 32kB, -1 sets based on shared_buffers
|
||||
# (change requires restart)
|
||||
#wal_writer_delay = 200ms # 1-10000 milliseconds
|
||||
#wal_writer_flush_after = 1MB # measured in pages, 0 disables
|
||||
#wal_skip_threshold = 2MB
|
||||
|
||||
#commit_delay = 0 # range 0-100000, in microseconds
|
||||
#commit_siblings = 5 # range 1-1000
|
||||
|
||||
# - Checkpoints -
|
||||
|
||||
#checkpoint_timeout = 5min # range 30s-1d
|
||||
#checkpoint_completion_target = 0.9 # checkpoint target duration, 0.0 - 1.0
|
||||
#checkpoint_flush_after = 256kB # measured in pages, 0 disables
|
||||
#checkpoint_warning = 30s # 0 disables
|
||||
max_wal_size = 1GB
|
||||
min_wal_size = 80MB
|
||||
|
||||
# - Prefetching during recovery -
|
||||
|
||||
#recovery_prefetch = try # prefetch pages referenced in the WAL?
|
||||
#wal_decode_buffer_size = 512kB # lookahead window used for prefetching
|
||||
# (change requires restart)
|
||||
|
||||
# - Archiving -
|
||||
|
||||
#archive_mode = off # enables archiving; off, on, or always
|
||||
# (change requires restart)
|
||||
#archive_library = '' # library to use to archive a logfile segment
|
||||
# (empty string indicates archive_command should
|
||||
# be used)
|
||||
#archive_command = '' # command to use to archive a logfile segment
|
||||
# placeholders: %p = path of file to archive
|
||||
# %f = file name only
|
||||
# e.g. 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f'
|
||||
#archive_timeout = 0 # force a logfile segment switch after this
|
||||
# number of seconds; 0 disables
|
||||
|
||||
# - Archive Recovery -
|
||||
|
||||
# These are only used in recovery mode.
|
||||
|
||||
#restore_command = '' # command to use to restore an archived logfile segment
|
||||
# placeholders: %p = path of file to restore
|
||||
# %f = file name only
|
||||
# e.g. 'cp /mnt/server/archivedir/%f %p'
|
||||
#archive_cleanup_command = '' # command to execute at every restartpoint
|
||||
#recovery_end_command = '' # command to execute at completion of recovery
|
||||
|
||||
# - Recovery Target -
|
||||
|
||||
# Set these only when performing a targeted recovery.
|
||||
|
||||
#recovery_target = '' # 'immediate' to end recovery as soon as a
|
||||
# consistent state is reached
|
||||
# (change requires restart)
|
||||
#recovery_target_name = '' # the named restore point to which recovery will proceed
|
||||
# (change requires restart)
|
||||
#recovery_target_time = '' # the time stamp up to which recovery will proceed
|
||||
# (change requires restart)
|
||||
#recovery_target_xid = '' # the transaction ID up to which recovery will proceed
|
||||
# (change requires restart)
|
||||
#recovery_target_lsn = '' # the WAL LSN up to which recovery will proceed
|
||||
# (change requires restart)
|
||||
#recovery_target_inclusive = on # Specifies whether to stop:
|
||||
# just after the specified recovery target (on)
|
||||
# just before the recovery target (off)
|
||||
# (change requires restart)
|
||||
#recovery_target_timeline = 'latest' # 'current', 'latest', or timeline ID
|
||||
# (change requires restart)
|
||||
#recovery_target_action = 'pause' # 'pause', 'promote', 'shutdown'
|
||||
# (change requires restart)
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# REPLICATION
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# - Sending Servers -
|
||||
|
||||
# Set these on the primary and on any standby that will send replication data.
|
||||
|
||||
#max_wal_senders = 10 # max number of walsender processes
|
||||
# (change requires restart)
|
||||
#max_replication_slots = 10 # max number of replication slots
|
||||
# (change requires restart)
|
||||
#wal_keep_size = 0 # in megabytes; 0 disables
|
||||
#max_slot_wal_keep_size = -1 # in megabytes; -1 disables
|
||||
#wal_sender_timeout = 60s # in milliseconds; 0 disables
|
||||
#track_commit_timestamp = off # collect timestamp of transaction commit
|
||||
# (change requires restart)
|
||||
|
||||
# - Primary Server -
|
||||
|
||||
# These settings are ignored on a standby server.
|
||||
|
||||
#synchronous_standby_names = '' # standby servers that provide sync rep
|
||||
# method to choose sync standbys, number of sync standbys,
|
||||
# and comma-separated list of application_name
|
||||
# from standby(s); '*' = all
|
||||
#vacuum_defer_cleanup_age = 0 # number of xacts by which cleanup is delayed
|
||||
|
||||
# - Standby Servers -
|
||||
|
||||
# These settings are ignored on a primary server.
|
||||
|
||||
#primary_conninfo = '' # connection string to sending server
|
||||
#primary_slot_name = '' # replication slot on sending server
|
||||
#promote_trigger_file = '' # file name whose presence ends recovery
|
||||
#hot_standby = on # "off" disallows queries during recovery
|
||||
# (change requires restart)
|
||||
#max_standby_archive_delay = 30s # max delay before canceling queries
|
||||
# when reading WAL from archive;
|
||||
# -1 allows indefinite delay
|
||||
#max_standby_streaming_delay = 30s # max delay before canceling queries
|
||||
# when reading streaming WAL;
|
||||
# -1 allows indefinite delay
|
||||
#wal_receiver_create_temp_slot = off # create temp slot if primary_slot_name
|
||||
# is not set
|
||||
#wal_receiver_status_interval = 10s # send replies at least this often
|
||||
# 0 disables
|
||||
#hot_standby_feedback = off # send info from standby to prevent
|
||||
# query conflicts
|
||||
#wal_receiver_timeout = 60s # time that receiver waits for
|
||||
# communication from primary
|
||||
# in milliseconds; 0 disables
|
||||
#wal_retrieve_retry_interval = 5s # time to wait before retrying to
|
||||
# retrieve WAL after a failed attempt
|
||||
#recovery_min_apply_delay = 0 # minimum delay for applying changes during recovery
|
||||
|
||||
# - Subscribers -
|
||||
|
||||
# These settings are ignored on a publisher.
|
||||
|
||||
#max_logical_replication_workers = 4 # taken from max_worker_processes
|
||||
# (change requires restart)
|
||||
#max_sync_workers_per_subscription = 2 # taken from max_logical_replication_workers
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# QUERY TUNING
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# - Planner Method Configuration -
|
||||
|
||||
#enable_async_append = on
|
||||
#enable_bitmapscan = on
|
||||
#enable_gathermerge = on
|
||||
#enable_hashagg = on
|
||||
#enable_hashjoin = on
|
||||
#enable_incremental_sort = on
|
||||
#enable_indexscan = on
|
||||
#enable_indexonlyscan = on
|
||||
#enable_material = on
|
||||
#enable_memoize = on
|
||||
#enable_mergejoin = on
|
||||
#enable_nestloop = on
|
||||
#enable_parallel_append = on
|
||||
#enable_parallel_hash = on
|
||||
#enable_partition_pruning = on
|
||||
#enable_partitionwise_join = off
|
||||
#enable_partitionwise_aggregate = off
|
||||
#enable_seqscan = on
|
||||
#enable_sort = on
|
||||
#enable_tidscan = on
|
||||
|
||||
# - Planner Cost Constants -
|
||||
|
||||
#seq_page_cost = 1.0 # measured on an arbitrary scale
|
||||
#random_page_cost = 4.0 # same scale as above
|
||||
#cpu_tuple_cost = 0.01 # same scale as above
|
||||
#cpu_index_tuple_cost = 0.005 # same scale as above
|
||||
#cpu_operator_cost = 0.0025 # same scale as above
|
||||
#parallel_setup_cost = 1000.0 # same scale as above
|
||||
#parallel_tuple_cost = 0.1 # same scale as above
|
||||
#min_parallel_table_scan_size = 8MB
|
||||
#min_parallel_index_scan_size = 512kB
|
||||
#effective_cache_size = 4GB
|
||||
|
||||
#jit_above_cost = 100000 # perform JIT compilation if available
|
||||
# and query more expensive than this;
|
||||
# -1 disables
|
||||
#jit_inline_above_cost = 500000 # inline small functions if query is
|
||||
# more expensive than this; -1 disables
|
||||
#jit_optimize_above_cost = 500000 # use expensive JIT optimizations if
|
||||
# query is more expensive than this;
|
||||
# -1 disables
|
||||
|
||||
# - Genetic Query Optimizer -
|
||||
|
||||
#geqo = on
|
||||
#geqo_threshold = 12
|
||||
#geqo_effort = 5 # range 1-10
|
||||
#geqo_pool_size = 0 # selects default based on effort
|
||||
#geqo_generations = 0 # selects default based on effort
|
||||
#geqo_selection_bias = 2.0 # range 1.5-2.0
|
||||
#geqo_seed = 0.0 # range 0.0-1.0
|
||||
|
||||
# - Other Planner Options -
|
||||
|
||||
#default_statistics_target = 100 # range 1-10000
|
||||
#constraint_exclusion = partition # on, off, or partition
|
||||
#cursor_tuple_fraction = 0.1 # range 0.0-1.0
|
||||
#from_collapse_limit = 8
|
||||
#jit = on # allow JIT compilation
|
||||
#join_collapse_limit = 8 # 1 disables collapsing of explicit
|
||||
# JOIN clauses
|
||||
#plan_cache_mode = auto # auto, force_generic_plan or
|
||||
# force_custom_plan
|
||||
#recursive_worktable_factor = 10.0 # range 0.001-1000000
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# REPORTING AND LOGGING
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# - Where to Log -
|
||||
|
||||
#log_destination = 'stderr' # Valid values are combinations of
|
||||
# stderr, csvlog, jsonlog, syslog, and
|
||||
# eventlog, depending on platform.
|
||||
# csvlog and jsonlog require
|
||||
# logging_collector to be on.
|
||||
|
||||
# This is used when logging to stderr:
|
||||
#logging_collector = off # Enable capturing of stderr, jsonlog,
|
||||
# and csvlog into log files. Required
|
||||
# to be on for csvlogs and jsonlogs.
|
||||
# (change requires restart)
|
||||
|
||||
# These are only used if logging_collector is on:
|
||||
#log_directory = 'log' # directory where log files are written,
|
||||
# can be absolute or relative to PGDATA
|
||||
#log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log file name pattern,
|
||||
# can include strftime() escapes
|
||||
#log_file_mode = 0600 # creation mode for log files,
|
||||
# begin with 0 to use octal notation
|
||||
#log_rotation_age = 1d # Automatic rotation of logfiles will
|
||||
# happen after that time. 0 disables.
|
||||
#log_rotation_size = 10MB # Automatic rotation of logfiles will
|
||||
# happen after that much log output.
|
||||
# 0 disables.
|
||||
#log_truncate_on_rotation = off # If on, an existing log file with the
|
||||
# same name as the new log file will be
|
||||
# truncated rather than appended to.
|
||||
# But such truncation only occurs on
|
||||
# time-driven rotation, not on restarts
|
||||
# or size-driven rotation. Default is
|
||||
# off, meaning append to existing files
|
||||
# in all cases.
|
||||
|
||||
# These are relevant when logging to syslog:
|
||||
#syslog_facility = 'LOCAL0'
|
||||
#syslog_ident = 'postgres'
|
||||
#syslog_sequence_numbers = on
|
||||
#syslog_split_messages = on
|
||||
|
||||
# This is only relevant when logging to eventlog (Windows):
|
||||
# (change requires restart)
|
||||
#event_source = 'PostgreSQL'
|
||||
|
||||
# - When to Log -
|
||||
|
||||
#log_min_messages = warning # values in order of decreasing detail:
|
||||
# debug5
|
||||
# debug4
|
||||
# debug3
|
||||
# debug2
|
||||
# debug1
|
||||
# info
|
||||
# notice
|
||||
# warning
|
||||
# error
|
||||
# log
|
||||
# fatal
|
||||
# panic
|
||||
|
||||
#log_min_error_statement = error # values in order of decreasing detail:
|
||||
# debug5
|
||||
# debug4
|
||||
# debug3
|
||||
# debug2
|
||||
# debug1
|
||||
# info
|
||||
# notice
|
||||
# warning
|
||||
# error
|
||||
# log
|
||||
# fatal
|
||||
# panic (effectively off)
|
||||
|
||||
#log_min_duration_statement = -1 # -1 is disabled, 0 logs all statements
|
||||
# and their durations, > 0 logs only
|
||||
# statements running at least this number
|
||||
# of milliseconds
|
||||
|
||||
#log_min_duration_sample = -1 # -1 is disabled, 0 logs a sample of statements
|
||||
# and their durations, > 0 logs only a sample of
|
||||
# statements running at least this number
|
||||
# of milliseconds;
|
||||
# sample fraction is determined by log_statement_sample_rate
|
||||
|
||||
#log_statement_sample_rate = 1.0 # fraction of logged statements exceeding
|
||||
# log_min_duration_sample to be logged;
|
||||
# 1.0 logs all such statements, 0.0 never logs
|
||||
|
||||
|
||||
#log_transaction_sample_rate = 0.0 # fraction of transactions whose statements
|
||||
# are logged regardless of their duration; 1.0 logs all
|
||||
# statements from all transactions, 0.0 never logs
|
||||
|
||||
#log_startup_progress_interval = 10s # Time between progress updates for
|
||||
# long-running startup operations.
|
||||
# 0 disables the feature, > 0 indicates
|
||||
# the interval in milliseconds.
|
||||
|
||||
# - What to Log -
|
||||
|
||||
#debug_print_parse = off
|
||||
#debug_print_rewritten = off
|
||||
#debug_print_plan = off
|
||||
#debug_pretty_print = on
|
||||
#log_autovacuum_min_duration = 10min # log autovacuum activity;
|
||||
# -1 disables, 0 logs all actions and
|
||||
# their durations, > 0 logs only
|
||||
# actions running at least this number
|
||||
# of milliseconds.
|
||||
#log_checkpoints = on
|
||||
#log_connections = off
|
||||
#log_disconnections = off
|
||||
#log_duration = off
|
||||
#log_error_verbosity = default # terse, default, or verbose messages
|
||||
#log_hostname = off
|
||||
log_line_prefix = '%m [%p] %q%u@%d ' # special values:
|
||||
# %a = application name
|
||||
# %u = user name
|
||||
# %d = database name
|
||||
# %r = remote host and port
|
||||
# %h = remote host
|
||||
# %b = backend type
|
||||
# %p = process ID
|
||||
# %P = process ID of parallel group leader
|
||||
# %t = timestamp without milliseconds
|
||||
# %m = timestamp with milliseconds
|
||||
# %n = timestamp with milliseconds (as a Unix epoch)
|
||||
# %Q = query ID (0 if none or not computed)
|
||||
# %i = command tag
|
||||
# %e = SQL state
|
||||
# %c = session ID
|
||||
# %l = session line number
|
||||
# %s = session start timestamp
|
||||
# %v = virtual transaction ID
|
||||
# %x = transaction ID (0 if none)
|
||||
# %q = stop here in non-session
|
||||
# processes
|
||||
# %% = '%'
|
||||
# e.g. '<%u%%%d> '
|
||||
#log_lock_waits = off # log lock waits >= deadlock_timeout
|
||||
#log_recovery_conflict_waits = off # log standby recovery conflict waits
|
||||
# >= deadlock_timeout
|
||||
#log_parameter_max_length = -1 # when logging statements, limit logged
|
||||
# bind-parameter values to N bytes;
|
||||
# -1 means print in full, 0 disables
|
||||
#log_parameter_max_length_on_error = 0 # when logging an error, limit logged
|
||||
# bind-parameter values to N bytes;
|
||||
# -1 means print in full, 0 disables
|
||||
#log_statement = 'none' # none, ddl, mod, all
|
||||
#log_replication_commands = off
|
||||
#log_temp_files = -1 # log temporary files equal or larger
|
||||
# than the specified size in kilobytes;
|
||||
# -1 disables, 0 logs all temp files
|
||||
log_timezone = 'Etc/UTC'
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# PROCESS TITLE
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
cluster_name = '15/main' # added to process titles if nonempty
|
||||
# (change requires restart)
|
||||
#update_process_title = on
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# STATISTICS
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# - Cumulative Query and Index Statistics -
|
||||
|
||||
#track_activities = on
|
||||
#track_activity_query_size = 1024 # (change requires restart)
|
||||
#track_counts = on
|
||||
#track_io_timing = off
|
||||
#track_wal_io_timing = off
|
||||
#track_functions = none # none, pl, all
|
||||
#stats_fetch_consistency = cache
|
||||
|
||||
|
||||
# - Monitoring -
|
||||
|
||||
#compute_query_id = auto
|
||||
#log_statement_stats = off
|
||||
#log_parser_stats = off
|
||||
#log_planner_stats = off
|
||||
#log_executor_stats = off
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# AUTOVACUUM
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
#autovacuum = on # Enable autovacuum subprocess? 'on'
|
||||
# requires track_counts to also be on.
|
||||
#autovacuum_max_workers = 3 # max number of autovacuum subprocesses
|
||||
# (change requires restart)
|
||||
#autovacuum_naptime = 1min # time between autovacuum runs
|
||||
#autovacuum_vacuum_threshold = 50 # min number of row updates before
|
||||
# vacuum
|
||||
#autovacuum_vacuum_insert_threshold = 1000 # min number of row inserts
|
||||
# before vacuum; -1 disables insert
|
||||
# vacuums
|
||||
#autovacuum_analyze_threshold = 50 # min number of row updates before
|
||||
# analyze
|
||||
#autovacuum_vacuum_scale_factor = 0.2 # fraction of table size before vacuum
|
||||
#autovacuum_vacuum_insert_scale_factor = 0.2 # fraction of inserts over table
|
||||
# size before insert vacuum
|
||||
#autovacuum_analyze_scale_factor = 0.1 # fraction of table size before analyze
|
||||
#autovacuum_freeze_max_age = 200000000 # maximum XID age before forced vacuum
|
||||
# (change requires restart)
|
||||
#autovacuum_multixact_freeze_max_age = 400000000 # maximum multixact age
|
||||
# before forced vacuum
|
||||
# (change requires restart)
|
||||
#autovacuum_vacuum_cost_delay = 2ms # default vacuum cost delay for
|
||||
# autovacuum, in milliseconds;
|
||||
# -1 means use vacuum_cost_delay
|
||||
#autovacuum_vacuum_cost_limit = -1 # default vacuum cost limit for
|
||||
# autovacuum, -1 means use
|
||||
# vacuum_cost_limit
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# CLIENT CONNECTION DEFAULTS
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# - Statement Behavior -
|
||||
|
||||
#client_min_messages = notice # values in order of decreasing detail:
|
||||
# debug5
|
||||
# debug4
|
||||
# debug3
|
||||
# debug2
|
||||
# debug1
|
||||
# log
|
||||
# notice
|
||||
# warning
|
||||
# error
|
||||
#search_path = '"$user", public' # schema names
|
||||
#row_security = on
|
||||
#default_table_access_method = 'heap'
|
||||
#default_tablespace = '' # a tablespace name, '' uses the default
|
||||
#default_toast_compression = 'pglz' # 'pglz' or 'lz4'
|
||||
#temp_tablespaces = '' # a list of tablespace names, '' uses
|
||||
# only default tablespace
|
||||
#check_function_bodies = on
|
||||
#default_transaction_isolation = 'read committed'
|
||||
#default_transaction_read_only = off
|
||||
#default_transaction_deferrable = off
|
||||
#session_replication_role = 'origin'
|
||||
#statement_timeout = 0 # in milliseconds, 0 is disabled
|
||||
#lock_timeout = 0 # in milliseconds, 0 is disabled
|
||||
#idle_in_transaction_session_timeout = 0 # in milliseconds, 0 is disabled
|
||||
#idle_session_timeout = 0 # in milliseconds, 0 is disabled
|
||||
#vacuum_freeze_table_age = 150000000
|
||||
#vacuum_freeze_min_age = 50000000
|
||||
#vacuum_failsafe_age = 1600000000
|
||||
#vacuum_multixact_freeze_table_age = 150000000
|
||||
#vacuum_multixact_freeze_min_age = 5000000
|
||||
#vacuum_multixact_failsafe_age = 1600000000
|
||||
#bytea_output = 'hex' # hex, escape
|
||||
#xmlbinary = 'base64'
|
||||
#xmloption = 'content'
|
||||
#gin_pending_list_limit = 4MB
|
||||
|
||||
# - Locale and Formatting -
|
||||
|
||||
datestyle = 'iso, mdy'
|
||||
#intervalstyle = 'postgres'
|
||||
timezone = 'Etc/UTC'
|
||||
#timezone_abbreviations = 'Default' # Select the set of available time zone
|
||||
# abbreviations. Currently, there are
|
||||
# Default
|
||||
# Australia (historical usage)
|
||||
# India
|
||||
# You can create your own file in
|
||||
# share/timezonesets/.
|
||||
#extra_float_digits = 1 # min -15, max 3; any value >0 actually
|
||||
# selects precise output mode
|
||||
#client_encoding = sql_ascii # actually, defaults to database
|
||||
# encoding
|
||||
|
||||
# These settings are initialized by initdb, but they can be changed.
|
||||
lc_messages = 'C.UTF-8' # locale for system error message
|
||||
# strings
|
||||
lc_monetary = 'C.UTF-8' # locale for monetary formatting
|
||||
lc_numeric = 'C.UTF-8' # locale for number formatting
|
||||
lc_time = 'C.UTF-8' # locale for time formatting
|
||||
|
||||
# default configuration for text search
|
||||
default_text_search_config = 'pg_catalog.english'
|
||||
|
||||
# - Shared Library Preloading -
|
||||
|
||||
#local_preload_libraries = ''
|
||||
#session_preload_libraries = ''
|
||||
#shared_preload_libraries = '' # (change requires restart)
|
||||
#jit_provider = 'llvmjit' # JIT library to use
|
||||
|
||||
# - Other Defaults -
|
||||
|
||||
#dynamic_library_path = '$libdir'
|
||||
#extension_destdir = '' # prepend path when loading extensions
|
||||
# and shared objects (added by Debian)
|
||||
#gin_fuzzy_search_limit = 0
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# LOCK MANAGEMENT
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
#deadlock_timeout = 1s
|
||||
#max_locks_per_transaction = 64 # min 10
|
||||
# (change requires restart)
|
||||
#max_pred_locks_per_transaction = 64 # min 10
|
||||
# (change requires restart)
|
||||
#max_pred_locks_per_relation = -2 # negative values mean
|
||||
# (max_pred_locks_per_transaction
|
||||
# / -max_pred_locks_per_relation) - 1
|
||||
#max_pred_locks_per_page = 2 # min 0
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# VERSION AND PLATFORM COMPATIBILITY
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# - Previous PostgreSQL Versions -
|
||||
|
||||
#array_nulls = on
|
||||
#backslash_quote = safe_encoding # on, off, or safe_encoding
|
||||
#escape_string_warning = on
|
||||
#lo_compat_privileges = off
|
||||
#quote_all_identifiers = off
|
||||
#standard_conforming_strings = on
|
||||
#synchronize_seqscans = on
|
||||
|
||||
# - Other Platforms and Clients -
|
||||
|
||||
#transform_null_equals = off
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# ERROR HANDLING
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
#exit_on_error = off # terminate session on any error?
|
||||
#restart_after_crash = on # reinitialize after backend crash?
|
||||
#data_sync_retry = off # retry or panic on failure to fsync
|
||||
# data?
|
||||
# (change requires restart)
|
||||
#recovery_init_sync_method = fsync # fsync, syncfs (Linux 5.8+)
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# CONFIG FILE INCLUDES
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# These options allow settings to be loaded from files other than the
|
||||
# default postgresql.conf. Note that these are directives, not variable
|
||||
# assignments, so they can usefully be given more than once.
|
||||
|
||||
# include_dir = 'conf.d' # include files ending in '.conf' from
|
||||
# a directory, e.g., 'conf.d'
|
||||
#include_if_exists = '...' # include file only if it exists
|
||||
#include = '...' # include file
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# CUSTOMIZED OPTIONS
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# Add settings for extensions here
|
||||
@@ -0,0 +1,71 @@
|
||||
|
||||
[program:web]
|
||||
command=node /app/web/web/server.js
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stdout
|
||||
stderr_logfile_maxbytes=0
|
||||
environment=PORT=3001,HOSTNAME=0.0.0.0
|
||||
|
||||
[program:space]
|
||||
command=node /app/space/space/server.js
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stdout
|
||||
stderr_logfile_maxbytes=0
|
||||
environment=PORT=3002,HOSTNAME=0.0.0.0
|
||||
|
||||
[program:admin]
|
||||
command=node /app/admin/admin/server.js
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stdout
|
||||
stderr_logfile_maxbytes=0
|
||||
environment=PORT=3003,HOSTNAME=0.0.0.0
|
||||
|
||||
[program:migrator]
|
||||
directory=/app/api
|
||||
command=sh -c "./bin/docker-entrypoint-migrator.sh"
|
||||
autostart=true
|
||||
autorestart=false
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stdout
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:api]
|
||||
directory=/app/api
|
||||
command=sh -c "./bin/docker-entrypoint-api.sh"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stdout
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:worker]
|
||||
directory=/app/api
|
||||
command=sh -c "./bin/docker-entrypoint-worker.sh"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stdout
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:beat]
|
||||
directory=/app/api
|
||||
command=sh -c "./bin/docker-entrypoint-beat.sh"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stdout
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
[supervisord]
|
||||
user=root
|
||||
nodaemon=true
|
||||
stderr_logfile=/app/logs/error/supervisor.err.log
|
||||
stdout_logfile=/app/logs/access/supervisor.log
|
||||
|
||||
[program:redis]
|
||||
directory=/app/data/redis
|
||||
command=redis-server
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stderr_logfile=/app/logs/error/redis.err.log
|
||||
stdout_logfile=/app/logs/access/redis.log
|
||||
|
||||
[program:postgresql]
|
||||
user=postgres
|
||||
command=/usr/lib/postgresql/15/bin/postgres --config-file=/etc/postgresql/postgresql.conf
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stderr_logfile=/app/logs/error/postgresql.err.log
|
||||
stdout_logfile=/app/logs/access/postgresql.log
|
||||
|
||||
[program:minio]
|
||||
directory=/app/data/minio
|
||||
command=minio server /app/data/minio
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stderr_logfile=/app/logs/error/minio.err.log
|
||||
stdout_logfile=/app/logs/access/minio.log
|
||||
|
||||
[program:nginx]
|
||||
directory=/app/data/nginx
|
||||
command=/app/nginx-start.sh
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stderr_logfile=/app/logs/error/nginx.err.log
|
||||
stdout_logfile=/app/logs/access/nginx.log
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[supervisord]
|
||||
user=root
|
||||
nodaemon=true
|
||||
stderr_logfile=/app/logs/error/supervisor.err.log
|
||||
stdout_logfile=/app/logs/access/supervisor.log
|
||||
|
||||
[program:nginx]
|
||||
directory=/app/data/nginx
|
||||
command=/app/nginx-start.sh
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stderr_logfile=/app/logs/error/nginx.err.log
|
||||
stdout_logfile=/app/logs/access/nginx.log
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "Plane",
|
||||
"description": "Plane helps you track your issues, epics, and product roadmaps.",
|
||||
"repository": "http://github.com/makeplane/plane",
|
||||
"logo": "https://avatars.githubusercontent.com/u/115727700?s=200&v=4",
|
||||
"website": "https://plane.so/",
|
||||
"success_url": "/",
|
||||
"stack": "heroku-22",
|
||||
"keywords": ["plane", "project management", "django", "next"],
|
||||
"addons": ["heroku-postgresql:mini", "heroku-redis:mini"],
|
||||
"buildpacks": [
|
||||
{
|
||||
"url": "https://github.com/heroku/heroku-buildpack-python.git"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/heroku/heroku-buildpack-nodejs#v176"
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"EMAIL_HOST": {
|
||||
"description": "Email host to send emails from",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_HOST_USER": {
|
||||
"description": "Email host to send emails from",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_HOST_PASSWORD": {
|
||||
"description": "Email host to send emails from",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_FROM": {
|
||||
"description": "Email Sender",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_PORT": {
|
||||
"description": "The default Email PORT to use",
|
||||
"value": "587"
|
||||
},
|
||||
"AWS_REGION": {
|
||||
"description": "AWS Region to use for S3",
|
||||
"value": "false"
|
||||
},
|
||||
"AWS_ACCESS_KEY_ID": {
|
||||
"description": "AWS Access Key ID to use for S3",
|
||||
"value": ""
|
||||
},
|
||||
"AWS_SECRET_ACCESS_KEY": {
|
||||
"description": "AWS Secret Access Key to use for S3",
|
||||
"value": ""
|
||||
},
|
||||
"AWS_S3_BUCKET_NAME": {
|
||||
"description": "AWS Bucket Name to use for S3",
|
||||
"value": ""
|
||||
},
|
||||
"WEB_URL": {
|
||||
"description": "Web URL for Plane this will be used for redirections in the emails",
|
||||
"value": ""
|
||||
},
|
||||
"GITHUB_CLIENT_SECRET": {
|
||||
"description": "GitHub Client Secret",
|
||||
"value": ""
|
||||
},
|
||||
"NEXT_PUBLIC_API_BASE_URL": {
|
||||
"description": "Next Public API Base URL",
|
||||
"value": ""
|
||||
},
|
||||
"SECRET_KEY": {
|
||||
"description": "Django Secret Key",
|
||||
"value": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
.vercel
|
||||
.tubro
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
dis/
|
||||
build/
|
||||
@@ -1,10 +1,4 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# Setup pnpm package manager with corepack and configure global bin directory for caching
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 1: Build the project
|
||||
@@ -13,8 +7,7 @@ FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
ARG TURBO_VERSION=2.5.6
|
||||
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune --scope=admin --docker
|
||||
@@ -29,13 +22,11 @@ WORKDIR /app
|
||||
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN corepack enable pnpm
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install --network-timeout 500000
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
@@ -58,7 +49,7 @@ ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN pnpm turbo run build --filter=admin
|
||||
RUN yarn turbo run build --filter=admin
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 3: Copy the project and start it
|
||||
@@ -98,6 +89,4 @@ ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/admin/server.js"]
|
||||
EXPOSE 3000
|
||||
@@ -1,12 +1,12 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN corepack enable pnpm && pnpm add -g turbo
|
||||
RUN pnpm install
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
|
||||
@@ -14,4 +14,4 @@ EXPOSE 3000
|
||||
|
||||
VOLUME [ "/app/node_modules", "/app/admin/node_modules" ]
|
||||
|
||||
CMD ["pnpm", "dev", "--filter=admin"]
|
||||
CMD ["yarn", "dev", "--filter=admin"]
|
||||
|
||||
@@ -66,11 +66,9 @@ const InstanceGitlabAuthenticationPage = observer(() => {
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGitlabConfig))}
|
||||
onChange={() => {
|
||||
if (Boolean(parseInt(enableGitlabConfig)) === true) {
|
||||
updateConfig("IS_GITLAB_ENABLED", "0");
|
||||
} else {
|
||||
updateConfig("IS_GITLAB_ENABLED", "1");
|
||||
}
|
||||
Boolean(parseInt(enableGitlabConfig)) === true
|
||||
? updateConfig("IS_GITLAB_ENABLED", "0")
|
||||
: updateConfig("IS_GITLAB_ENABLED", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
|
||||
@@ -67,11 +67,9 @@ const InstanceGoogleAuthenticationPage = observer(() => {
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGoogleConfig))}
|
||||
onChange={() => {
|
||||
if (Boolean(parseInt(enableGoogleConfig)) === true) {
|
||||
updateConfig("IS_GOOGLE_ENABLED", "0");
|
||||
} else {
|
||||
updateConfig("IS_GOOGLE_ENABLED", "1");
|
||||
}
|
||||
Boolean(parseInt(enableGoogleConfig)) === true
|
||||
? updateConfig("IS_GOOGLE_ENABLED", "0")
|
||||
: updateConfig("IS_GOOGLE_ENABLED", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting || !formattedConfig}
|
||||
|
||||
@@ -49,9 +49,9 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
|
||||
EMAIL_USE_TLS: config["EMAIL_USE_TLS"],
|
||||
EMAIL_USE_SSL: config["EMAIL_USE_SSL"],
|
||||
EMAIL_FROM: config["EMAIL_FROM"],
|
||||
ENABLE_SMTP: config["ENABLE_SMTP"],
|
||||
},
|
||||
});
|
||||
|
||||
const emailFormFields: TControllerInputFormField[] = [
|
||||
{
|
||||
key: "EMAIL_HOST",
|
||||
@@ -101,7 +101,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
|
||||
];
|
||||
|
||||
const onSubmit = async (formData: EmailFormValues) => {
|
||||
const payload: Partial<EmailFormValues> = { ...formData, ENABLE_SMTP: "1" };
|
||||
const payload: Partial<EmailFormValues> = { ...formData };
|
||||
|
||||
await updateInstanceConfigurations(payload)
|
||||
.then(() =>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { Loader, setToast, TOAST_TYPE, ToggleSwitch } from "@plane/ui";
|
||||
import { Loader } from "@plane/ui";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// components
|
||||
@@ -11,80 +10,36 @@ import { InstanceEmailForm } from "./email-config-form";
|
||||
|
||||
const InstanceEmailPage = observer(() => {
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();
|
||||
const { fetchInstanceConfigurations, formattedConfig } = useInstance();
|
||||
|
||||
const { isLoading } = useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSMTPEnabled, setIsSMTPEnabled] = useState(false);
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (isSMTPEnabled) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await disableEmail();
|
||||
setIsSMTPEnabled(false);
|
||||
setToast({
|
||||
title: "Email feature disabled",
|
||||
message: "Email feature has been disabled",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: "Error disabling email",
|
||||
message: "Failed to disable email feature. Please try again.",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setIsSMTPEnabled(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (formattedConfig) {
|
||||
setIsSMTPEnabled(formattedConfig.ENABLE_SMTP === "1");
|
||||
}
|
||||
}, [formattedConfig]);
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<div className="py-4 space-y-1 flex-shrink-0">
|
||||
<div className="text-xl font-medium text-custom-text-100">Secure emails from your own instance</div>
|
||||
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<div className="text-xl font-medium text-custom-text-100">Secure emails from your own instance</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">
|
||||
Plane can send useful emails to you and your users from your own instance without talking to the Internet.
|
||||
<div className="text-sm font-normal text-custom-text-300">
|
||||
Plane can send useful emails to you and your users from your own instance without talking to the Internet.
|
||||
<div className="text-sm font-normal text-custom-text-300">
|
||||
Set it up below and please test your settings before you save them.
|
||||
<span className="text-red-400">Misconfigs can lead to email bounces and errors.</span>
|
||||
</div>
|
||||
Set it up below and please test your settings before you save them.
|
||||
<span className="text-red-400">Misconfigs can lead to email bounces and errors.</span>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader>
|
||||
<Loader.Item width="24px" height="16px" className="rounded-full" />
|
||||
</Loader>
|
||||
</div>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceEmailForm config={formattedConfig} />
|
||||
) : (
|
||||
<ToggleSwitch value={isSMTPEnabled} onChange={handleToggle} size="sm" disabled={isSubmitting} />
|
||||
<Loader className="space-y-10">
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="20%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
{isSMTPEnabled && !isLoading && (
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceEmailForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-10">
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="75%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<Loader.Item height="50px" width="20%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ export const AdminHeader: FC = observer(() => {
|
||||
const breadcrumbItems = generateBreadcrumbItems(pathName);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-sidebar-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-sidebar-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<HamburgerToggle />
|
||||
{breadcrumbItems.length >= 0 && (
|
||||
|
||||
@@ -42,7 +42,7 @@ export const AdminSidebarDropdown = observer(() => {
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 pb-2">
|
||||
<span className="px-2 text-custom-sidebar-text-200 truncate">{currentUser?.email}</span>
|
||||
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Menu.Item
|
||||
@@ -77,7 +77,7 @@ export const AdminSidebarDropdown = observer(() => {
|
||||
}, [csrfToken]);
|
||||
|
||||
return (
|
||||
<div className="flex max-h-header items-center gap-x-5 gap-y-2 border-b border-custom-sidebar-border-200 px-4 py-3.5">
|
||||
<div className="flex max-h-[3.75rem] items-center gap-x-5 gap-y-2 border-b border-custom-sidebar-border-200 px-4 py-3.5">
|
||||
<div className="h-full w-full truncate">
|
||||
<div
|
||||
className={`flex flex-grow items-center gap-x-2 truncate rounded py-1 ${
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PlaneLockup } from "@plane/ui";
|
||||
|
||||
export const AuthHeader = () => (
|
||||
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
|
||||
<Link href="/">
|
||||
<PlaneLockup height={20} width={95} className="text-custom-text-100" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -1,9 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
// logo assets
|
||||
import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg";
|
||||
import PlaneBackgroundPattern from "public/auth/background-pattern.svg";
|
||||
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png";
|
||||
import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const patternBackground = resolvedTheme === "light" ? PlaneBackgroundPattern : PlaneBackgroundPatternDark;
|
||||
const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo;
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
|
||||
{children}
|
||||
<div className="relative">
|
||||
<div className="h-screen w-full overflow-hidden overflow-y-auto flex flex-col">
|
||||
<div className="container h-[110px] flex-shrink-0 mx-auto px-5 lg:px-0 flex items-center justify-between gap-5 z-50">
|
||||
<div className="flex items-center gap-x-2 py-10">
|
||||
<Link href={`/`} className="h-[30px] w-[133px]">
|
||||
<Image src={logo} alt="Plane logo" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image src={patternBackground} className="w-screen h-full object-cover" alt="Plane background pattern" />
|
||||
</div>
|
||||
<div className="relative z-10 flex-grow">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { InstanceFailureView } from "@/components/instance/failure";
|
||||
import { InstanceLoading } from "@/components/instance/loading";
|
||||
import { InstanceSetupForm } from "@/components/instance/setup-form";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
@@ -17,24 +17,46 @@ const HomePage = () => {
|
||||
// if instance is not fetched, show loading
|
||||
if (!instance && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<LogoSpinner />
|
||||
<div className="relative h-full w-full overflow-y-auto px-6 py-10 mx-auto flex justify-center items-center">
|
||||
<InstanceLoading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// if instance fetch fails, show failure view
|
||||
if (error) {
|
||||
return <InstanceFailureView />;
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto px-6 py-10 mx-auto flex justify-center items-center">
|
||||
<InstanceFailureView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// if instance is fetched and setup is not done, show setup form
|
||||
if (instance && !instance?.is_setup_done) {
|
||||
return <InstanceSetupForm />;
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto px-6 py-10 mx-auto flex justify-center items-center">
|
||||
<InstanceSetupForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// if instance is fetched and setup is done, show sign in form
|
||||
return <InstanceSignInForm />;
|
||||
return (
|
||||
<div className="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 lg:pt-28 transition-all">
|
||||
<div className="relative flex flex-col space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
|
||||
Manage your Plane instance
|
||||
</h3>
|
||||
<p className="font-medium text-onboarding-text-400">
|
||||
Configure instance-wide settings to secure your instance
|
||||
</p>
|
||||
</div>
|
||||
<InstanceSignInForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(HomePage);
|
||||
|
||||
@@ -10,9 +10,7 @@ import { Button, Input, Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { Banner } from "@/components/common/banner";
|
||||
// local components
|
||||
import { FormHeader } from "../../../core/components/instance/form-header";
|
||||
import { AuthBanner } from "./auth-banner";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
import { authErrorHandler } from "./auth-helpers";
|
||||
|
||||
// service initialization
|
||||
@@ -103,91 +101,78 @@ export const InstanceSignInForm: FC = () => {
|
||||
}, [errorCode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<FormHeader
|
||||
heading="Manage your Plane instance"
|
||||
subHeading="Configure instance-wide settings to secure your instance"
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/api/instances/admins/sign-in/`}
|
||||
onSubmit={() => setIsSubmitting(true)}
|
||||
onError={() => setIsSubmitting(false)}
|
||||
>
|
||||
{errorData.type && errorData?.message ? (
|
||||
<Banner type="error" message={errorData?.message} />
|
||||
) : (
|
||||
<>{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}</>
|
||||
)}
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputSize="md"
|
||||
placeholder="name@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
inputSize="md"
|
||||
placeholder="Enter your password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/api/instances/admins/sign-in/`}
|
||||
onSubmit={() => setIsSubmitting(true)}
|
||||
onError={() => setIsSubmitting(false)}
|
||||
>
|
||||
{errorData.type && errorData?.message ? (
|
||||
<Banner type="error" message={errorData?.message} />
|
||||
) : (
|
||||
<>
|
||||
{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}
|
||||
</>
|
||||
)}
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputSize="md"
|
||||
placeholder="name@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
inputSize="md"
|
||||
placeholder="Enter your password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
{showPassword ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => setShowPassword(false)}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => setShowPassword(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Sign in"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{showPassword ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => setShowPassword(false)}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => setShowPassword(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div className="py-2">
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Sign in"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,8 +7,8 @@ import { cn } from "@plane/utils";
|
||||
type Props = {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
config: React.ReactNode;
|
||||
icon: JSX.Element;
|
||||
config: JSX.Element;
|
||||
disabled?: boolean;
|
||||
withBorder?: boolean;
|
||||
unavailable?: boolean;
|
||||
|
||||
@@ -13,7 +13,7 @@ type Props = {
|
||||
type: "text" | "password";
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string | React.ReactNode;
|
||||
description?: string | JSX.Element;
|
||||
placeholder: string;
|
||||
error: boolean;
|
||||
required: boolean;
|
||||
@@ -23,7 +23,7 @@ export type TControllerInputFormField = {
|
||||
key: string;
|
||||
type: "text" | "password";
|
||||
label: string;
|
||||
description?: string | React.ReactNode;
|
||||
description?: string | JSX.Element;
|
||||
placeholder: string;
|
||||
error: boolean;
|
||||
required: boolean;
|
||||
|
||||
@@ -9,14 +9,14 @@ import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
type Props = {
|
||||
label: string;
|
||||
url: string;
|
||||
description: string | React.ReactNode;
|
||||
description: string | JSX.Element;
|
||||
};
|
||||
|
||||
export type TCopyField = {
|
||||
key: string;
|
||||
label: string;
|
||||
url: string;
|
||||
description: string | React.ReactNode;
|
||||
description: string | JSX.Element;
|
||||
};
|
||||
|
||||
export const CopyField: React.FC<Props> = (props) => {
|
||||
|
||||
@@ -7,11 +7,11 @@ import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
|
||||
export const LogoSpinner = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
|
||||
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
|
||||
<Image src={logoSrc} alt="logo" className="w-[82px] h-[82px] mr-2" priority={false} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useMemo } from "react";
|
||||
// plane internal packages
|
||||
import { E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { cn, getPasswordStrength } from "@plane/utils";
|
||||
|
||||
type TPasswordStrengthMeter = {
|
||||
password: string;
|
||||
isFocused?: boolean;
|
||||
};
|
||||
|
||||
export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => {
|
||||
const { password, isFocused = false } = props;
|
||||
// derived values
|
||||
const strength = useMemo(() => getPasswordStrength(password), [password]);
|
||||
const strengthBars = useMemo(() => {
|
||||
switch (strength) {
|
||||
case E_PASSWORD_STRENGTH.EMPTY: {
|
||||
return {
|
||||
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
|
||||
text: "Please enter your password.",
|
||||
textColor: "text-custom-text-100",
|
||||
};
|
||||
}
|
||||
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: {
|
||||
return {
|
||||
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
|
||||
text: "Password length should me more than 8 characters.",
|
||||
textColor: "text-red-500",
|
||||
};
|
||||
}
|
||||
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: {
|
||||
return {
|
||||
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
|
||||
text: "Password is weak.",
|
||||
textColor: "text-red-500",
|
||||
};
|
||||
}
|
||||
case E_PASSWORD_STRENGTH.STRENGTH_VALID: {
|
||||
return {
|
||||
bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`],
|
||||
text: "Password is strong.",
|
||||
textColor: "text-green-500",
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
|
||||
text: "Please enter your password.",
|
||||
textColor: "text-custom-text-100",
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [strength]);
|
||||
|
||||
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
|
||||
|
||||
if (!isPasswordMeterVisible) return <></>;
|
||||
return (
|
||||
<div className="w-full space-y-2 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="relative flex items-center gap-2">
|
||||
{strengthBars?.bars.map((color, index) => (
|
||||
<div key={`${color}-${index}`} className={cn("w-full h-1 rounded-full", color)} />
|
||||
))}
|
||||
</div>
|
||||
<div className={cn(`text-xs font-medium text-custom-text-100`, strengthBars?.textColor)}>
|
||||
{strengthBars?.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="relative flex flex-wrap gap-x-4 gap-y-2">
|
||||
{PASSWORD_CRITERIA.map((criteria) => (
|
||||
<div
|
||||
key={criteria.key}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1 text-xs",
|
||||
criteria.isCriteriaValid(password) ? `text-green-500/70` : "text-custom-text-300"
|
||||
)}
|
||||
>
|
||||
<CircleCheck width={14} height={14} />
|
||||
{criteria.label}
|
||||
</div>
|
||||
))}
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@plane/ui";
|
||||
// assets
|
||||
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
|
||||
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
|
||||
import InstanceFailureImage from "@/public/instance/instance-failure.svg";
|
||||
|
||||
export const InstanceFailureView: FC = observer(() => {
|
||||
export const InstanceFailureView: FC = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
|
||||
@@ -19,24 +17,22 @@ export const InstanceFailureView: FC = observer(() => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<Image src={instanceImage} alt="Plane Logo" />
|
||||
<h3 className="font-medium text-2xl text-white text-center">Unable to fetch instance details.</h3>
|
||||
<p className="font-medium text-base text-center">
|
||||
We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="md" onClick={handleRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
|
||||
<div className="w-auto max-w-2xl relative space-y-8 py-10">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<Image src={instanceImage} alt="Plane Logo" />
|
||||
<h3 className="font-medium text-2xl text-white ">Unable to fetch instance details.</h3>
|
||||
<p className="font-medium text-base text-center">
|
||||
We were unable to fetch the details of the instance. <br />
|
||||
Fret not, it might just be a connectivity issue.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="md" onClick={handleRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export const FormHeader = ({ heading, subHeading }: { heading: string; subHeading: string }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-2xl font-semibold text-custom-text-100 leading-7">{heading}</span>
|
||||
<span className="text-lg font-semibold text-custom-text-400 leading-7">{subHeading}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -13,7 +13,7 @@ export const InstanceNotReady: FC = () => (
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
|
||||
<Image src={PlaneTakeOffImage} alt="Plane Logo" />
|
||||
<p className="font-medium text-base text-custom-text-400">
|
||||
<p className="font-medium text-base text-onboarding-text-400">
|
||||
Get started by setting up your instance and workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,16 @@ import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
|
||||
|
||||
export const InstanceLoading = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
|
||||
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
|
||||
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
|
||||
<div className="w-auto max-w-2xl relative space-y-8 py-10">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<Image src={logoSrc} alt="logo" className="w-[82px] h-[82px] mr-2" priority={false} />
|
||||
<h3 className="font-medium text-2xl text-white ">Fetching instance details...</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,12 +7,11 @@ import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Button, Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
|
||||
import { Button, Checkbox, Input, Spinner } from "@plane/ui";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// components
|
||||
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
|
||||
import { Banner } from "@/components/common/banner";
|
||||
import { FormHeader } from "@/components/instance/form-header";
|
||||
import { PasswordStrengthMeter } from "@/components/common/password-strength-meter";
|
||||
|
||||
// service initialization
|
||||
const authService = new AuthService();
|
||||
@@ -133,221 +132,227 @@ export const InstanceSetupForm: FC = (props) => {
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<FormHeader
|
||||
heading="Setup your Plane Instance"
|
||||
subHeading="Post setup you will be able to manage this Plane instance."
|
||||
/>
|
||||
{errorData.type &&
|
||||
errorData?.message &&
|
||||
![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && (
|
||||
<Banner type="error" message={errorData?.message} />
|
||||
)}
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/api/instances/admins/sign-up/`}
|
||||
onSubmit={() => setIsSubmitting(true)}
|
||||
onError={() => setIsSubmitting(false)}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<input type="hidden" name="is_telemetry_enabled" value={formData.is_telemetry_enabled ? "True" : "False"} />
|
||||
<div className="max-w-lg lg:max-w-md w-full">
|
||||
<div className="relative flex flex-col space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
|
||||
Setup your Plane Instance
|
||||
</h3>
|
||||
<p className="font-medium text-onboarding-text-400">
|
||||
Post setup you will be able to manage this Plane instance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="first_name">
|
||||
First name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
inputSize="md"
|
||||
placeholder="Wilber"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleFormChange("first_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="last_name">
|
||||
Last name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
inputSize="md"
|
||||
placeholder="Wright"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleFormChange("last_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{errorData.type &&
|
||||
errorData?.message &&
|
||||
![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && (
|
||||
<Banner type="error" message={errorData?.message} />
|
||||
)}
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/api/instances/admins/sign-up/`}
|
||||
onSubmit={() => setIsSubmitting(true)}
|
||||
onError={() => setIsSubmitting(false)}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<input type="hidden" name="is_telemetry_enabled" value={formData.is_telemetry_enabled ? "True" : "False"} />
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
|
||||
Email <span className="text-red-500">*</span>
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="first_name">
|
||||
First name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputSize="md"
|
||||
placeholder="name@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
|
||||
autoComplete="on"
|
||||
/>
|
||||
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
|
||||
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="company_name">
|
||||
Company name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
inputSize="md"
|
||||
placeholder="Company name"
|
||||
value={formData.company_name}
|
||||
onChange={(e) => handleFormChange("company_name", e.target.value)}
|
||||
placeholder="Wilber"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleFormChange("first_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
|
||||
Set a password <span className="text-red-500">*</span>
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="last_name">
|
||||
Last name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword.password ? "text" : "password"}
|
||||
inputSize="md"
|
||||
placeholder="New password..."
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
{showPassword.password ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
|
||||
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
|
||||
)}
|
||||
<PasswordStrengthIndicator password={formData.password} isFocused={isPasswordInputFocused} />
|
||||
<Input
|
||||
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
inputSize="md"
|
||||
placeholder="Wright"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleFormChange("last_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="confirm_password">
|
||||
Confirm password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword.retypePassword ? "text" : "password"}
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
inputSize="md"
|
||||
value={formData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
{showPassword.retypePassword ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!!formData.confirm_password &&
|
||||
formData.password !== formData.confirm_password &&
|
||||
renderPasswordMatchError && <span className="text-sm text-red-500">Passwords don{"'"}t match</span>}
|
||||
</div>
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputSize="md"
|
||||
placeholder="name@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
|
||||
autoComplete="on"
|
||||
/>
|
||||
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
|
||||
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex gap-2">
|
||||
<div>
|
||||
<Checkbox
|
||||
className="w-4 h-4"
|
||||
iconClassName="w-3 h-3"
|
||||
id="is_telemetry_enabled"
|
||||
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
|
||||
checked={formData.is_telemetry_enabled}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-sm text-custom-text-300 font-medium cursor-pointer" htmlFor="is_telemetry_enabled">
|
||||
Allow Plane to anonymously collect usage events.{" "}
|
||||
<a
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="company_name">
|
||||
Company name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
type="text"
|
||||
inputSize="md"
|
||||
placeholder="Company name"
|
||||
value={formData.company_name}
|
||||
onChange={(e) => handleFormChange("company_name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
|
||||
Set a password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword.password ? "text" : "password"}
|
||||
inputSize="md"
|
||||
placeholder="New password..."
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
{showPassword.password ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
href="https://developers.plane.so/self-hosting/telemetry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-blue-500 hover:text-blue-600 flex-shrink-0"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
See More
|
||||
</a>
|
||||
</label>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
|
||||
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
|
||||
)}
|
||||
<PasswordStrengthMeter password={formData.password} isFocused={isPasswordInputFocused} />
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
|
||||
</Button>
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">
|
||||
Confirm password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword.retypePassword ? "text" : "password"}
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
inputSize="md"
|
||||
value={formData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
{showPassword.retypePassword ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{!!formData.confirm_password &&
|
||||
formData.password !== formData.confirm_password &&
|
||||
renderPasswordMatchError && <span className="text-sm text-red-500">Passwords don{"'"}t match</span>}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center pt-2 gap-2">
|
||||
<div>
|
||||
<Checkbox
|
||||
className="w-4 h-4"
|
||||
iconClassName="w-3 h-3"
|
||||
id="is_telemetry_enabled"
|
||||
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
|
||||
checked={formData.is_telemetry_enabled}
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
className="text-sm text-onboarding-text-300 font-medium cursor-pointer"
|
||||
htmlFor="is_telemetry_enabled"
|
||||
>
|
||||
Allow Plane to anonymously collect usage events.
|
||||
</label>
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://developers.plane.so/self-hosting/telemetry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
See More
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,7 +32,6 @@ export interface IInstanceStore {
|
||||
fetchInstanceAdmins: () => Promise<IInstanceAdmin[] | undefined>;
|
||||
fetchInstanceConfigurations: () => Promise<IInstanceConfiguration[] | undefined>;
|
||||
updateInstanceConfigurations: (data: Partial<IFormattedInstanceConfiguration>) => Promise<IInstanceConfiguration[]>;
|
||||
disableEmail: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class InstanceStore implements IInstanceStore {
|
||||
@@ -188,30 +187,4 @@ export class InstanceStore implements IInstanceStore {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
disableEmail = async () => {
|
||||
const instanceConfigurations = this.instanceConfigurations;
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.instanceConfigurations = this.instanceConfigurations?.map((config) => {
|
||||
if (
|
||||
[
|
||||
"EMAIL_HOST",
|
||||
"EMAIL_PORT",
|
||||
"EMAIL_HOST_USER",
|
||||
"EMAIL_HOST_PASSWORD",
|
||||
"EMAIL_FROM",
|
||||
"ENABLE_SMTP",
|
||||
].includes(config.key)
|
||||
)
|
||||
return { ...config, value: "" };
|
||||
return config;
|
||||
});
|
||||
});
|
||||
await this.instanceService.disableEmail();
|
||||
} catch (error) {
|
||||
console.error("Error disabling the email");
|
||||
this.instanceConfigurations = instanceConfigurations;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "0.28.0",
|
||||
"version": "0.27.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"dev": "turbo run develop",
|
||||
"develop": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
|
||||
"check:lint": "eslint . --max-warnings 19",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"fix:lint": "eslint . --fix",
|
||||
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\""
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/propel": "workspace:*",
|
||||
"@plane/services": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@plane/constants": "*",
|
||||
"@plane/hooks": "*",
|
||||
"@plane/propel": "*",
|
||||
"@plane/services": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
"axios": "1.11.0",
|
||||
"axios": "^1.8.3",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
"mobx": "^6.12.0",
|
||||
@@ -37,19 +37,19 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "7.51.5",
|
||||
"sharp": "^0.33.5",
|
||||
"swr": "^2.2.4",
|
||||
"uuid": "^9.0.1"
|
||||
"uuid": "^9.0.1",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/tailwind-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/zxcvbn": "^4.4.4",
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 466 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 761 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 919 B |
@@ -2,8 +2,8 @@
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{ "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
|
||||
|
Before Width: | Height: | Size: 954 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -24,24 +24,24 @@
|
||||
:root {
|
||||
color-scheme: light !important;
|
||||
|
||||
--color-primary-10: 229, 243, 250;
|
||||
--color-primary-20: 216, 237, 248;
|
||||
--color-primary-30: 199, 229, 244;
|
||||
--color-primary-40: 169, 214, 239;
|
||||
--color-primary-50: 144, 202, 234;
|
||||
--color-primary-60: 109, 186, 227;
|
||||
--color-primary-70: 75, 170, 221;
|
||||
--color-primary-80: 41, 154, 214;
|
||||
--color-primary-90: 34, 129, 180;
|
||||
--color-primary-100: 0, 99, 153;
|
||||
--color-primary-200: 0, 92, 143;
|
||||
--color-primary-300: 0, 86, 133;
|
||||
--color-primary-400: 0, 77, 117;
|
||||
--color-primary-500: 0, 66, 102;
|
||||
--color-primary-600: 0, 53, 82;
|
||||
--color-primary-700: 0, 43, 66;
|
||||
--color-primary-800: 0, 33, 51;
|
||||
--color-primary-900: 0, 23, 36;
|
||||
--color-primary-10: 236, 241, 255;
|
||||
--color-primary-20: 217, 228, 255;
|
||||
--color-primary-30: 197, 214, 255;
|
||||
--color-primary-40: 178, 200, 255;
|
||||
--color-primary-50: 159, 187, 255;
|
||||
--color-primary-60: 140, 173, 255;
|
||||
--color-primary-70: 121, 159, 255;
|
||||
--color-primary-80: 101, 145, 255;
|
||||
--color-primary-90: 82, 132, 255;
|
||||
--color-primary-100: 63, 118, 255;
|
||||
--color-primary-200: 57, 106, 230;
|
||||
--color-primary-300: 50, 94, 204;
|
||||
--color-primary-400: 44, 83, 179;
|
||||
--color-primary-500: 38, 71, 153;
|
||||
--color-primary-600: 32, 59, 128;
|
||||
--color-primary-700: 25, 47, 102;
|
||||
--color-primary-800: 19, 35, 76;
|
||||
--color-primary-900: 13, 24, 51;
|
||||
|
||||
--color-background-100: 255, 255, 255; /* primary bg */
|
||||
--color-background-90: 247, 247, 247; /* secondary bg */
|
||||
@@ -135,6 +135,28 @@
|
||||
--color-border-300: 212, 212, 212; /* strong border- 1 */
|
||||
--color-border-400: 185, 185, 185; /* strong border- 2 */
|
||||
|
||||
/* onboarding colors */
|
||||
--gradient-onboarding-100: linear-gradient(106deg, #f2f6ff 29.8%, #e1eaff 99.34%);
|
||||
--gradient-onboarding-200: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%);
|
||||
--gradient-onboarding-300: linear-gradient(164deg, #fff 4.25%, rgba(255, 255, 255, 0.06) 93.5%);
|
||||
--gradient-onboarding-400: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%);
|
||||
|
||||
--color-onboarding-text-100: 23, 23, 23;
|
||||
--color-onboarding-text-200: 58, 58, 58;
|
||||
--color-onboarding-text-300: 82, 82, 82;
|
||||
--color-onboarding-text-400: 163, 163, 163;
|
||||
|
||||
--color-onboarding-background-100: 236, 241, 255;
|
||||
--color-onboarding-background-200: 255, 255, 255;
|
||||
--color-onboarding-background-300: 236, 241, 255;
|
||||
--color-onboarding-background-400: 177, 206, 250;
|
||||
|
||||
--color-onboarding-border-100: 229, 229, 229;
|
||||
--color-onboarding-border-200: 217, 228, 255;
|
||||
--color-onboarding-border-300: 229, 229, 229, 0.5;
|
||||
|
||||
--color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(126, 139, 171, 0.1);
|
||||
|
||||
/* toast theme */
|
||||
--color-toast-success-text: 62, 155, 79;
|
||||
--color-toast-error-text: 220, 62, 66;
|
||||
@@ -175,25 +197,6 @@
|
||||
[data-theme="dark-contrast"] {
|
||||
color-scheme: dark !important;
|
||||
|
||||
--color-primary-10: 8, 31, 43;
|
||||
--color-primary-20: 10, 37, 51;
|
||||
--color-primary-30: 13, 49, 69;
|
||||
--color-primary-40: 16, 58, 81;
|
||||
--color-primary-50: 18, 68, 94;
|
||||
--color-primary-60: 23, 86, 120;
|
||||
--color-primary-70: 28, 104, 146;
|
||||
--color-primary-80: 31, 116, 163;
|
||||
--color-primary-90: 34, 129, 180;
|
||||
--color-primary-100: 40, 146, 204;
|
||||
--color-primary-200: 41, 154, 214;
|
||||
--color-primary-300: 75, 170, 221;
|
||||
--color-primary-400: 109, 186, 227;
|
||||
--color-primary-500: 144, 202, 234;
|
||||
--color-primary-600: 169, 214, 239;
|
||||
--color-primary-700: 199, 229, 244;
|
||||
--color-primary-800: 216, 237, 248;
|
||||
--color-primary-900: 229, 243, 250;
|
||||
|
||||
--color-background-100: 25, 25, 25; /* primary bg */
|
||||
--color-background-90: 32, 32, 32; /* secondary bg */
|
||||
--color-background-80: 44, 44, 44; /* tertiary bg */
|
||||
@@ -222,6 +225,27 @@
|
||||
--color-border-300: 46, 46, 46; /* strong border- 1 */
|
||||
--color-border-400: 58, 58, 58; /* strong border- 2 */
|
||||
|
||||
/* onboarding colors */
|
||||
--gradient-onboarding-100: linear-gradient(106deg, #18191b 25.17%, #18191b 99.34%);
|
||||
--gradient-onboarding-200: linear-gradient(129deg, rgba(47, 49, 53, 0.8) -22.23%, rgba(33, 34, 37, 0.8) 62.98%);
|
||||
--gradient-onboarding-300: linear-gradient(167deg, rgba(47, 49, 53, 0.45) 19.22%, #212225 98.48%);
|
||||
|
||||
--color-onboarding-text-100: 237, 238, 240;
|
||||
--color-onboarding-text-200: 176, 180, 187;
|
||||
--color-onboarding-text-300: 118, 123, 132;
|
||||
--color-onboarding-text-400: 105, 110, 119;
|
||||
|
||||
--color-onboarding-background-100: 54, 58, 64;
|
||||
--color-onboarding-background-200: 40, 42, 45;
|
||||
--color-onboarding-background-300: 40, 42, 45;
|
||||
--color-onboarding-background-400: 67, 72, 79;
|
||||
|
||||
--color-onboarding-border-100: 54, 58, 64;
|
||||
--color-onboarding-border-200: 54, 58, 64;
|
||||
--color-onboarding-border-300: 34, 35, 38, 0.5;
|
||||
|
||||
--color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(39, 44, 56, 0.1);
|
||||
|
||||
/* toast theme */
|
||||
--color-toast-success-text: 178, 221, 181;
|
||||
--color-toast-error-text: 206, 44, 49;
|
||||
@@ -262,6 +286,25 @@
|
||||
[data-theme="dark"],
|
||||
[data-theme="light-contrast"],
|
||||
[data-theme="dark-contrast"] {
|
||||
--color-primary-10: 236, 241, 255;
|
||||
--color-primary-20: 217, 228, 255;
|
||||
--color-primary-30: 197, 214, 255;
|
||||
--color-primary-40: 178, 200, 255;
|
||||
--color-primary-50: 159, 187, 255;
|
||||
--color-primary-60: 140, 173, 255;
|
||||
--color-primary-70: 121, 159, 255;
|
||||
--color-primary-80: 101, 145, 255;
|
||||
--color-primary-90: 82, 132, 255;
|
||||
--color-primary-100: 63, 118, 255;
|
||||
--color-primary-200: 57, 106, 230;
|
||||
--color-primary-300: 50, 94, 204;
|
||||
--color-primary-400: 44, 83, 179;
|
||||
--color-primary-500: 38, 71, 153;
|
||||
--color-primary-600: 32, 59, 128;
|
||||
--color-primary-700: 25, 47, 102;
|
||||
--color-primary-800: 19, 35, 76;
|
||||
--color-primary-900: 13, 24, 51;
|
||||
|
||||
--color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
|
||||
--color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
|
||||
--color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */
|
||||
@@ -289,6 +332,7 @@
|
||||
text-rendering: optimizeLegibility;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
name = "plane.api"
|
||||
|
||||
def ready(self):
|
||||
# Import authentication extensions to register them with drf-spectacular
|
||||
try:
|
||||
import plane.utils.openapi.auth # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -1,55 +0,0 @@
|
||||
from .user import UserLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .project import (
|
||||
ProjectSerializer,
|
||||
ProjectLiteSerializer,
|
||||
ProjectCreateSerializer,
|
||||
ProjectUpdateSerializer,
|
||||
)
|
||||
from .issue import (
|
||||
IssueSerializer,
|
||||
LabelCreateUpdateSerializer,
|
||||
LabelSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueExpandSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentUploadSerializer,
|
||||
IssueSearchSerializer,
|
||||
IssueCommentCreateSerializer,
|
||||
IssueLinkCreateSerializer,
|
||||
IssueLinkUpdateSerializer,
|
||||
)
|
||||
from .state import StateLiteSerializer, StateSerializer
|
||||
from .cycle import (
|
||||
CycleSerializer,
|
||||
CycleIssueSerializer,
|
||||
CycleLiteSerializer,
|
||||
CycleIssueRequestSerializer,
|
||||
TransferCycleIssueRequestSerializer,
|
||||
CycleCreateSerializer,
|
||||
CycleUpdateSerializer,
|
||||
)
|
||||
from .module import (
|
||||
ModuleSerializer,
|
||||
ModuleIssueSerializer,
|
||||
ModuleLiteSerializer,
|
||||
ModuleIssueRequestSerializer,
|
||||
ModuleCreateSerializer,
|
||||
ModuleUpdateSerializer,
|
||||
)
|
||||
from .intake import (
|
||||
IntakeIssueSerializer,
|
||||
IntakeIssueCreateSerializer,
|
||||
IntakeIssueUpdateSerializer,
|
||||
)
|
||||
from .estimate import EstimatePointSerializer
|
||||
from .asset import (
|
||||
UserAssetUploadSerializer,
|
||||
AssetUpdateSerializer,
|
||||
GenericAssetUploadSerializer,
|
||||
GenericAssetUpdateSerializer,
|
||||
FileAssetSerializer,
|
||||
)
|
||||
@@ -1,123 +0,0 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import FileAsset
|
||||
|
||||
|
||||
class UserAssetUploadSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for user asset upload requests.
|
||||
|
||||
This serializer validates the metadata required to generate a presigned URL
|
||||
for uploading user profile assets (avatar or cover image) directly to S3 storage.
|
||||
Supports JPEG, PNG, WebP, JPG, and GIF image formats with size validation.
|
||||
"""
|
||||
|
||||
name = serializers.CharField(help_text="Original filename of the asset")
|
||||
type = serializers.ChoiceField(
|
||||
choices=[
|
||||
("image/jpeg", "JPEG"),
|
||||
("image/png", "PNG"),
|
||||
("image/webp", "WebP"),
|
||||
("image/jpg", "JPG"),
|
||||
("image/gif", "GIF"),
|
||||
],
|
||||
default="image/jpeg",
|
||||
help_text="MIME type of the file",
|
||||
style={"placeholder": "image/jpeg"},
|
||||
)
|
||||
size = serializers.IntegerField(help_text="File size in bytes")
|
||||
entity_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
(FileAsset.EntityTypeContext.USER_AVATAR, "User Avatar"),
|
||||
(FileAsset.EntityTypeContext.USER_COVER, "User Cover"),
|
||||
],
|
||||
help_text="Type of user asset",
|
||||
)
|
||||
|
||||
|
||||
class AssetUpdateSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for asset status updates after successful upload completion.
|
||||
|
||||
Handles post-upload asset metadata updates including attribute modifications
|
||||
and upload confirmation for S3-based file storage workflows.
|
||||
"""
|
||||
|
||||
attributes = serializers.JSONField(
|
||||
required=False, help_text="Additional attributes to update for the asset"
|
||||
)
|
||||
|
||||
|
||||
class GenericAssetUploadSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for generic asset upload requests with project association.
|
||||
|
||||
Validates metadata for generating presigned URLs for workspace assets including
|
||||
project association, external system tracking, and file validation for
|
||||
document management and content storage workflows.
|
||||
"""
|
||||
|
||||
name = serializers.CharField(help_text="Original filename of the asset")
|
||||
type = serializers.CharField(required=False, help_text="MIME type of the file")
|
||||
size = serializers.IntegerField(help_text="File size in bytes")
|
||||
project_id = serializers.UUIDField(
|
||||
required=False,
|
||||
help_text="UUID of the project to associate with the asset",
|
||||
style={"placeholder": "123e4567-e89b-12d3-a456-426614174000"},
|
||||
)
|
||||
external_id = serializers.CharField(
|
||||
required=False,
|
||||
help_text="External identifier for the asset (for integration tracking)",
|
||||
)
|
||||
external_source = serializers.CharField(
|
||||
required=False, help_text="External source system (for integration tracking)"
|
||||
)
|
||||
|
||||
|
||||
class GenericAssetUpdateSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for generic asset upload confirmation and status management.
|
||||
|
||||
Handles post-upload status updates for workspace assets including
|
||||
upload completion marking and metadata finalization.
|
||||
"""
|
||||
|
||||
is_uploaded = serializers.BooleanField(
|
||||
default=True, help_text="Whether the asset has been successfully uploaded"
|
||||
)
|
||||
|
||||
|
||||
class FileAssetSerializer(BaseSerializer):
|
||||
"""
|
||||
Comprehensive file asset serializer with complete metadata and URL generation.
|
||||
|
||||
Provides full file asset information including storage metadata, access URLs,
|
||||
relationship data, and upload status for complete asset management workflows.
|
||||
"""
|
||||
|
||||
asset_url = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FileAsset
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"comment",
|
||||
"page",
|
||||
"draft_issue",
|
||||
"user",
|
||||
"is_deleted",
|
||||
"deleted_at",
|
||||
"storage_metadata",
|
||||
"asset_url",
|
||||
]
|
||||
@@ -1,140 +0,0 @@
|
||||
# Module improts
|
||||
from .base import BaseSerializer
|
||||
from .issue import IssueExpandSerializer
|
||||
from plane.db.models import IntakeIssue, Issue
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class IssueForIntakeSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for work item data within intake submissions.
|
||||
|
||||
Handles essential work item fields for intake processing including
|
||||
content validation and priority assignment for triage workflows.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = [
|
||||
"name",
|
||||
"description",
|
||||
"description_html",
|
||||
"priority",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IntakeIssueCreateSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for creating intake work items with embedded issue data.
|
||||
|
||||
Manages intake work item creation including nested issue creation,
|
||||
status assignment, and source tracking for issue queue management.
|
||||
"""
|
||||
|
||||
issue = IssueForIntakeSerializer(help_text="Issue data for the intake issue")
|
||||
|
||||
class Meta:
|
||||
model = IntakeIssue
|
||||
fields = [
|
||||
"issue",
|
||||
"intake",
|
||||
"status",
|
||||
"snoozed_till",
|
||||
"duplicate_to",
|
||||
"source",
|
||||
"source_email",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IntakeIssueSerializer(BaseSerializer):
|
||||
"""
|
||||
Comprehensive serializer for intake work items with expanded issue details.
|
||||
|
||||
Provides full intake work item data including embedded issue information,
|
||||
status tracking, and triage metadata for issue queue management.
|
||||
"""
|
||||
|
||||
issue_detail = IssueExpandSerializer(read_only=True, source="issue")
|
||||
inbox = serializers.UUIDField(source="intake.id", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IntakeIssue
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IntakeIssueUpdateSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for updating intake work items and their associated issues.
|
||||
|
||||
Handles intake work item modifications including status changes, triage decisions,
|
||||
and embedded issue updates for issue queue processing workflows.
|
||||
"""
|
||||
|
||||
issue = IssueForIntakeSerializer(
|
||||
required=False, help_text="Issue data to update in the intake issue"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IntakeIssue
|
||||
fields = [
|
||||
"status",
|
||||
"snoozed_till",
|
||||
"duplicate_to",
|
||||
"source",
|
||||
"source_email",
|
||||
"issue",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IssueDataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for nested work item data in intake request payloads.
|
||||
|
||||
Validates core work item fields within intake requests including
|
||||
content formatting, priority levels, and metadata for issue creation.
|
||||
"""
|
||||
|
||||
name = serializers.CharField(max_length=255, help_text="Issue name")
|
||||
description_html = serializers.CharField(
|
||||
required=False, allow_null=True, help_text="Issue description HTML"
|
||||
)
|
||||
priority = serializers.ChoiceField(
|
||||
choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority"
|
||||
)
|
||||
@@ -1,270 +0,0 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectIdentifier,
|
||||
WorkspaceMember,
|
||||
State,
|
||||
Estimate,
|
||||
)
|
||||
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_json_content,
|
||||
)
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
class ProjectCreateSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for creating projects with workspace validation.
|
||||
|
||||
Handles project creation including identifier validation, member verification,
|
||||
and workspace association for new project initialization.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = [
|
||||
"name",
|
||||
"description",
|
||||
"project_lead",
|
||||
"default_assignee",
|
||||
"identifier",
|
||||
"icon_prop",
|
||||
"emoji",
|
||||
"cover_image",
|
||||
"module_view",
|
||||
"cycle_view",
|
||||
"issue_views_view",
|
||||
"page_view",
|
||||
"intake_view",
|
||||
"guest_view_all_features",
|
||||
"archive_in",
|
||||
"close_in",
|
||||
"timezone",
|
||||
"logo_props",
|
||||
"external_source",
|
||||
"external_id",
|
||||
"is_issue_type_enabled",
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
if data.get("project_lead", None) is not None:
|
||||
# Check if the project lead is a member of the workspace
|
||||
if not WorkspaceMember.objects.filter(
|
||||
workspace_id=self.context["workspace_id"],
|
||||
member_id=data.get("project_lead"),
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Project lead should be a user in the workspace"
|
||||
)
|
||||
|
||||
if data.get("default_assignee", None) is not None:
|
||||
# Check if the default assignee is a member of the workspace
|
||||
if not WorkspaceMember.objects.filter(
|
||||
workspace_id=self.context["workspace_id"],
|
||||
member_id=data.get("default_assignee"),
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Default assignee should be a user in the workspace"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
identifier = validated_data.get("identifier", "").strip().upper()
|
||||
if identifier == "":
|
||||
raise serializers.ValidationError(detail="Project Identifier is required")
|
||||
|
||||
if ProjectIdentifier.objects.filter(
|
||||
name=identifier, workspace_id=self.context["workspace_id"]
|
||||
).exists():
|
||||
raise serializers.ValidationError(detail="Project Identifier is taken")
|
||||
|
||||
project = Project.objects.create(
|
||||
**validated_data, workspace_id=self.context["workspace_id"]
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
class ProjectUpdateSerializer(ProjectCreateSerializer):
|
||||
"""
|
||||
Serializer for updating projects with enhanced state and estimation management.
|
||||
|
||||
Extends project creation with update-specific validations including default state
|
||||
assignment, estimation configuration, and project setting modifications.
|
||||
"""
|
||||
|
||||
class Meta(ProjectCreateSerializer.Meta):
|
||||
model = Project
|
||||
fields = ProjectCreateSerializer.Meta.fields + [
|
||||
"default_state",
|
||||
"estimate",
|
||||
]
|
||||
|
||||
read_only_fields = ProjectCreateSerializer.Meta.read_only_fields
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Update a project"""
|
||||
if (
|
||||
validated_data.get("default_state", None) is not None
|
||||
and not State.objects.filter(
|
||||
project=instance, id=validated_data.get("default_state")
|
||||
).exists()
|
||||
):
|
||||
# Check if the default state is a state in the project
|
||||
raise serializers.ValidationError(
|
||||
"Default state should be a state in the project"
|
||||
)
|
||||
|
||||
if (
|
||||
validated_data.get("estimate", None) is not None
|
||||
and not Estimate.objects.filter(
|
||||
project=instance, id=validated_data.get("estimate")
|
||||
).exists()
|
||||
):
|
||||
# Check if the estimate is a estimate in the project
|
||||
raise serializers.ValidationError(
|
||||
"Estimate should be a estimate in the project"
|
||||
)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class ProjectSerializer(BaseSerializer):
|
||||
"""
|
||||
Comprehensive project serializer with metrics and member context.
|
||||
|
||||
Provides complete project data including member counts, cycle/module totals,
|
||||
deployment status, and user-specific context for project management.
|
||||
"""
|
||||
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_cycles = serializers.IntegerField(read_only=True)
|
||||
total_modules = serializers.IntegerField(read_only=True)
|
||||
is_member = serializers.BooleanField(read_only=True)
|
||||
sort_order = serializers.FloatField(read_only=True)
|
||||
member_role = serializers.IntegerField(read_only=True)
|
||||
is_deployed = serializers.BooleanField(read_only=True)
|
||||
cover_image_url = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"emoji",
|
||||
"workspace",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"deleted_at",
|
||||
"cover_image_url",
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
# Check project lead should be a member of the workspace
|
||||
if (
|
||||
data.get("project_lead", None) is not None
|
||||
and not WorkspaceMember.objects.filter(
|
||||
workspace_id=self.context["workspace_id"],
|
||||
member_id=data.get("project_lead"),
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Project lead should be a user in the workspace"
|
||||
)
|
||||
|
||||
# Check default assignee should be a member of the workspace
|
||||
if (
|
||||
data.get("default_assignee", None) is not None
|
||||
and not WorkspaceMember.objects.filter(
|
||||
workspace_id=self.context["workspace_id"],
|
||||
member_id=data.get("default_assignee"),
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Default assignee should be a user in the workspace"
|
||||
)
|
||||
|
||||
# Validate description content for security
|
||||
if "description" in data and data["description"]:
|
||||
# For Project, description might be text field, not JSON
|
||||
if isinstance(data["description"], dict):
|
||||
is_valid, error_msg = validate_json_content(data["description"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description": error_msg})
|
||||
|
||||
if "description_text" in data and data["description_text"]:
|
||||
is_valid, error_msg = validate_json_content(data["description_text"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_text": error_msg})
|
||||
|
||||
if "description_html" in data and data["description_html"]:
|
||||
if isinstance(data["description_html"], dict):
|
||||
is_valid, error_msg = validate_json_content(data["description_html"])
|
||||
else:
|
||||
is_valid, error_msg = validate_html_content(
|
||||
str(data["description_html"])
|
||||
)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_html": error_msg})
|
||||
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
identifier = validated_data.get("identifier", "").strip().upper()
|
||||
if identifier == "":
|
||||
raise serializers.ValidationError(detail="Project Identifier is required")
|
||||
|
||||
if ProjectIdentifier.objects.filter(
|
||||
name=identifier, workspace_id=self.context["workspace_id"]
|
||||
).exists():
|
||||
raise serializers.ValidationError(detail="Project Identifier is taken")
|
||||
|
||||
project = Project.objects.create(
|
||||
**validated_data, workspace_id=self.context["workspace_id"]
|
||||
)
|
||||
_ = ProjectIdentifier.objects.create(
|
||||
name=project.identifier,
|
||||
project=project,
|
||||
workspace_id=self.context["workspace_id"],
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
class ProjectLiteSerializer(BaseSerializer):
|
||||
"""
|
||||
Lightweight project serializer for minimal data transfer.
|
||||
|
||||
Provides essential project information including identifiers, visual properties,
|
||||
and basic metadata optimized for list views and references.
|
||||
"""
|
||||
|
||||
cover_image_url = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = [
|
||||
"id",
|
||||
"identifier",
|
||||
"name",
|
||||
"cover_image",
|
||||
"icon_prop",
|
||||
"emoji",
|
||||
"description",
|
||||
"cover_image_url",
|
||||
]
|
||||
read_only_fields = fields
|
||||
@@ -1,40 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
UserAssetEndpoint,
|
||||
UserServerAssetEndpoint,
|
||||
GenericAssetEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"assets/user-assets/",
|
||||
UserAssetEndpoint.as_view(http_method_names=["post"]),
|
||||
name="user-assets",
|
||||
),
|
||||
path(
|
||||
"assets/user-assets/<uuid:asset_id>/",
|
||||
UserAssetEndpoint.as_view(http_method_names=["patch", "delete"]),
|
||||
name="user-assets-detail",
|
||||
),
|
||||
path(
|
||||
"assets/user-assets/server/",
|
||||
UserServerAssetEndpoint.as_view(http_method_names=["post"]),
|
||||
name="user-server-assets",
|
||||
),
|
||||
path(
|
||||
"assets/user-assets/<uuid:asset_id>/server/",
|
||||
UserServerAssetEndpoint.as_view(http_method_names=["patch", "delete"]),
|
||||
name="user-server-assets-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/assets/",
|
||||
GenericAssetEndpoint.as_view(http_method_names=["post"]),
|
||||
name="generic-asset",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/assets/<uuid:asset_id>/",
|
||||
GenericAssetEndpoint.as_view(http_method_names=["get", "patch"]),
|
||||
name="generic-asset-detail",
|
||||
),
|
||||
]
|
||||
@@ -1,97 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
IssueListCreateAPIEndpoint,
|
||||
IssueDetailAPIEndpoint,
|
||||
LabelListCreateAPIEndpoint,
|
||||
LabelDetailAPIEndpoint,
|
||||
IssueLinkListCreateAPIEndpoint,
|
||||
IssueLinkDetailAPIEndpoint,
|
||||
IssueCommentListCreateAPIEndpoint,
|
||||
IssueCommentDetailAPIEndpoint,
|
||||
IssueActivityListAPIEndpoint,
|
||||
IssueActivityDetailAPIEndpoint,
|
||||
IssueAttachmentListCreateAPIEndpoint,
|
||||
IssueAttachmentDetailAPIEndpoint,
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/search/",
|
||||
IssueSearchEndpoint.as_view(http_method_names=["get"]),
|
||||
name="issue-search",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/<str:project_identifier>-<str:issue_identifier>/",
|
||||
WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="issue-by-identifier",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
|
||||
IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
|
||||
IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
|
||||
LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="label",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
|
||||
LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="label",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
|
||||
IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="link",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
|
||||
IssueLinkDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="link",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
|
||||
IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="comment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
|
||||
IssueCommentDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="comment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
|
||||
IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="activity",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
|
||||
IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="activity",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
|
||||
IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="attachment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
|
||||
IssueAttachmentDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="issue-attachment",
|
||||
),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
|
||||
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="project-members",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/members/",
|
||||
WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="workspace-members",
|
||||
),
|
||||
]
|
||||
@@ -1,47 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
ModuleListCreateAPIEndpoint,
|
||||
ModuleDetailAPIEndpoint,
|
||||
ModuleIssueListCreateAPIEndpoint,
|
||||
ModuleIssueDetailAPIEndpoint,
|
||||
ModuleArchiveUnarchiveAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/",
|
||||
ModuleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="modules",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/",
|
||||
ModuleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="modules-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
|
||||
ModuleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="module-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
|
||||
ModuleIssueDetailAPIEndpoint.as_view(http_method_names=["delete"]),
|
||||
name="module-issues-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/archive/",
|
||||
ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]),
|
||||
name="module-archive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/",
|
||||
ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="module-archive-list",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/<uuid:pk>/unarchive/",
|
||||
ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]),
|
||||
name="module-unarchive",
|
||||
),
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
ProjectListCreateAPIEndpoint,
|
||||
ProjectDetailAPIEndpoint,
|
||||
ProjectArchiveUnarchiveAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/",
|
||||
ProjectListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="project",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:pk>/",
|
||||
ProjectDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="project",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archive/",
|
||||
ProjectArchiveUnarchiveAPIEndpoint.as_view(
|
||||
http_method_names=["post", "delete"]
|
||||
),
|
||||
name="project-archive-unarchive",
|
||||
),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
from drf_spectacular.views import (
|
||||
SpectacularAPIView,
|
||||
SpectacularRedocView,
|
||||
SpectacularSwaggerView,
|
||||
)
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = [
|
||||
path("schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
path(
|
||||
"schema/swagger-ui/",
|
||||
SpectacularSwaggerView.as_view(url_name="schema"),
|
||||
name="swagger-ui",
|
||||
),
|
||||
path(
|
||||
"schema/redoc/",
|
||||
SpectacularRedocView.as_view(url_name="schema"),
|
||||
name="redoc",
|
||||
),
|
||||
]
|
||||
@@ -1,11 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import UserEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"users/me/",
|
||||
UserEndpoint.as_view(http_method_names=["get"]),
|
||||
name="users",
|
||||
),
|
||||
]
|
||||
@@ -1,55 +0,0 @@
|
||||
from .project import (
|
||||
ProjectListCreateAPIEndpoint,
|
||||
ProjectDetailAPIEndpoint,
|
||||
ProjectArchiveUnarchiveAPIEndpoint,
|
||||
)
|
||||
|
||||
from .state import (
|
||||
StateListCreateAPIEndpoint,
|
||||
StateDetailAPIEndpoint,
|
||||
)
|
||||
|
||||
from .issue import (
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueListCreateAPIEndpoint,
|
||||
IssueDetailAPIEndpoint,
|
||||
LabelListCreateAPIEndpoint,
|
||||
LabelDetailAPIEndpoint,
|
||||
IssueLinkListCreateAPIEndpoint,
|
||||
IssueLinkDetailAPIEndpoint,
|
||||
IssueCommentListCreateAPIEndpoint,
|
||||
IssueCommentDetailAPIEndpoint,
|
||||
IssueActivityListAPIEndpoint,
|
||||
IssueActivityDetailAPIEndpoint,
|
||||
IssueAttachmentListCreateAPIEndpoint,
|
||||
IssueAttachmentDetailAPIEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
)
|
||||
|
||||
from .cycle import (
|
||||
CycleListCreateAPIEndpoint,
|
||||
CycleDetailAPIEndpoint,
|
||||
CycleIssueListCreateAPIEndpoint,
|
||||
CycleIssueDetailAPIEndpoint,
|
||||
TransferCycleIssueAPIEndpoint,
|
||||
CycleArchiveUnarchiveAPIEndpoint,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
ModuleListCreateAPIEndpoint,
|
||||
ModuleDetailAPIEndpoint,
|
||||
ModuleIssueListCreateAPIEndpoint,
|
||||
ModuleIssueDetailAPIEndpoint,
|
||||
ModuleArchiveUnarchiveAPIEndpoint,
|
||||
)
|
||||
|
||||
from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
|
||||
|
||||
from .intake import (
|
||||
IntakeIssueListCreateAPIEndpoint,
|
||||
IntakeIssueDetailAPIEndpoint,
|
||||
)
|
||||
|
||||
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
|
||||
|
||||
from .user import UserEndpoint
|
||||
@@ -1,631 +0,0 @@
|
||||
# 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
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiRequest, OpenApiTypes
|
||||
|
||||
# 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
|
||||
from plane.api.serializers import (
|
||||
UserAssetUploadSerializer,
|
||||
AssetUpdateSerializer,
|
||||
GenericAssetUploadSerializer,
|
||||
GenericAssetUpdateSerializer,
|
||||
)
|
||||
from plane.utils.openapi import (
|
||||
ASSET_ID_PARAMETER,
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PRESIGNED_URL_SUCCESS_RESPONSE,
|
||||
GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE,
|
||||
GENERIC_ASSET_VALIDATION_ERROR_RESPONSE,
|
||||
ASSET_CONFLICT_RESPONSE,
|
||||
ASSET_DOWNLOAD_SUCCESS_RESPONSE,
|
||||
ASSET_DOWNLOAD_ERROR_RESPONSE,
|
||||
ASSET_UPDATED_RESPONSE,
|
||||
ASSET_DELETED_RESPONSE,
|
||||
VALIDATION_ERROR_RESPONSE,
|
||||
ASSET_NOT_FOUND_RESPONSE,
|
||||
NOT_FOUND_RESPONSE,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
asset_docs,
|
||||
)
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
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
|
||||
|
||||
@asset_docs(
|
||||
operation_id="create_user_asset_upload",
|
||||
summary="Generate presigned URL for user asset upload",
|
||||
description="Generate presigned URL for user asset upload",
|
||||
request=OpenApiRequest(
|
||||
request=UserAssetUploadSerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Avatar Upload",
|
||||
value={
|
||||
"name": "profile.jpg",
|
||||
"type": "image/jpeg",
|
||||
"size": 1024000,
|
||||
"entity_type": "USER_AVATAR",
|
||||
},
|
||||
description="Example request for uploading a user avatar",
|
||||
),
|
||||
OpenApiExample(
|
||||
"User Cover Upload",
|
||||
value={
|
||||
"name": "cover.jpg",
|
||||
"type": "image/jpeg",
|
||||
"size": 1024000,
|
||||
"entity_type": "USER_COVER",
|
||||
},
|
||||
description="Example request for uploading a user cover",
|
||||
),
|
||||
],
|
||||
),
|
||||
responses={
|
||||
200: PRESIGNED_URL_SUCCESS_RESPONSE,
|
||||
400: VALIDATION_ERROR_RESPONSE,
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
"""Generate presigned URL for user asset upload.
|
||||
|
||||
Create a presigned URL for uploading user profile assets (avatar or cover image).
|
||||
This endpoint generates the necessary credentials for direct S3 upload.
|
||||
"""
|
||||
# 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,
|
||||
)
|
||||
|
||||
@asset_docs(
|
||||
operation_id="update_user_asset",
|
||||
summary="Mark user asset as uploaded",
|
||||
description="Mark user asset as uploaded",
|
||||
parameters=[ASSET_ID_PARAMETER],
|
||||
request=OpenApiRequest(
|
||||
request=AssetUpdateSerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Update Asset Attributes",
|
||||
value={
|
||||
"attributes": {
|
||||
"name": "updated_profile.jpg",
|
||||
"type": "image/jpeg",
|
||||
"size": 1024000,
|
||||
},
|
||||
"entity_type": "USER_AVATAR",
|
||||
},
|
||||
description="Example request for updating asset attributes",
|
||||
),
|
||||
],
|
||||
),
|
||||
responses={
|
||||
204: ASSET_UPDATED_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
def patch(self, request, asset_id):
|
||||
"""Update user asset after upload completion.
|
||||
|
||||
Update the asset status and attributes after the file has been uploaded to S3.
|
||||
This endpoint should be called after completing the S3 upload to mark the asset as uploaded.
|
||||
"""
|
||||
# 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)
|
||||
|
||||
@asset_docs(
|
||||
operation_id="delete_user_asset",
|
||||
summary="Delete user asset",
|
||||
parameters=[ASSET_ID_PARAMETER],
|
||||
responses={
|
||||
204: ASSET_DELETED_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
def delete(self, request, asset_id):
|
||||
"""Delete user asset.
|
||||
|
||||
Delete a user profile asset (avatar or cover image) and remove its reference from the user profile.
|
||||
This performs a soft delete by marking the asset as deleted and updating the user's profile.
|
||||
"""
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(
|
||||
entity_type=asset.entity_type, asset=asset, request=request
|
||||
)
|
||||
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
|
||||
|
||||
@asset_docs(
|
||||
operation_id="create_user_server_asset_upload",
|
||||
summary="Generate presigned URL for user server asset upload",
|
||||
request=UserAssetUploadSerializer,
|
||||
responses={
|
||||
200: PRESIGNED_URL_SUCCESS_RESPONSE,
|
||||
400: VALIDATION_ERROR_RESPONSE,
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
"""Generate presigned URL for user server asset upload.
|
||||
|
||||
Create a presigned URL for uploading user profile assets (avatar or cover image) using server credentials.
|
||||
This endpoint generates the necessary credentials for direct S3 upload with server-side authentication.
|
||||
"""
|
||||
# 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,
|
||||
)
|
||||
|
||||
@asset_docs(
|
||||
operation_id="update_user_server_asset",
|
||||
summary="Mark user server asset as uploaded",
|
||||
parameters=[ASSET_ID_PARAMETER],
|
||||
request=AssetUpdateSerializer,
|
||||
responses={
|
||||
204: ASSET_UPDATED_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
def patch(self, request, asset_id):
|
||||
"""Update user server asset after upload completion.
|
||||
|
||||
Update the asset status and attributes after the file has been uploaded to S3 using server credentials.
|
||||
This endpoint should be called after completing the S3 upload to mark the asset as uploaded.
|
||||
"""
|
||||
# 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)
|
||||
|
||||
@asset_docs(
|
||||
operation_id="delete_user_server_asset",
|
||||
summary="Delete user server asset",
|
||||
parameters=[ASSET_ID_PARAMETER],
|
||||
responses={
|
||||
204: ASSET_DELETED_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
def delete(self, request, asset_id):
|
||||
"""Delete user server asset.
|
||||
|
||||
Delete a user profile asset (avatar or cover image) using server credentials and remove its reference from the user profile.
|
||||
This performs a soft delete by marking the asset as deleted and updating the user's profile.
|
||||
"""
|
||||
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."""
|
||||
|
||||
use_read_replica = True
|
||||
|
||||
@asset_docs(
|
||||
operation_id="get_generic_asset",
|
||||
summary="Get presigned URL for asset download",
|
||||
description="Get presigned URL for asset download",
|
||||
parameters=[WORKSPACE_SLUG_PARAMETER],
|
||||
responses={
|
||||
200: ASSET_DOWNLOAD_SUCCESS_RESPONSE,
|
||||
400: ASSET_DOWNLOAD_ERROR_RESPONSE,
|
||||
404: ASSET_NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
def get(self, request, slug, asset_id):
|
||||
"""Get presigned URL for asset download.
|
||||
|
||||
Generate a presigned URL for downloading a generic asset.
|
||||
The asset must be uploaded and associated with the specified workspace.
|
||||
"""
|
||||
try:
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
# 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:
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "Internal server error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@asset_docs(
|
||||
operation_id="create_generic_asset_upload",
|
||||
summary="Generate presigned URL for generic asset upload",
|
||||
description="Generate presigned URL for generic asset upload",
|
||||
parameters=[WORKSPACE_SLUG_PARAMETER],
|
||||
request=OpenApiRequest(
|
||||
request=GenericAssetUploadSerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"GenericAssetUploadSerializer",
|
||||
value={
|
||||
"name": "image.jpg",
|
||||
"type": "image/jpeg",
|
||||
"size": 1024000,
|
||||
"project_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for uploading a generic asset",
|
||||
),
|
||||
],
|
||||
),
|
||||
responses={
|
||||
200: GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE,
|
||||
400: GENERIC_ASSET_VALIDATION_ERROR_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
409: ASSET_CONFLICT_RESPONSE,
|
||||
},
|
||||
)
|
||||
def post(self, request, slug):
|
||||
"""Generate presigned URL for generic asset upload.
|
||||
|
||||
Create a presigned URL for uploading generic assets that can be bound to entities like work items.
|
||||
Supports various file types and includes external source tracking for integrations.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
@asset_docs(
|
||||
operation_id="update_generic_asset",
|
||||
summary="Update generic asset after upload completion",
|
||||
description="Update generic asset after upload completion",
|
||||
parameters=[WORKSPACE_SLUG_PARAMETER, ASSET_ID_PARAMETER],
|
||||
request=OpenApiRequest(
|
||||
request=GenericAssetUpdateSerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"GenericAssetUpdateSerializer",
|
||||
value={"is_uploaded": True},
|
||||
description="Example request for updating a generic asset",
|
||||
)
|
||||
],
|
||||
),
|
||||
responses={
|
||||
204: ASSET_UPDATED_RESPONSE,
|
||||
404: ASSET_NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
def patch(self, request, slug, asset_id):
|
||||
"""Update generic asset after upload completion.
|
||||
|
||||
Update the asset status after the file has been uploaded to S3.
|
||||
This endpoint should be called after completing the S3 upload to mark the asset as uploaded
|
||||
and trigger metadata extraction.
|
||||
"""
|
||||
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
|
||||
)
|
||||
@@ -1,137 +0,0 @@
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema,
|
||||
OpenApiResponse,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.api.serializers import UserLiteSerializer
|
||||
from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember
|
||||
from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission
|
||||
from plane.utils.openapi import (
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
FORBIDDEN_RESPONSE,
|
||||
WORKSPACE_NOT_FOUND_RESPONSE,
|
||||
PROJECT_NOT_FOUND_RESPONSE,
|
||||
WORKSPACE_MEMBER_EXAMPLE,
|
||||
PROJECT_MEMBER_EXAMPLE,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceMemberAPIEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkSpaceAdminPermission]
|
||||
use_read_replica = True
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_workspace_members",
|
||||
summary="List workspace members",
|
||||
description="Retrieve all users who are members of the specified workspace.",
|
||||
tags=["Members"],
|
||||
parameters=[WORKSPACE_SLUG_PARAMETER],
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="List of workspace members with their roles",
|
||||
response={
|
||||
"type": "array",
|
||||
"items": {
|
||||
"allOf": [
|
||||
{"$ref": "#/components/schemas/UserLite"},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "integer",
|
||||
"description": "Member role in the workspace",
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
examples=[WORKSPACE_MEMBER_EXAMPLE],
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: WORKSPACE_NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
# Get all the users that are present inside the workspace
|
||||
def get(self, request, slug):
|
||||
"""List workspace members
|
||||
|
||||
Retrieve all users who are members of the specified workspace.
|
||||
Returns user profiles with their respective workspace roles and permissions.
|
||||
"""
|
||||
# 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
|
||||
class ProjectMemberAPIEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectMemberPermission]
|
||||
use_read_replica = True
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_project_members",
|
||||
summary="List project members",
|
||||
description="Retrieve all users who are members of the specified project.",
|
||||
tags=["Members"],
|
||||
parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="List of project members with their roles",
|
||||
response=UserLiteSerializer,
|
||||
examples=[PROJECT_MEMBER_EXAMPLE],
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: PROJECT_NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
# Get all the users that are present inside the workspace
|
||||
def get(self, request, slug, project_id):
|
||||
"""List project members
|
||||
|
||||
Retrieve all users who are members of the specified project.
|
||||
Returns user profiles with their project-specific roles and access levels.
|
||||
"""
|
||||
# Check if the workspace exists
|
||||
if not Workspace.objects.filter(slug=slug).exists():
|
||||
return Response(
|
||||
{"error": "Provided workspace does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the workspace members that are present inside the workspace
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).values_list("member_id", flat=True)
|
||||
|
||||
# Get all the users that are present inside the workspace
|
||||
users = UserLiteSerializer(
|
||||
User.objects.filter(id__in=project_members), many=True
|
||||
).data
|
||||
|
||||
return Response(users, status=status.HTTP_200_OK)
|
||||
@@ -1,306 +0,0 @@
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import StateSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Issue, State
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.openapi import (
|
||||
state_docs,
|
||||
STATE_ID_PARAMETER,
|
||||
CURSOR_PARAMETER,
|
||||
PER_PAGE_PARAMETER,
|
||||
FIELDS_PARAMETER,
|
||||
EXPAND_PARAMETER,
|
||||
create_paginated_response,
|
||||
# Request Examples
|
||||
STATE_CREATE_EXAMPLE,
|
||||
STATE_UPDATE_EXAMPLE,
|
||||
# Response Examples
|
||||
STATE_EXAMPLE,
|
||||
INVALID_REQUEST_RESPONSE,
|
||||
STATE_NAME_EXISTS_RESPONSE,
|
||||
DELETED_RESPONSE,
|
||||
STATE_CANNOT_DELETE_RESPONSE,
|
||||
EXTERNAL_ID_EXISTS_RESPONSE,
|
||||
)
|
||||
|
||||
|
||||
class StateListCreateAPIEndpoint(BaseAPIView):
|
||||
"""State List and Create Endpoint"""
|
||||
|
||||
serializer_class = StateSerializer
|
||||
model = State
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
use_read_replica = True
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(is_triage=False)
|
||||
.filter(project__archived_at__isnull=True)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@state_docs(
|
||||
operation_id="create_state",
|
||||
summary="Create state",
|
||||
description="Create a new workflow state for a project with specified name, color, and group.",
|
||||
request=OpenApiRequest(
|
||||
request=StateSerializer,
|
||||
examples=[STATE_CREATE_EXAMPLE],
|
||||
),
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="State created",
|
||||
response=StateSerializer,
|
||||
examples=[STATE_EXAMPLE],
|
||||
),
|
||||
400: INVALID_REQUEST_RESPONSE,
|
||||
409: STATE_NAME_EXISTS_RESPONSE,
|
||||
},
|
||||
)
|
||||
def post(self, request, slug, project_id):
|
||||
"""Create state
|
||||
|
||||
Create a new workflow state for a project with specified name, color, and group.
|
||||
Supports external ID tracking for integration purposes.
|
||||
"""
|
||||
try:
|
||||
serializer = StateSerializer(
|
||||
data=request.data, context={"project_id": project_id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and State.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
state = State.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
external_id=request.data.get("external_id"),
|
||||
external_source=request.data.get("external_source"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "State with the same external id and external source already exists",
|
||||
"id": str(state.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
state = State.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
name=request.data.get("name"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "State with the same name already exists in the project",
|
||||
"id": str(state.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
@state_docs(
|
||||
operation_id="list_states",
|
||||
summary="List states",
|
||||
description="Retrieve all workflow states for a project.",
|
||||
parameters=[
|
||||
CURSOR_PARAMETER,
|
||||
PER_PAGE_PARAMETER,
|
||||
FIELDS_PARAMETER,
|
||||
EXPAND_PARAMETER,
|
||||
],
|
||||
responses={
|
||||
200: create_paginated_response(
|
||||
StateSerializer,
|
||||
"PaginatedStateResponse",
|
||||
"Paginated list of states",
|
||||
"Paginated States",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request, slug, project_id):
|
||||
"""List states
|
||||
|
||||
Retrieve all workflow states for a project.
|
||||
Returns paginated results when listing all states.
|
||||
"""
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda states: StateSerializer(
|
||||
states, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
|
||||
class StateDetailAPIEndpoint(BaseAPIView):
|
||||
"""State Detail Endpoint"""
|
||||
|
||||
serializer_class = StateSerializer
|
||||
model = State
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
use_read_replica = True
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(is_triage=False)
|
||||
.filter(project__archived_at__isnull=True)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@state_docs(
|
||||
operation_id="retrieve_state",
|
||||
summary="Retrieve state",
|
||||
description="Retrieve details of a specific state.",
|
||||
parameters=[
|
||||
STATE_ID_PARAMETER,
|
||||
],
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="State retrieved",
|
||||
response=StateSerializer,
|
||||
examples=[STATE_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request, slug, project_id, state_id):
|
||||
"""Retrieve state
|
||||
|
||||
Retrieve details of a specific state.
|
||||
Returns paginated results when listing all states.
|
||||
"""
|
||||
serializer = StateSerializer(
|
||||
self.get_queryset().get(pk=state_id),
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@state_docs(
|
||||
operation_id="delete_state",
|
||||
summary="Delete state",
|
||||
description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.",
|
||||
parameters=[
|
||||
STATE_ID_PARAMETER,
|
||||
],
|
||||
responses={
|
||||
204: DELETED_RESPONSE,
|
||||
400: STATE_CANNOT_DELETE_RESPONSE,
|
||||
},
|
||||
)
|
||||
def delete(self, request, slug, project_id, state_id):
|
||||
"""Delete state
|
||||
|
||||
Permanently remove a workflow state from a project.
|
||||
Default states and states with existing work items cannot be deleted.
|
||||
"""
|
||||
state = State.objects.get(
|
||||
is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
if state.default:
|
||||
return Response(
|
||||
{"error": "Default state cannot be deleted"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check for any issues in the state
|
||||
issue_exist = Issue.issue_objects.filter(state=state_id).exists()
|
||||
|
||||
if issue_exist:
|
||||
return Response(
|
||||
{"error": "The state is not empty, only empty states can be deleted"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
state.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@state_docs(
|
||||
operation_id="update_state",
|
||||
summary="Update state",
|
||||
description="Partially update an existing workflow state's properties like name, color, or group.",
|
||||
parameters=[
|
||||
STATE_ID_PARAMETER,
|
||||
],
|
||||
request=OpenApiRequest(
|
||||
request=StateSerializer,
|
||||
examples=[STATE_UPDATE_EXAMPLE],
|
||||
),
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="State updated",
|
||||
response=StateSerializer,
|
||||
examples=[STATE_EXAMPLE],
|
||||
),
|
||||
400: INVALID_REQUEST_RESPONSE,
|
||||
409: EXTERNAL_ID_EXISTS_RESPONSE,
|
||||
},
|
||||
)
|
||||
def patch(self, request, slug, project_id, state_id):
|
||||
"""Update state
|
||||
|
||||
Partially update an existing workflow state's properties like name, color, or group.
|
||||
Validates external ID uniqueness if provided.
|
||||
"""
|
||||
state = State.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=state_id
|
||||
)
|
||||
serializer = StateSerializer(state, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and (state.external_id != str(request.data.get("external_id")))
|
||||
and State.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", state.external_source
|
||||
),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "State with the same external id and external source already exists",
|
||||
"id": str(state.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1,37 +0,0 @@
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import OpenApiResponse
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import UserLiteSerializer
|
||||
from plane.api.views.base import BaseAPIView
|
||||
from plane.db.models import User
|
||||
from plane.utils.openapi.decorators import user_docs
|
||||
from plane.utils.openapi import USER_EXAMPLE
|
||||
|
||||
|
||||
class UserEndpoint(BaseAPIView):
|
||||
serializer_class = UserLiteSerializer
|
||||
model = User
|
||||
|
||||
@user_docs(
|
||||
operation_id="get_current_user",
|
||||
summary="Get current user",
|
||||
description="Retrieve the authenticated user's profile information including basic details.",
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Current user profile",
|
||||
response=UserLiteSerializer,
|
||||
examples=[USER_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request):
|
||||
"""Get current user
|
||||
|
||||
Retrieve the authenticated user's profile information including basic details.
|
||||
Returns user data based on the current authentication context.
|
||||
"""
|
||||
serializer = UserLiteSerializer(request.user)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -1,423 +0,0 @@
|
||||
# Python imports
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import List, Dict, Any, Callable, Optional
|
||||
import os
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import F, Window, Subquery
|
||||
from django.db.models.functions import RowNumber
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from pymongo.errors import BulkWriteError
|
||||
from pymongo.collection import Collection
|
||||
from pymongo.operations import InsertOne
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
EmailNotificationLog,
|
||||
PageVersion,
|
||||
APIActivityLog,
|
||||
IssueDescriptionVersion,
|
||||
)
|
||||
from plane.settings.mongo import MongoConnection
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def get_mongo_collection(collection_name: str) -> Optional[Collection]:
|
||||
"""Get MongoDB collection if available, otherwise return None."""
|
||||
if not MongoConnection.is_configured():
|
||||
logger.info("MongoDB not configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
mongo_collection = MongoConnection.get_collection(collection_name)
|
||||
logger.info(f"MongoDB collection '{collection_name}' connected successfully")
|
||||
return mongo_collection
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MongoDB collection: {str(e)}")
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
|
||||
def flush_to_mongo_and_delete(
|
||||
mongo_collection: Optional[Collection],
|
||||
buffer: List[Dict[str, Any]],
|
||||
ids_to_delete: List[int],
|
||||
model,
|
||||
mongo_available: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Inserts a batch of records into MongoDB and deletes the corresponding rows from PostgreSQL.
|
||||
"""
|
||||
if not buffer:
|
||||
logger.debug("No records to flush - buffer is empty")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Starting batch flush: {len(buffer)} records, {len(ids_to_delete)} IDs to delete"
|
||||
)
|
||||
|
||||
mongo_archival_failed = False
|
||||
|
||||
# Try to insert into MongoDB if available
|
||||
if mongo_collection and mongo_available:
|
||||
try:
|
||||
mongo_collection.bulk_write([InsertOne(doc) for doc in buffer])
|
||||
except BulkWriteError as bwe:
|
||||
logger.error(f"MongoDB bulk write error: {str(bwe)}")
|
||||
log_exception(bwe)
|
||||
mongo_archival_failed = True
|
||||
|
||||
# If MongoDB is available and archival failed, log the error and return
|
||||
if mongo_available and mongo_archival_failed:
|
||||
logger.error(f"MongoDB archival failed for {len(buffer)} records")
|
||||
return
|
||||
|
||||
# Delete from PostgreSQL - delete() returns (count, {model: count})
|
||||
delete_result = model.all_objects.filter(id__in=ids_to_delete).delete()
|
||||
deleted_count = (
|
||||
delete_result[0] if delete_result and isinstance(delete_result, tuple) else 0
|
||||
)
|
||||
logger.info(f"Batch flush completed: {deleted_count} records deleted")
|
||||
|
||||
|
||||
def process_cleanup_task(
|
||||
queryset_func: Callable,
|
||||
transform_func: Callable[[Dict], Dict],
|
||||
model,
|
||||
task_name: str,
|
||||
collection_name: str,
|
||||
):
|
||||
"""
|
||||
Generic function to process cleanup tasks.
|
||||
|
||||
Args:
|
||||
queryset_func: Function that returns the queryset to process
|
||||
transform_func: Function to transform each record for MongoDB
|
||||
model: Django model class
|
||||
task_name: Name of the task for logging
|
||||
collection_name: MongoDB collection name
|
||||
"""
|
||||
logger.info(f"Starting {task_name} cleanup task")
|
||||
|
||||
# Get MongoDB collection
|
||||
mongo_collection = get_mongo_collection(collection_name)
|
||||
mongo_available = mongo_collection is not None
|
||||
|
||||
# Get queryset
|
||||
queryset = queryset_func()
|
||||
|
||||
# Process records in batches
|
||||
buffer: List[Dict[str, Any]] = []
|
||||
ids_to_delete: List[int] = []
|
||||
total_processed = 0
|
||||
total_batches = 0
|
||||
|
||||
for record in queryset:
|
||||
# Transform record for MongoDB
|
||||
buffer.append(transform_func(record))
|
||||
ids_to_delete.append(record["id"])
|
||||
|
||||
# Flush batch when it reaches BATCH_SIZE
|
||||
if len(buffer) >= BATCH_SIZE:
|
||||
total_batches += 1
|
||||
flush_to_mongo_and_delete(
|
||||
mongo_collection=mongo_collection,
|
||||
buffer=buffer,
|
||||
ids_to_delete=ids_to_delete,
|
||||
model=model,
|
||||
mongo_available=mongo_available,
|
||||
)
|
||||
total_processed += len(buffer)
|
||||
buffer.clear()
|
||||
ids_to_delete.clear()
|
||||
|
||||
# Process final batch if any records remain
|
||||
if buffer:
|
||||
total_batches += 1
|
||||
flush_to_mongo_and_delete(
|
||||
mongo_collection=mongo_collection,
|
||||
buffer=buffer,
|
||||
ids_to_delete=ids_to_delete,
|
||||
model=model,
|
||||
mongo_available=mongo_available,
|
||||
)
|
||||
total_processed += len(buffer)
|
||||
|
||||
logger.info(
|
||||
f"{task_name} cleanup task completed",
|
||||
extra={
|
||||
"total_records_processed": total_processed,
|
||||
"total_batches": total_batches,
|
||||
"mongo_available": mongo_available,
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Transform functions for each model
|
||||
def transform_api_log(record: Dict) -> Dict:
|
||||
"""Transform API activity log record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"token_identifier": record["token_identifier"],
|
||||
"path": record["path"],
|
||||
"method": record["method"],
|
||||
"query_params": record.get("query_params"),
|
||||
"headers": record.get("headers"),
|
||||
"body": record.get("body"),
|
||||
"response_code": record["response_code"],
|
||||
"response_body": record["response_body"],
|
||||
"ip_address": record["ip_address"],
|
||||
"user_agent": record["user_agent"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
}
|
||||
|
||||
|
||||
def transform_email_log(record: Dict) -> Dict:
|
||||
"""Transform email notification log record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"receiver_id": record["receiver_id"],
|
||||
"triggered_by_id": record["triggered_by_id"],
|
||||
"entity_identifier": record["entity_identifier"],
|
||||
"entity_name": record["entity_name"],
|
||||
"data": record["data"],
|
||||
"processed_at": (
|
||||
str(record["processed_at"]) if record.get("processed_at") else None
|
||||
),
|
||||
"sent_at": str(record["sent_at"]) if record.get("sent_at") else None,
|
||||
"entity": record["entity"],
|
||||
"old_value": record["old_value"],
|
||||
"new_value": record["new_value"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
}
|
||||
|
||||
|
||||
def transform_page_version(record: Dict) -> Dict:
|
||||
"""Transform page version record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"page_id": record["page_id"],
|
||||
"workspace_id": record["workspace_id"],
|
||||
"owned_by_id": record["owned_by_id"],
|
||||
"description_html": record["description_html"],
|
||||
"description_binary": record["description_binary"],
|
||||
"description_stripped": record["description_stripped"],
|
||||
"description_json": record["description_json"],
|
||||
"sub_pages_data": record["sub_pages_data"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
"updated_by_id": record["updated_by_id"],
|
||||
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
|
||||
"last_saved_at": (
|
||||
str(record["last_saved_at"]) if record.get("last_saved_at") else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def transform_issue_description_version(record: Dict) -> Dict:
|
||||
"""Transform issue description version record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"issue_id": record["issue_id"],
|
||||
"workspace_id": record["workspace_id"],
|
||||
"project_id": record["project_id"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
"updated_by_id": record["updated_by_id"],
|
||||
"owned_by_id": record["owned_by_id"],
|
||||
"last_saved_at": (
|
||||
str(record["last_saved_at"]) if record.get("last_saved_at") else None
|
||||
),
|
||||
"description_binary": record["description_binary"],
|
||||
"description_html": record["description_html"],
|
||||
"description_stripped": record["description_stripped"],
|
||||
"description_json": record["description_json"],
|
||||
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
|
||||
}
|
||||
|
||||
|
||||
# Queryset functions for each cleanup task
|
||||
def get_api_logs_queryset():
|
||||
"""Get API logs older than cutoff days."""
|
||||
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
|
||||
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
|
||||
logger.info(f"API logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
APIActivityLog.all_objects.filter(created_at__lte=cutoff_time)
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"token_identifier",
|
||||
"path",
|
||||
"method",
|
||||
"query_params",
|
||||
"headers",
|
||||
"body",
|
||||
"response_code",
|
||||
"response_body",
|
||||
"ip_address",
|
||||
"user_agent",
|
||||
"created_by_id",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_email_logs_queryset():
|
||||
"""Get email logs older than cutoff days."""
|
||||
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
|
||||
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
|
||||
logger.info(f"Email logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
EmailNotificationLog.all_objects.filter(sent_at__lte=cutoff_time)
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"receiver_id",
|
||||
"triggered_by_id",
|
||||
"entity_identifier",
|
||||
"entity_name",
|
||||
"data",
|
||||
"processed_at",
|
||||
"sent_at",
|
||||
"entity",
|
||||
"old_value",
|
||||
"new_value",
|
||||
"created_by_id",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_page_versions_queryset():
|
||||
"""Get page versions beyond the maximum allowed (20 per page)."""
|
||||
subq = (
|
||||
PageVersion.all_objects.annotate(
|
||||
row_num=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F("page_id")],
|
||||
order_by=F("created_at").desc(),
|
||||
)
|
||||
)
|
||||
.filter(row_num__gt=20)
|
||||
.values("id")
|
||||
)
|
||||
|
||||
return (
|
||||
PageVersion.all_objects.filter(id__in=Subquery(subq))
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"page_id",
|
||||
"workspace_id",
|
||||
"owned_by_id",
|
||||
"description_html",
|
||||
"description_binary",
|
||||
"description_stripped",
|
||||
"description_json",
|
||||
"sub_pages_data",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
"deleted_at",
|
||||
"last_saved_at",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_issue_description_versions_queryset():
|
||||
"""Get issue description versions beyond the maximum allowed (20 per issue)."""
|
||||
subq = (
|
||||
IssueDescriptionVersion.all_objects.annotate(
|
||||
row_num=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F("issue_id")],
|
||||
order_by=F("created_at").desc(),
|
||||
)
|
||||
)
|
||||
.filter(row_num__gt=20)
|
||||
.values("id")
|
||||
)
|
||||
|
||||
return (
|
||||
IssueDescriptionVersion.all_objects.filter(id__in=Subquery(subq))
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"issue_id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
"owned_by_id",
|
||||
"last_saved_at",
|
||||
"description_binary",
|
||||
"description_html",
|
||||
"description_stripped",
|
||||
"description_json",
|
||||
"deleted_at",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
# Celery tasks - now much simpler!
|
||||
@shared_task
|
||||
def delete_api_logs():
|
||||
"""Delete old API activity logs."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_api_logs_queryset,
|
||||
transform_func=transform_api_log,
|
||||
model=APIActivityLog,
|
||||
task_name="API Activity Log",
|
||||
collection_name="api_activity_logs",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_email_notification_logs():
|
||||
"""Delete old email notification logs."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_email_logs_queryset,
|
||||
transform_func=transform_email_log,
|
||||
model=EmailNotificationLog,
|
||||
task_name="Email Notification Log",
|
||||
collection_name="email_notification_logs",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_page_versions():
|
||||
"""Delete excess page versions."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_page_versions_queryset,
|
||||
transform_func=transform_page_version,
|
||||
model=PageVersion,
|
||||
task_name="Page Version",
|
||||
collection_name="page_versions",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_issue_description_versions():
|
||||
"""Delete excess issue description versions."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_issue_description_versions_queryset,
|
||||
transform_func=transform_issue_description_version,
|
||||
model=IssueDescriptionVersion,
|
||||
task_name="Issue Description Version",
|
||||
collection_name="issue_description_versions",
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
# Generated by Django 4.2.21 on 2025-07-14 10:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0097_project_external_id_project_external_source'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='is_app_rail_docked',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='commentreaction',
|
||||
name='reaction',
|
||||
field=models.TextField(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issuereaction',
|
||||
name='reaction',
|
||||
field=models.TextField(),
|
||||
),
|
||||
]
|
||||
@@ -1,34 +0,0 @@
|
||||
# Generated by Django 4.2.22 on 2025-07-27 16:01
|
||||
|
||||
|
||||
from django.db import migrations, models
|
||||
import plane.utils.color
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0098_profile_is_app_rail_docked_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="background_color",
|
||||
field=models.CharField(
|
||||
default=plane.utils.color.get_random_color, max_length=255
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="goals",
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workspace",
|
||||
name="background_color",
|
||||
field=models.CharField(
|
||||
default=plane.utils.color.get_random_color, max_length=255
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,182 +0,0 @@
|
||||
# Generated by Django 4.2.21 on 2025-08-19 11:52
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0100_profile_has_marketing_email_consent_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Description",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Deleted At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("description_json", models.JSONField(blank=True, default=dict)),
|
||||
("description_html", models.TextField(blank=True, default="<p></p>")),
|
||||
("description_binary", models.BinaryField(null=True)),
|
||||
("description_stripped", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_%(class)s",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Description",
|
||||
"verbose_name_plural": "Descriptions",
|
||||
"db_table": "descriptions",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="DescriptionVersion",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Deleted At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("description_json", models.JSONField(blank=True, default=dict)),
|
||||
("description_html", models.TextField(blank=True, default="<p></p>")),
|
||||
("description_binary", models.BinaryField(null=True)),
|
||||
("description_stripped", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="versions",
|
||||
to="db.description",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_%(class)s",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Description Version",
|
||||
"verbose_name_plural": "Description Versions",
|
||||
"db_table": "description_versions",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,56 +0,0 @@
|
||||
from django.db import models
|
||||
from django.utils.html import strip_tags
|
||||
from .workspace import WorkspaceBaseModel
|
||||
|
||||
|
||||
class Description(WorkspaceBaseModel):
|
||||
|
||||
|
||||
description_json = models.JSONField(default=dict, blank=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Description"
|
||||
verbose_name_plural = "Descriptions"
|
||||
db_table = "descriptions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(Description, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class DescriptionVersion(WorkspaceBaseModel):
|
||||
"""
|
||||
DescriptionVersion is a model used to store historical versions of a Description.
|
||||
"""
|
||||
|
||||
description = models.ForeignKey(
|
||||
"db.Description", on_delete=models.CASCADE, related_name="versions"
|
||||
)
|
||||
description_json = models.JSONField(default=dict, blank=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Description Version"
|
||||
verbose_name_plural = "Description Versions"
|
||||
db_table = "description_versions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(DescriptionVersion, self).save(*args, **kwargs)
|
||||
@@ -1,162 +0,0 @@
|
||||
"""
|
||||
Database routing middleware for read replica selection.
|
||||
This middleware determines whether database queries should be routed to
|
||||
read replicas or the primary database based on HTTP method and view configuration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from plane.utils.core import (
|
||||
set_use_read_replica,
|
||||
clear_read_replica_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("plane.api")
|
||||
|
||||
|
||||
class ReadReplicaRoutingMiddleware:
|
||||
"""
|
||||
Middleware for intelligent database routing to read replicas.
|
||||
Routing Logic:
|
||||
• Non-GET requests (POST, PUT, DELETE, PATCH) ➜ Primary database
|
||||
• GET requests:
|
||||
- View has use_read_replica=False ➜ Primary database
|
||||
- View has use_read_replica=True ➜ Read replica
|
||||
- View has no use_read_replica attribute ➜ Primary database (safe default)
|
||||
The middleware supports both Django CBVs and DRF APIViews/ViewSets.
|
||||
Context is properly isolated per request to prevent data leakage.
|
||||
"""
|
||||
|
||||
# HTTP methods that are considered read-only by default
|
||||
READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
|
||||
def __init__(self, get_response):
|
||||
"""
|
||||
Initialize the middleware with the next middleware/view in the chain.
|
||||
Args:
|
||||
get_response: The next middleware or view function
|
||||
"""
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
"""
|
||||
Process the request and determine database routing.
|
||||
Args:
|
||||
request: The HTTP request object
|
||||
Returns:
|
||||
HttpResponse: The HTTP response from the view
|
||||
"""
|
||||
# For non-read operations, set primary database immediately
|
||||
if request.method not in self.READ_ONLY_METHODS:
|
||||
set_use_read_replica(False)
|
||||
logger.debug(f"Routing {request.method} {request.path} to primary database")
|
||||
|
||||
try:
|
||||
# Process the request through the middleware chain
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
finally:
|
||||
# Always clean up context, even if an exception occurs
|
||||
# This prevents context leakage between requests
|
||||
clear_read_replica_context()
|
||||
|
||||
def process_view(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
view_func: Callable,
|
||||
view_args: tuple,
|
||||
view_kwargs: dict,
|
||||
) -> None:
|
||||
"""
|
||||
Hook called just before Django calls the view.
|
||||
This is more efficient than resolving URLs in __call__ since Django
|
||||
provides the view function directly.
|
||||
Args:
|
||||
request: The HTTP request object
|
||||
view_func: The view function to be called
|
||||
view_args: Positional arguments for the view
|
||||
view_kwargs: Keyword arguments for the view
|
||||
"""
|
||||
# Only process read operations (write operations already handled in __call__)
|
||||
if request.method in self.READ_ONLY_METHODS:
|
||||
use_replica = self._should_use_read_replica(view_func)
|
||||
set_use_read_replica(use_replica)
|
||||
|
||||
db_type = "read replica" if use_replica else "primary database"
|
||||
logger.debug(f"Routing {request.method} {request.path} to {db_type}")
|
||||
|
||||
# Return None to continue normal request processing
|
||||
return None
|
||||
|
||||
def _should_use_read_replica(self, view_func: Callable) -> bool:
|
||||
"""
|
||||
Determine if the view should use read replica based on its configuration.
|
||||
Args:
|
||||
view_func: The view function to inspect
|
||||
Returns:
|
||||
bool: True if should use read replica, False for primary database
|
||||
"""
|
||||
use_replica_attr = self._get_use_replica_attribute(view_func)
|
||||
|
||||
# Default to primary database for GET requests if no explicit setting
|
||||
# This ensures only views that explicitly opt-in use read replicas
|
||||
if use_replica_attr is None:
|
||||
return False
|
||||
|
||||
return bool(use_replica_attr)
|
||||
|
||||
def _get_use_replica_attribute(self, view_func: Callable) -> Optional[bool]:
|
||||
"""
|
||||
Extract the use_read_replica attribute from various view types.
|
||||
Args:
|
||||
view_func: The view function to inspect
|
||||
Returns:
|
||||
Optional[bool]: The use_read_replica setting, or None if not found
|
||||
"""
|
||||
# Return None if view_func is None to prevent AttributeError
|
||||
if view_func is None:
|
||||
return None
|
||||
|
||||
# Check function-based view attribute
|
||||
use_replica = getattr(view_func, "use_read_replica", None)
|
||||
if use_replica is not None:
|
||||
return use_replica
|
||||
|
||||
# Check Django CBV wrapper
|
||||
if hasattr(view_func, "view_class"):
|
||||
use_replica = getattr(view_func.view_class, "use_read_replica", None)
|
||||
if use_replica is not None:
|
||||
return use_replica
|
||||
|
||||
# Check DRF wrapper (APIView / ViewSet)
|
||||
if hasattr(view_func, "cls"):
|
||||
use_replica = getattr(view_func.cls, "use_read_replica", None)
|
||||
if use_replica is not None:
|
||||
return use_replica
|
||||
|
||||
return None
|
||||
|
||||
def process_exception(self, request: HttpRequest, exception: Exception) -> None:
|
||||
"""
|
||||
Handle exceptions that occur during view processing.
|
||||
This provides an additional safety net for context cleanup when views
|
||||
raise exceptions, complementing the try/finally in __call__.
|
||||
Args:
|
||||
request: The HTTP request object
|
||||
exception: The exception that was raised
|
||||
Returns:
|
||||
None: Don't handle the exception, just clean up context
|
||||
"""
|
||||
# Clean up context on exception as a safety measure
|
||||
# The try/finally in __call__ should handle most cases, but this
|
||||
# provides extra protection specifically for view exceptions
|
||||
clear_read_replica_context()
|
||||
logger.debug(
|
||||
f"Cleaned up read replica context due to exception: {type(exception).__name__}"
|
||||
)
|
||||
|
||||
# Return None to let the exception continue propagating
|
||||
return None
|
||||
@@ -1,121 +0,0 @@
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from pymongo import MongoClient
|
||||
from pymongo.database import Database
|
||||
from pymongo.collection import Collection
|
||||
from typing import Optional, TypeVar, Type
|
||||
|
||||
|
||||
T = TypeVar("T", bound="MongoConnection")
|
||||
|
||||
# Set up logger
|
||||
logger = logging.getLogger("plane.mongo")
|
||||
|
||||
|
||||
class MongoConnection:
|
||||
"""
|
||||
A singleton class that manages MongoDB connections.
|
||||
|
||||
This class ensures only one MongoDB connection is maintained throughout the application.
|
||||
It provides methods to access the MongoDB client, database, and collections.
|
||||
|
||||
Attributes:
|
||||
_instance (Optional[MongoConnection]): The singleton instance of this class
|
||||
_client (Optional[MongoClient]): The MongoDB client instance
|
||||
_db (Optional[Database]): The MongoDB database instance
|
||||
"""
|
||||
|
||||
_instance: Optional["MongoConnection"] = None
|
||||
_client: Optional[MongoClient] = None
|
||||
_db: Optional[Database] = None
|
||||
|
||||
def __new__(cls: Type[T]) -> T:
|
||||
"""
|
||||
Creates a new instance of MongoConnection if one doesn't exist.
|
||||
|
||||
Returns:
|
||||
MongoConnection: The singleton instance
|
||||
"""
|
||||
if cls._instance is None:
|
||||
cls._instance = super(MongoConnection, cls).__new__(cls)
|
||||
try:
|
||||
mongo_url = getattr(settings, "MONGO_DB_URL", None)
|
||||
mongo_db_database = getattr(settings, "MONGO_DB_DATABASE", None)
|
||||
|
||||
if not mongo_url or not mongo_db_database:
|
||||
logger.warning(
|
||||
"MongoDB connection parameters not configured. MongoDB functionality will be disabled."
|
||||
)
|
||||
return cls._instance
|
||||
|
||||
cls._client = MongoClient(mongo_url)
|
||||
cls._db = cls._client[mongo_db_database]
|
||||
|
||||
# Test the connection
|
||||
cls._client.server_info()
|
||||
logger.info("MongoDB connection established successfully")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to initialize MongoDB connection: {str(e)}. MongoDB functionality will be disabled."
|
||||
)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def get_client(cls) -> Optional[MongoClient]:
|
||||
"""
|
||||
Returns the MongoDB client instance.
|
||||
|
||||
Returns:
|
||||
Optional[MongoClient]: The MongoDB client instance or None if not configured
|
||||
"""
|
||||
if cls._client is None:
|
||||
cls._instance = cls()
|
||||
return cls._client
|
||||
|
||||
@classmethod
|
||||
def get_db(cls) -> Optional[Database]:
|
||||
"""
|
||||
Returns the MongoDB database instance.
|
||||
|
||||
Returns:
|
||||
Optional[Database]: The MongoDB database instance or None if not configured
|
||||
"""
|
||||
if cls._db is None:
|
||||
cls._instance = cls()
|
||||
return cls._db
|
||||
|
||||
@classmethod
|
||||
def get_collection(cls, collection_name: str) -> Optional[Collection]:
|
||||
"""
|
||||
Returns a MongoDB collection by name.
|
||||
|
||||
Args:
|
||||
collection_name (str): The name of the collection to retrieve
|
||||
|
||||
Returns:
|
||||
Optional[Collection]: The MongoDB collection instance or None if not configured
|
||||
"""
|
||||
try:
|
||||
db = cls.get_db()
|
||||
if db is None:
|
||||
logger.warning(
|
||||
f"Cannot access collection '{collection_name}': MongoDB not configured"
|
||||
)
|
||||
return None
|
||||
return db[collection_name]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to access collection '{collection_name}': {str(e)}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def is_configured(cls) -> bool:
|
||||
"""
|
||||
Check if MongoDB is properly configured and connected.
|
||||
|
||||
Returns:
|
||||
bool: True if MongoDB is configured and connected, False otherwise
|
||||
"""
|
||||
return cls._client is not None and cls._db is not None
|
||||
@@ -1,272 +0,0 @@
|
||||
"""
|
||||
OpenAPI/Swagger configuration for drf-spectacular.
|
||||
|
||||
This file contains the complete configuration for API documentation generation.
|
||||
"""
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
# ========================================================================
|
||||
# Basic API Information
|
||||
# ========================================================================
|
||||
"TITLE": "The Plane REST API",
|
||||
"DESCRIPTION": (
|
||||
"The Plane REST API\n\n"
|
||||
"Visit our quick start guide and full API documentation at "
|
||||
"[developers.plane.so](https://developers.plane.so/api-reference/introduction)."
|
||||
),
|
||||
"CONTACT": {
|
||||
"name": "Plane",
|
||||
"url": "https://plane.so",
|
||||
"email": "support@plane.so",
|
||||
},
|
||||
"VERSION": "0.0.1",
|
||||
"LICENSE": {
|
||||
"name": "GNU AGPLv3",
|
||||
"url": "https://github.com/makeplane/plane/blob/preview/LICENSE.txt",
|
||||
},
|
||||
# ========================================================================
|
||||
# Schema Generation Settings
|
||||
# ========================================================================
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
"SCHEMA_PATH_PREFIX": "/api/v1/",
|
||||
"SCHEMA_CACHE_TIMEOUT": 0, # disables caching
|
||||
# ========================================================================
|
||||
# Processing Hooks
|
||||
# ========================================================================
|
||||
"PREPROCESSING_HOOKS": [
|
||||
"plane.utils.openapi.hooks.preprocess_filter_api_v1_paths",
|
||||
],
|
||||
# ========================================================================
|
||||
# Server Configuration
|
||||
# ========================================================================
|
||||
"SERVERS": [
|
||||
{"url": "http://localhost:8000", "description": "Local"},
|
||||
{"url": "https://api.plane.so", "description": "Production"},
|
||||
],
|
||||
# ========================================================================
|
||||
# API Tag Definitions
|
||||
# ========================================================================
|
||||
"TAGS": [
|
||||
# System Features
|
||||
{
|
||||
"name": "Assets",
|
||||
"description": (
|
||||
"**File Upload & Presigned URLs**\n\n"
|
||||
"Generate presigned URLs for direct file uploads to cloud storage. Handle user avatars, "
|
||||
"cover images, and generic project assets with secure upload workflows.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Generate presigned URLs for S3 uploads\n"
|
||||
"- Support for user avatars and cover images\n"
|
||||
"- Generic asset upload for projects\n"
|
||||
"- File validation and size limits\n\n"
|
||||
"*Use Cases:* User profile images, project file uploads, secure direct-to-cloud uploads."
|
||||
),
|
||||
},
|
||||
# Project Organization
|
||||
{
|
||||
"name": "Cycles",
|
||||
"description": (
|
||||
"**Sprint & Development Cycles**\n\n"
|
||||
"Create and manage development cycles (sprints) to organize work into time-boxed iterations. "
|
||||
"Track progress, assign work items, and monitor team velocity.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Create and configure development cycles\n"
|
||||
"- Assign work items to cycles\n"
|
||||
"- Track cycle progress and completion\n"
|
||||
"- Generate cycle analytics and reports\n\n"
|
||||
"*Use Cases:* Sprint planning, iterative development, progress tracking, team velocity."
|
||||
),
|
||||
},
|
||||
# System Features
|
||||
{
|
||||
"name": "Intake",
|
||||
"description": (
|
||||
"**Work Item Intake Queue**\n\n"
|
||||
"Manage incoming work items through a dedicated intake queue for triage and review. "
|
||||
"Submit, update, and process work items before they enter the main project workflow.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Submit work items to intake queue\n"
|
||||
"- Review and triage incoming work items\n"
|
||||
"- Update intake work item status and properties\n"
|
||||
"- Accept, reject, or modify work items before approval\n\n"
|
||||
"*Use Cases:* Work item triage, external submissions, quality review, approval workflows."
|
||||
),
|
||||
},
|
||||
# Project Organization
|
||||
{
|
||||
"name": "Labels",
|
||||
"description": (
|
||||
"**Labels & Tags**\n\n"
|
||||
"Create and manage labels to categorize and organize work items. Use color-coded labels "
|
||||
"for easy identification, filtering, and project organization.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Create custom labels with colors and descriptions\n"
|
||||
"- Apply labels to work items for categorization\n"
|
||||
"- Filter and search by labels\n"
|
||||
"- Organize labels across projects\n\n"
|
||||
"*Use Cases:* Priority marking, feature categorization, bug classification, team organization."
|
||||
),
|
||||
},
|
||||
# Team & User Management
|
||||
{
|
||||
"name": "Members",
|
||||
"description": (
|
||||
"**Team Member Management**\n\n"
|
||||
"Manage team members, roles, and permissions within projects and workspaces. "
|
||||
"Control access levels and track member participation.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Invite and manage team members\n"
|
||||
"- Assign roles and permissions\n"
|
||||
"- Control project and workspace access\n"
|
||||
"- Track member activity and participation\n\n"
|
||||
"*Use Cases:* Team setup, access control, role management, collaboration."
|
||||
),
|
||||
},
|
||||
# Project Organization
|
||||
{
|
||||
"name": "Modules",
|
||||
"description": (
|
||||
"**Feature Modules**\n\n"
|
||||
"Group related work items into modules for better organization and tracking. "
|
||||
"Plan features, track progress, and manage deliverables at a higher level.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Create and organize feature modules\n"
|
||||
"- Group work items by module\n"
|
||||
"- Track module progress and completion\n"
|
||||
"- Manage module leads and assignments\n\n"
|
||||
"*Use Cases:* Feature planning, release organization, progress tracking, team coordination."
|
||||
),
|
||||
},
|
||||
# Core Project Management
|
||||
{
|
||||
"name": "Projects",
|
||||
"description": (
|
||||
"**Project Management**\n\n"
|
||||
"Create and manage projects to organize your development work. Configure project settings, "
|
||||
"manage team access, and control project visibility.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Create, update, and delete projects\n"
|
||||
"- Configure project settings and preferences\n"
|
||||
"- Manage team access and permissions\n"
|
||||
"- Control project visibility and sharing\n\n"
|
||||
"*Use Cases:* Project setup, team collaboration, access control, project configuration."
|
||||
),
|
||||
},
|
||||
# Project Organization
|
||||
{
|
||||
"name": "States",
|
||||
"description": (
|
||||
"**Workflow States**\n\n"
|
||||
"Define custom workflow states for work items to match your team's process. "
|
||||
"Configure state transitions and track work item progress through different stages.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Create custom workflow states\n"
|
||||
"- Configure state transitions and rules\n"
|
||||
"- Track work item progress through states\n"
|
||||
"- Set state-based permissions and automation\n\n"
|
||||
"*Use Cases:* Custom workflows, status tracking, process automation, progress monitoring."
|
||||
),
|
||||
},
|
||||
# Team & User Management
|
||||
{
|
||||
"name": "Users",
|
||||
"description": (
|
||||
"**Current User Information**\n\n"
|
||||
"Get information about the currently authenticated user including profile details "
|
||||
"and account settings.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Retrieve current user profile\n"
|
||||
"- Access user account information\n"
|
||||
"- View user preferences and settings\n"
|
||||
"- Get authentication context\n\n"
|
||||
"*Use Cases:* Profile display, user context, account information, authentication status."
|
||||
),
|
||||
},
|
||||
# Work Item Management
|
||||
{
|
||||
"name": "Work Item Activity",
|
||||
"description": (
|
||||
"**Activity History & Search**\n\n"
|
||||
"View activity history and search for work items across the workspace. "
|
||||
"Get detailed activity logs and find work items using text search.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- View work item activity history\n"
|
||||
"- Search work items across workspace\n"
|
||||
"- Track changes and modifications\n"
|
||||
"- Filter search results by project\n\n"
|
||||
"*Use Cases:* Activity tracking, work item discovery, change history, workspace search."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Work Item Attachments",
|
||||
"description": (
|
||||
"**Work Item File Attachments**\n\n"
|
||||
"Generate presigned URLs for uploading files directly to specific work items. "
|
||||
"Upload and manage attachments associated with work items.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Generate presigned URLs for work item attachments\n"
|
||||
"- Upload files directly to work items\n"
|
||||
"- Retrieve and manage attachment metadata\n"
|
||||
"- Delete attachments from work items\n\n"
|
||||
"*Use Cases:* Screenshots, error logs, design files, supporting documents."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Work Item Comments",
|
||||
"description": (
|
||||
"**Comments & Discussions**\n\n"
|
||||
"Add comments and discussions to work items for team collaboration. "
|
||||
"Support threaded conversations, mentions, and rich text formatting.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Add comments to work items\n"
|
||||
"- Thread conversations and replies\n"
|
||||
"- Mention users and trigger notifications\n"
|
||||
"- Rich text and markdown support\n\n"
|
||||
"*Use Cases:* Team discussions, progress updates, code reviews, decision tracking."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Work Item Links",
|
||||
"description": (
|
||||
"**External Links & References**\n\n"
|
||||
"Link work items to external resources like documentation, repositories, or design files. "
|
||||
"Maintain connections between work items and external systems.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Add external URL links to work items\n"
|
||||
"- Validate and preview linked resources\n"
|
||||
"- Organize links by type and category\n"
|
||||
"- Track link usage and access\n\n"
|
||||
"*Use Cases:* Documentation links, repository connections, design references, external tools."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Work Items",
|
||||
"description": (
|
||||
"**Work Items & Tasks**\n\n"
|
||||
"Create and manage work items like tasks, bugs, features, and user stories. "
|
||||
"The core entities for tracking work in your projects.\n\n"
|
||||
"*Key Features:*\n"
|
||||
"- Create, update, and manage work items\n"
|
||||
"- Assign to team members and set priorities\n"
|
||||
"- Track progress through workflow states\n"
|
||||
"- Set due dates, estimates, and relationships\n\n"
|
||||
"*Use Cases:* Bug tracking, task management, feature development, sprint planning."
|
||||
),
|
||||
},
|
||||
],
|
||||
# ========================================================================
|
||||
# Security & Authentication
|
||||
# ========================================================================
|
||||
"AUTHENTICATION_WHITELIST": [
|
||||
"plane.api.middleware.api_authentication.APIKeyAuthentication",
|
||||
],
|
||||
# ========================================================================
|
||||
# Schema Generation Options
|
||||
# ========================================================================
|
||||
"COMPONENT_NO_READ_ONLY_REQUIRED": True,
|
||||
"COMPONENT_SPLIT_REQUEST": True,
|
||||
"ENUM_NAME_OVERRIDES": {
|
||||
"ModuleStatusEnum": "plane.db.models.module.ModuleStatus",
|
||||
"IntakeWorkItemStatusEnum": "plane.db.models.intake.IntakeIssueStatus",
|
||||
},
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
from .user import UserLiteSerializer
|
||||
|
||||
from .issue import LabelLiteSerializer, IssuePublicSerializer
|
||||
|
||||
from .state import StateSerializer
|
||||
@@ -1,228 +0,0 @@
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from django.db import IntegrityError
|
||||
from uuid import uuid4
|
||||
|
||||
from plane.db.models import Label, Project, ProjectMember
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""Create a test project with the user as a member"""
|
||||
project = Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20, # Admin role
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def label_data():
|
||||
"""Sample label data for tests"""
|
||||
return {
|
||||
"name": "Test Label",
|
||||
"color": "#FF5733",
|
||||
"description": "A test label for unit tests",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_label(db, project, create_user):
|
||||
"""Create a test label"""
|
||||
return Label.objects.create(
|
||||
name="Existing Label",
|
||||
color="#00FF00",
|
||||
description="An existing label",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestLabelListCreateAPIEndpoint:
|
||||
"""Test Label List and Create API Endpoint"""
|
||||
|
||||
def get_label_url(self, workspace_slug, project_id):
|
||||
"""Helper to get label endpoint URL"""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_success(self, api_key_client, workspace, project, label_data):
|
||||
"""Test successful label creation"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
response = api_key_client.post(url, label_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert Label.objects.count() == 1
|
||||
|
||||
created_label = Label.objects.first()
|
||||
assert created_label.name == label_data["name"]
|
||||
assert created_label.color == label_data["color"]
|
||||
assert created_label.description == label_data["description"]
|
||||
assert created_label.project == project
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_invalid_data(self, api_key_client, workspace, project):
|
||||
"""Test label creation with invalid data"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
# Test with empty data
|
||||
response = api_key_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Test with missing name
|
||||
response = api_key_client.post(url, {"color": "#FF5733"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_with_external_id(self, api_key_client, workspace, project):
|
||||
"""Test creating label with external ID"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
label_data = {
|
||||
"name": "External Label",
|
||||
"color": "#FF5733",
|
||||
"external_id": "ext-123",
|
||||
"external_source": "github",
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, label_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
created_label = Label.objects.first()
|
||||
assert created_label.external_id == "ext-123"
|
||||
assert created_label.external_source == "github"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_duplicate_external_id(
|
||||
self, api_key_client, workspace, project
|
||||
):
|
||||
"""Test creating label with duplicate external ID"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
# Create first label
|
||||
Label.objects.create(
|
||||
name="First Label",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
external_id="ext-123",
|
||||
external_source="github",
|
||||
)
|
||||
|
||||
# Try to create second label with same external ID
|
||||
label_data = {
|
||||
"name": "Second Label",
|
||||
"external_id": "ext-123",
|
||||
"external_source": "github",
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, label_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert "same external id" in response.data["error"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_labels_success(
|
||||
self, api_key_client, workspace, project, create_label
|
||||
):
|
||||
"""Test successful label listing"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
# Create additional labels
|
||||
Label.objects.create(
|
||||
name="Label 2", project=project, workspace=workspace, color="#00FF00"
|
||||
)
|
||||
Label.objects.create(
|
||||
name="Label 3", project=project, workspace=workspace, color="#0000FF"
|
||||
)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "results" in response.data
|
||||
assert len(response.data["results"]) == 3 # Including create_label fixture
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestLabelDetailAPIEndpoint:
|
||||
"""Test Label Detail API Endpoint"""
|
||||
|
||||
def get_label_detail_url(self, workspace_slug, project_id, label_id):
|
||||
"""Helper to get label detail endpoint URL"""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/{label_id}/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_label_success(self, api_key_client, workspace, project, create_label):
|
||||
"""Test successful label retrieval"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["id"] == create_label.id
|
||||
assert response.data["name"] == create_label.name
|
||||
assert response.data["color"] == create_label.color
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_label_not_found(self, api_key_client, workspace, project):
|
||||
"""Test getting non-existent label"""
|
||||
from uuid import uuid4
|
||||
|
||||
fake_id = uuid4()
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, fake_id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_label_success(
|
||||
self, api_key_client, workspace, project, create_label
|
||||
):
|
||||
"""Test successful label update"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
update_data = {
|
||||
"name": f"Updated Label {uuid4()}",
|
||||
}
|
||||
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
create_label.refresh_from_db()
|
||||
assert create_label.name == update_data["name"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_label_invalid_data(
|
||||
self, api_key_client, workspace, project, create_label
|
||||
):
|
||||
"""Test label update with invalid data"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
update_data = {"name": ""}
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
# This might be 400 if name is required, or 200 if empty names are allowed
|
||||
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_label_success(
|
||||
self, api_key_client, workspace, project, create_label
|
||||
):
|
||||
"""Test successful label deletion"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
response = api_key_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Label.objects.filter(id=create_label.id).exists()
|
||||
@@ -1,182 +0,0 @@
|
||||
import pytest
|
||||
from plane.db.models import Project, ProjectMember, Issue, FileAsset
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plane.bgtasks.copy_s3_object import (
|
||||
copy_s3_objects_of_description_and_assets,
|
||||
copy_assets,
|
||||
)
|
||||
import base64
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCopyS3Objects:
|
||||
"""Test the copy_s3_objects_of_description_and_assets function"""
|
||||
|
||||
@pytest.fixture
|
||||
def project(self, create_user, workspace):
|
||||
project = Project.objects.create(
|
||||
name="Test Project", identifier="test-project", workspace=workspace
|
||||
)
|
||||
|
||||
ProjectMember.objects.create(project=project, member=create_user)
|
||||
return project
|
||||
|
||||
@pytest.fixture
|
||||
def issue(self, workspace, project):
|
||||
return Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project_id=project.id,
|
||||
description_html=f'<div><image-component src="35e8b958-6ee5-43ce-ae56-fb0e776f421e"></image-component><image-component src="97988198-274f-4dfe-aa7a-4c0ffc684214"></image-component></div>',
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def file_asset(self, workspace, project, issue):
|
||||
return FileAsset.objects.create(
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
asset="workspace1/test-asset-1.jpg",
|
||||
attributes={
|
||||
"name": "test-asset-1.jpg",
|
||||
"size": 100,
|
||||
"type": "image/jpeg",
|
||||
},
|
||||
id="35e8b958-6ee5-43ce-ae56-fb0e776f421e",
|
||||
entity_type="ISSUE_DESCRIPTION",
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_s3_objects_of_description_and_assets(
|
||||
self, mock_s3_storage, create_user, workspace, project, issue, file_asset
|
||||
):
|
||||
FileAsset.objects.create(
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
asset="workspace1/test-asset-2.pdf",
|
||||
attributes={
|
||||
"name": "test-asset-2.pdf",
|
||||
"size": 100,
|
||||
"type": "application/pdf",
|
||||
},
|
||||
id="97988198-274f-4dfe-aa7a-4c0ffc684214",
|
||||
entity_type="ISSUE_DESCRIPTION",
|
||||
)
|
||||
|
||||
issue.save()
|
||||
|
||||
# Set up mock S3 storage
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
|
||||
# Mock the external service call to avoid actual HTTP requests
|
||||
with patch(
|
||||
"plane.bgtasks.copy_s3_object.sync_with_external_service"
|
||||
) as mock_sync:
|
||||
mock_sync.return_value = {
|
||||
"description": "test description",
|
||||
"description_binary": base64.b64encode(b"test binary").decode(),
|
||||
}
|
||||
|
||||
# Call the actual function (not .delay())
|
||||
copy_s3_objects_of_description_and_assets(
|
||||
"ISSUE", issue.id, project.id, "test-workspace", create_user.id
|
||||
)
|
||||
|
||||
# Assert that copy_object was called for each asset
|
||||
assert mock_storage_instance.copy_object.call_count == 2
|
||||
|
||||
# Get the updated issue and its new assets
|
||||
updated_issue = Issue.objects.get(id=issue.id)
|
||||
new_assets = FileAsset.objects.filter(
|
||||
issue=updated_issue,
|
||||
entity_type="ISSUE_DESCRIPTION",
|
||||
)
|
||||
|
||||
# Verify new assets were created
|
||||
assert new_assets.count() == 4 # 2 original + 2 copied
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_assets_successful(
|
||||
self, mock_s3_storage, workspace, project, issue, file_asset
|
||||
):
|
||||
"""Test successful copying of assets"""
|
||||
# Arrange
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
|
||||
# Act
|
||||
result = copy_assets(
|
||||
entity=issue,
|
||||
entity_identifier=issue.id,
|
||||
project_id=project.id,
|
||||
asset_ids=[file_asset.id],
|
||||
user_id=issue.created_by_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Verify S3 copy was called
|
||||
mock_storage_instance.copy_object.assert_called_once()
|
||||
|
||||
# Verify new asset was created
|
||||
assert len(result) == 1
|
||||
new_asset_id = result[0]["new_asset_id"]
|
||||
new_asset = FileAsset.objects.get(id=new_asset_id)
|
||||
|
||||
# Verify asset properties were copied correctly
|
||||
assert new_asset.workspace == workspace
|
||||
assert new_asset.project_id == project.id
|
||||
assert new_asset.entity_type == file_asset.entity_type
|
||||
assert new_asset.attributes == file_asset.attributes
|
||||
assert new_asset.size == file_asset.size
|
||||
assert new_asset.is_uploaded is True
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_assets_empty_asset_ids(
|
||||
self, mock_s3_storage, workspace, project, issue
|
||||
):
|
||||
"""Test copying with empty asset_ids list"""
|
||||
# Arrange
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
|
||||
# Act
|
||||
result = copy_assets(
|
||||
entity=issue,
|
||||
entity_identifier=issue.id,
|
||||
project_id=project.id,
|
||||
asset_ids=[],
|
||||
user_id=issue.created_by_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
mock_storage_instance.copy_object.assert_not_called()
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_assets_nonexistent_asset(
|
||||
self, mock_s3_storage, workspace, project, issue
|
||||
):
|
||||
"""Test copying with non-existent asset ID"""
|
||||
# Arrange
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
non_existent_id = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
# Act
|
||||
result = copy_assets(
|
||||
entity=issue,
|
||||
entity_identifier=issue.id,
|
||||
project_id=project.id,
|
||||
asset_ids=[non_existent_id],
|
||||
user_id=issue.created_by_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
mock_storage_instance.copy_object.assert_not_called()
|
||||
@@ -1,433 +0,0 @@
|
||||
"""
|
||||
Unit tests for ReadReplicaRoutingMiddleware.
|
||||
This module contains comprehensive tests for the ReadReplicaRoutingMiddleware
|
||||
that handles intelligent database routing to read replicas based on HTTP methods
|
||||
and view configuration.
|
||||
Test Organization:
|
||||
- TestReadReplicaRoutingMiddleware: Core middleware functionality
|
||||
- TestProcessView: process_view method behavior
|
||||
- TestReplicaDecisionLogic: Decision logic for replica usage
|
||||
- TestAttributeDetection: View attribute detection methods
|
||||
- TestExceptionHandling: Exception handling and cleanup
|
||||
- TestRealViewIntegration: Real Django/DRF view integration
|
||||
- TestEdgeCases: Edge cases and error conditions
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
from django.views import View
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from plane.middleware.db_routing import ReadReplicaRoutingMiddleware
|
||||
|
||||
|
||||
# Pytest fixtures
|
||||
@pytest.fixture
|
||||
def mock_get_response():
|
||||
"""Fixture for mocked get_response callable."""
|
||||
return Mock(return_value=HttpResponse())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def middleware(mock_get_response):
|
||||
"""Fixture for ReadReplicaRoutingMiddleware instance."""
|
||||
return ReadReplicaRoutingMiddleware(mock_get_response)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def request_factory():
|
||||
"""Fixture for Django RequestFactory."""
|
||||
return RequestFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_view_func():
|
||||
"""Fixture for a basic mocked view function."""
|
||||
view = Mock()
|
||||
view.use_read_replica = True
|
||||
return view
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_request(request_factory):
|
||||
"""Fixture for a GET request."""
|
||||
return request_factory.get("/api/test/")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post_request(request_factory):
|
||||
"""Fixture for a POST request."""
|
||||
return request_factory.post("/api/test/")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReadReplicaRoutingMiddleware:
|
||||
"""Test cases for ReadReplicaRoutingMiddleware core functionality."""
|
||||
|
||||
def test_middleware_initialization(self, middleware, mock_get_response):
|
||||
"""Test middleware initializes correctly with expected attributes."""
|
||||
assert middleware.get_response == mock_get_response
|
||||
assert hasattr(middleware, "READ_ONLY_METHODS")
|
||||
assert "GET" in middleware.READ_ONLY_METHODS
|
||||
assert "HEAD" in middleware.READ_ONLY_METHODS
|
||||
assert "OPTIONS" in middleware.READ_ONLY_METHODS
|
||||
|
||||
def test_read_only_methods_constant(self, middleware):
|
||||
"""Test READ_ONLY_METHODS contains expected HTTP methods."""
|
||||
expected_methods = {"GET", "HEAD", "OPTIONS"}
|
||||
assert middleware.READ_ONLY_METHODS == expected_methods
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_routes_write_methods_to_primary(
|
||||
self, mock_clear, mock_set, middleware, post_request, mock_get_response
|
||||
):
|
||||
"""Test __call__ routes write methods to primary database."""
|
||||
response = middleware(post_request)
|
||||
|
||||
mock_set.assert_called_once_with(False) # Primary database
|
||||
mock_clear.assert_called_once()
|
||||
assert response == mock_get_response.return_value
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_with_read_methods_waits_for_process_view(
|
||||
self, mock_clear, middleware, get_request, mock_get_response
|
||||
):
|
||||
"""Test __call__ with read methods waits for process_view."""
|
||||
response = middleware(get_request)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
assert response == mock_get_response.return_value
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_always_cleans_up_context(self, mock_clear, middleware, get_request):
|
||||
"""Test __call__ always cleans up context."""
|
||||
middleware(get_request)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_cleans_up_context_on_exception(
|
||||
self, mock_clear, middleware, get_request, mock_get_response
|
||||
):
|
||||
"""Test __call__ cleans up context even if get_response raises."""
|
||||
mock_get_response.side_effect = Exception("Test exception")
|
||||
|
||||
with pytest.raises(Exception, match="Test exception"):
|
||||
middleware(get_request)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestProcessView:
|
||||
"""Test cases for process_view method functionality."""
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_read_method_and_replica_true(self, mock_set, middleware, get_request):
|
||||
"""Test process_view with GET request and use_read_replica=True."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
|
||||
result = middleware.process_view(get_request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
assert result is None
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_read_method_and_replica_false(
|
||||
self, mock_set, middleware, get_request
|
||||
):
|
||||
"""Test process_view with GET request and use_read_replica=False."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = False
|
||||
|
||||
result = middleware.process_view(get_request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(False)
|
||||
assert result is None
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_read_method_and_no_replica_attribute(
|
||||
self, mock_set, middleware, get_request
|
||||
):
|
||||
"""Test process_view with GET request and no use_read_replica attr."""
|
||||
view_func = Mock(spec=[]) # No use_read_replica attribute
|
||||
|
||||
result = middleware.process_view(get_request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(False) # Default to primary
|
||||
assert result is None
|
||||
|
||||
def test_with_write_method_ignores_view_attributes(self, middleware, post_request):
|
||||
"""Test process_view with write methods ignores view attributes."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True # This should be ignored for POST
|
||||
|
||||
result = middleware.process_view(post_request, view_func, (), {})
|
||||
|
||||
assert result is None # Should not process for write methods
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReplicaDecisionLogic:
|
||||
"""Test cases for replica decision logic methods."""
|
||||
|
||||
def test_should_use_read_replica_with_true_attribute(self, middleware):
|
||||
"""Test _should_use_read_replica returns True for True attribute."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_should_use_read_replica_with_false_attribute(self, middleware):
|
||||
"""Test _should_use_read_replica returns False for False attribute."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = False
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_should_use_read_replica_with_no_attribute_defaults_false(self, middleware):
|
||||
"""Test _should_use_read_replica defaults to False for missing attr."""
|
||||
view_func = Mock(spec=[]) # No use_read_replica attribute
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAttributeDetection:
|
||||
"""Test cases for view attribute detection methods."""
|
||||
|
||||
def test_get_use_replica_attribute_function_based_view(self, middleware):
|
||||
"""Test _get_use_replica_attribute with function-based view."""
|
||||
# Test with True
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
assert result is True
|
||||
|
||||
# Test with False
|
||||
view_func.use_read_replica = False
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
assert result is False
|
||||
|
||||
# Test with no attribute
|
||||
view_func = Mock(spec=[])
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
assert result is None
|
||||
|
||||
def test_get_use_replica_attribute_django_cbv(self, middleware):
|
||||
"""Test _get_use_replica_attribute with Django CBV wrapper."""
|
||||
view_class = Mock()
|
||||
view_class.use_read_replica = True
|
||||
view_func = Mock()
|
||||
view_func.view_class = view_class
|
||||
# Remove use_read_replica from view_func to ensure it checks view_class
|
||||
del view_func.use_read_replica
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_get_use_replica_attribute_drf_wrapper(self, middleware):
|
||||
"""Test _get_use_replica_attribute with DRF wrapper."""
|
||||
|
||||
# Create a real object to avoid Mock issues
|
||||
class ViewClass:
|
||||
use_read_replica = True
|
||||
|
||||
class ViewFunc:
|
||||
cls = ViewClass()
|
||||
|
||||
view_func = ViewFunc()
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_get_use_replica_attribute_priority_order(self, middleware):
|
||||
"""Test attribute priority: direct > view_class > cls."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True # Direct attribute (highest priority)
|
||||
|
||||
# Add conflicting attributes with lower priority
|
||||
view_class = Mock()
|
||||
view_class.use_read_replica = False
|
||||
view_func.view_class = view_class
|
||||
|
||||
cls = Mock()
|
||||
cls.use_read_replica = False
|
||||
view_func.cls = cls
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is True # Should use direct attribute
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
(True, True),
|
||||
(False, False),
|
||||
(1, True),
|
||||
(0, False),
|
||||
("yes", True),
|
||||
("", False),
|
||||
([], False),
|
||||
([1], True),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_should_use_read_replica_truthy_falsy_values(
|
||||
self, middleware, value, expected
|
||||
):
|
||||
"""Test _should_use_read_replica with various truthy/falsy values."""
|
||||
|
||||
# Create a real object to test the attribute handling
|
||||
class TestView:
|
||||
pass
|
||||
|
||||
view_func = TestView()
|
||||
view_func.use_read_replica = value
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExceptionHandling:
|
||||
"""Test cases for exception handling and cleanup."""
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_process_exception_cleans_up_context(
|
||||
self, mock_clear, middleware, request_factory
|
||||
):
|
||||
"""Test process_exception cleans up context."""
|
||||
request = request_factory.get("/api/test/")
|
||||
exception = Exception("Test exception")
|
||||
|
||||
result = middleware.process_exception(request, exception)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
assert result is None # Don't handle the exception
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_integration_full_request_cycle(
|
||||
self, mock_clear, mock_set, middleware, request_factory, mock_get_response
|
||||
):
|
||||
"""Test complete request cycle from __call__ through process_view."""
|
||||
request = request_factory.get("/api/test/")
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
|
||||
# Call middleware and process_view manually
|
||||
response = middleware(request)
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
mock_clear.assert_called_once()
|
||||
assert response == mock_get_response.return_value
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRealViewIntegration:
|
||||
"""Test middleware with real Django/DRF view classes."""
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_django_class_based_view(self, mock_set, middleware, request_factory):
|
||||
"""Test middleware with actual Django CBV."""
|
||||
|
||||
class TestView(View):
|
||||
use_read_replica = True
|
||||
|
||||
# Simulate Django's URL resolver creating a view wrapper
|
||||
view_func = TestView.as_view()
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_drf_api_view(self, mock_set, middleware, request_factory):
|
||||
"""Test middleware with DRF APIView."""
|
||||
|
||||
class TestAPIView(APIView):
|
||||
use_read_replica = True
|
||||
|
||||
# Simulate DRF's URL pattern creating a view wrapper
|
||||
view_func = TestAPIView.as_view()
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_drf_viewset(self, mock_set, middleware, request_factory):
|
||||
"""Test middleware with DRF ViewSet."""
|
||||
|
||||
class TestViewSet(ViewSet):
|
||||
use_read_replica = True
|
||||
|
||||
# Simulate DRF router creating viewset action
|
||||
view_func = TestViewSet.as_view({"get": "list"})
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error conditions."""
|
||||
|
||||
def test_process_view_with_none_view_func(self, middleware, request_factory):
|
||||
"""Test process_view handles None view_func gracefully."""
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
result = middleware.process_view(request, None, (), {})
|
||||
|
||||
assert result is None # Should not crash
|
||||
|
||||
def test_get_use_replica_attribute_with_attribute_error(self, middleware):
|
||||
"""Test _get_use_replica_attribute with view that raises AttributeError."""
|
||||
|
||||
# Create a view class that raises AttributeError on access
|
||||
class ProblematicView:
|
||||
def __getattr__(self, name):
|
||||
if name == "use_read_replica":
|
||||
raise AttributeError("Simulated attribute error")
|
||||
raise AttributeError(
|
||||
f"'{type(self).__name__}' object has no attribute '{name}'"
|
||||
)
|
||||
|
||||
view_func = ProblematicView()
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is None # Should handle gracefully
|
||||
|
||||
def test_multiple_exception_calls_are_safe(self, middleware, request_factory):
|
||||
"""Test that multiple calls to process_exception don't cause issues."""
|
||||
request = request_factory.get("/api/test/")
|
||||
exception = Exception("Test exception")
|
||||
|
||||
# Call multiple times
|
||||
result1 = middleware.process_exception(request, exception)
|
||||
result2 = middleware.process_exception(request, exception)
|
||||
|
||||
assert result1 is None # Both should return None safely
|
||||
assert result2 is None
|
||||
@@ -1,262 +0,0 @@
|
||||
import pytest
|
||||
from plane.utils.url import (
|
||||
contains_url,
|
||||
is_valid_url,
|
||||
get_url_components,
|
||||
normalize_url_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestContainsURL:
|
||||
"""Test the contains_url function"""
|
||||
|
||||
def test_contains_url_with_http_protocol(self):
|
||||
"""Test contains_url with HTTP protocol URLs"""
|
||||
assert contains_url("Check out http://example.com") is True
|
||||
assert contains_url("Visit http://google.com/search") is True
|
||||
assert contains_url("http://localhost:8000") is True
|
||||
|
||||
def test_contains_url_with_https_protocol(self):
|
||||
"""Test contains_url with HTTPS protocol URLs"""
|
||||
assert contains_url("Check out https://example.com") is True
|
||||
assert contains_url("Visit https://google.com/search") is True
|
||||
assert contains_url("https://secure.example.com") is True
|
||||
|
||||
def test_contains_url_with_www_prefix(self):
|
||||
"""Test contains_url with www prefix"""
|
||||
assert contains_url("Visit www.example.com") is True
|
||||
assert contains_url("Check www.google.com") is True
|
||||
assert contains_url("Go to www.test-site.org") is True
|
||||
|
||||
def test_contains_url_with_domain_patterns(self):
|
||||
"""Test contains_url with domain patterns"""
|
||||
assert contains_url("Visit example.com") is True
|
||||
assert contains_url("Check google.org") is True
|
||||
assert contains_url("Go to test-site.co.uk") is True
|
||||
assert contains_url("Visit sub.domain.com") is True
|
||||
|
||||
def test_contains_url_with_ip_addresses(self):
|
||||
"""Test contains_url with IP addresses"""
|
||||
assert contains_url("Connect to 192.168.1.1") is True
|
||||
assert contains_url("Visit 10.0.0.1") is True
|
||||
assert contains_url("Check 127.0.0.1") is True
|
||||
assert contains_url("Go to 8.8.8.8") is True
|
||||
|
||||
def test_contains_url_case_insensitive(self):
|
||||
"""Test contains_url is case insensitive"""
|
||||
assert contains_url("Check HTTP://EXAMPLE.COM") is True
|
||||
assert contains_url("Visit WWW.GOOGLE.COM") is True
|
||||
assert contains_url("Go to Https://Test.Com") is True
|
||||
|
||||
def test_contains_url_with_no_urls(self):
|
||||
"""Test contains_url with text that doesn't contain URLs"""
|
||||
assert contains_url("This is just plain text") is False
|
||||
assert contains_url("No URLs here!") is False
|
||||
assert contains_url("com org net") is False # Just TLD words
|
||||
assert contains_url("192.168") is False # Incomplete IP
|
||||
assert contains_url("") is False # Empty string
|
||||
|
||||
def test_contains_url_edge_cases(self):
|
||||
"""Test contains_url with edge cases"""
|
||||
assert contains_url("example.c") is False # TLD too short
|
||||
assert contains_url("999.999.999.999") is False # Invalid IP (octets > 255)
|
||||
assert contains_url("just-a-hyphen") is False # No domain
|
||||
assert (
|
||||
contains_url("www.") is False
|
||||
) # Incomplete www - needs at least one char after dot
|
||||
|
||||
def test_contains_url_length_limit_under_1000(self):
|
||||
"""Test contains_url with input under 1000 characters containing URLs"""
|
||||
# Create a string under 1000 characters with a URL
|
||||
text_with_url = "a" * 970 + " https://example.com" # 970 + 1 + 19 = 990 chars
|
||||
assert len(text_with_url) < 1000
|
||||
assert contains_url(text_with_url) is True
|
||||
|
||||
# Test with exactly 1000 characters
|
||||
text_exact_1000 = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars
|
||||
assert len(text_exact_1000) == 1000
|
||||
assert contains_url(text_exact_1000) is True
|
||||
|
||||
def test_contains_url_length_limit_over_1000(self):
|
||||
"""Test contains_url with input over 1000 characters returns False"""
|
||||
# Create a string over 1000 characters with a URL
|
||||
text_with_url = "a" * 982 + "https://example.com" # 982 + 19 = 1001 chars
|
||||
assert len(text_with_url) > 1000
|
||||
assert contains_url(text_with_url) is False
|
||||
|
||||
# Test with much longer input
|
||||
long_text_with_url = "a" * 5000 + " https://example.com"
|
||||
assert contains_url(long_text_with_url) is False
|
||||
|
||||
def test_contains_url_length_limit_exactly_1000(self):
|
||||
"""Test contains_url with input exactly 1000 characters"""
|
||||
# Test with exactly 1000 characters without URL
|
||||
text_no_url = "a" * 1000
|
||||
assert len(text_no_url) == 1000
|
||||
assert contains_url(text_no_url) is False
|
||||
|
||||
# Test with exactly 1000 characters with URL at the end
|
||||
text_with_url = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars
|
||||
assert len(text_with_url) == 1000
|
||||
assert contains_url(text_with_url) is True
|
||||
|
||||
def test_contains_url_line_length_scenarios(self):
|
||||
"""Test contains_url with realistic line length scenarios"""
|
||||
# Test with multiline input where total is under 1000 but we test line processing
|
||||
# Short lines with URL
|
||||
multiline_short = "Line 1\nLine 2 with https://example.com\nLine 3"
|
||||
assert contains_url(multiline_short) is True
|
||||
|
||||
# Multiple lines under total limit
|
||||
multiline_text = (
|
||||
"a" * 200 + "\n" + "b" * 200 + "https://example.com\n" + "c" * 200
|
||||
)
|
||||
assert len(multiline_text) < 1000
|
||||
assert contains_url(multiline_text) is True
|
||||
|
||||
def test_contains_url_total_length_vs_line_length(self):
|
||||
"""Test the interaction between total length limit and line processing"""
|
||||
# Test that total length limit takes precedence
|
||||
# Even if individual lines would be processed, total > 1000 means immediate False
|
||||
over_limit_text = "a" * 1001 # No URL, but over total limit
|
||||
assert contains_url(over_limit_text) is False
|
||||
|
||||
# Test that under total limit, line processing works normally
|
||||
under_limit_with_url = "a" * 900 + "https://example.com" # 919 chars total
|
||||
assert len(under_limit_with_url) < 1000
|
||||
assert contains_url(under_limit_with_url) is True
|
||||
|
||||
def test_contains_url_multiline_mixed_lengths(self):
|
||||
"""Test contains_url with multiple lines of different lengths"""
|
||||
# Test realistic multiline scenario under 1000 chars total
|
||||
multiline_text = (
|
||||
"Short line\n"
|
||||
+ "a" * 400
|
||||
+ "https://example.com\n" # Line with URL
|
||||
+ "b" * 300 # Another line
|
||||
)
|
||||
assert len(multiline_text) < 1000
|
||||
assert contains_url(multiline_text) is True
|
||||
|
||||
# Test multiline without URLs
|
||||
multiline_no_url = "Short line\n" + "a" * 400 + "\n" + "b" * 300
|
||||
assert len(multiline_no_url) < 1000
|
||||
assert contains_url(multiline_no_url) is False
|
||||
|
||||
def test_contains_url_edge_cases_with_length_limits(self):
|
||||
"""Test contains_url edge cases related to length limits"""
|
||||
# Empty string
|
||||
assert contains_url("") is False
|
||||
|
||||
# Very short string with URL
|
||||
assert contains_url("http://a.co") is True
|
||||
|
||||
# String with newlines and mixed content
|
||||
mixed_content = "Line 1\nLine 2 with https://example.com\nLine 3"
|
||||
assert contains_url(mixed_content) is True
|
||||
|
||||
# String with many newlines under total limit
|
||||
many_newlines = "\n" * 500 + "https://example.com"
|
||||
assert len(many_newlines) < 1000
|
||||
assert contains_url(many_newlines) is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIsValidURL:
|
||||
"""Test the is_valid_url function"""
|
||||
|
||||
def test_is_valid_url_with_valid_urls(self):
|
||||
"""Test is_valid_url with valid URLs"""
|
||||
assert is_valid_url("https://example.com") is True
|
||||
assert is_valid_url("http://google.com") is True
|
||||
assert is_valid_url("https://sub.domain.com/path") is True
|
||||
assert is_valid_url("http://localhost:8000") is True
|
||||
assert is_valid_url("https://example.com/path?query=1") is True
|
||||
assert is_valid_url("ftp://files.example.com") is True
|
||||
|
||||
def test_is_valid_url_with_invalid_urls(self):
|
||||
"""Test is_valid_url with invalid URLs"""
|
||||
assert is_valid_url("not a url") is False
|
||||
assert is_valid_url("example.com") is False # No scheme
|
||||
assert is_valid_url("https://") is False # No netloc
|
||||
assert is_valid_url("") is False # Empty string
|
||||
assert is_valid_url("://example.com") is False # No scheme
|
||||
assert is_valid_url("https:/example.com") is False # Malformed
|
||||
|
||||
def test_is_valid_url_with_non_string_input(self):
|
||||
"""Test is_valid_url with non-string input"""
|
||||
assert is_valid_url(None) is False
|
||||
assert is_valid_url([]) is False
|
||||
assert is_valid_url({}) is False
|
||||
|
||||
def test_is_valid_url_with_special_schemes(self):
|
||||
"""Test is_valid_url with special URL schemes"""
|
||||
assert is_valid_url("ftp://ftp.example.com") is True
|
||||
assert is_valid_url("mailto:user@example.com") is False
|
||||
assert is_valid_url("file:///path/to/file") is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNormalizeURLPath:
|
||||
"""Test the normalize_url_path function"""
|
||||
|
||||
def test_normalize_url_path_with_multiple_slashes(self):
|
||||
"""Test normalize_url_path with multiple consecutive slashes"""
|
||||
result = normalize_url_path("https://example.com//foo///bar//baz")
|
||||
assert result == "https://example.com/foo/bar/baz"
|
||||
|
||||
def test_normalize_url_path_with_query_and_fragment(self):
|
||||
"""Test normalize_url_path preserves query and fragment"""
|
||||
result = normalize_url_path(
|
||||
"https://example.com//foo///bar//baz?x=1&y=2#fragment"
|
||||
)
|
||||
assert result == "https://example.com/foo/bar/baz?x=1&y=2#fragment"
|
||||
|
||||
def test_normalize_url_path_with_no_redundant_slashes(self):
|
||||
"""Test normalize_url_path with already normalized URL"""
|
||||
url = "https://example.com/foo/bar/baz?x=1#fragment"
|
||||
result = normalize_url_path(url)
|
||||
assert result == url
|
||||
|
||||
def test_normalize_url_path_with_root_path(self):
|
||||
"""Test normalize_url_path with root path"""
|
||||
result = normalize_url_path("https://example.com//")
|
||||
assert result == "https://example.com/"
|
||||
|
||||
def test_normalize_url_path_with_empty_path(self):
|
||||
"""Test normalize_url_path with empty path"""
|
||||
result = normalize_url_path("https://example.com")
|
||||
assert result == "https://example.com"
|
||||
|
||||
def test_normalize_url_path_with_complex_path(self):
|
||||
"""Test normalize_url_path with complex path structure"""
|
||||
result = normalize_url_path(
|
||||
"https://example.com///api//v1///users//123//profile"
|
||||
)
|
||||
assert result == "https://example.com/api/v1/users/123/profile"
|
||||
|
||||
def test_normalize_url_path_with_different_schemes(self):
|
||||
"""Test normalize_url_path with different URL schemes"""
|
||||
# HTTP
|
||||
result = normalize_url_path("http://example.com//path")
|
||||
assert result == "http://example.com/path"
|
||||
|
||||
# FTP
|
||||
result = normalize_url_path("ftp://ftp.example.com//files//document.txt")
|
||||
assert result == "ftp://ftp.example.com/files/document.txt"
|
||||
|
||||
def test_normalize_url_path_with_port(self):
|
||||
"""Test normalize_url_path with port number"""
|
||||
result = normalize_url_path("https://example.com:8080//api//v1")
|
||||
assert result == "https://example.com:8080/api/v1"
|
||||
|
||||
def test_normalize_url_path_edge_cases(self):
|
||||
"""Test normalize_url_path with edge cases"""
|
||||
# Many consecutive slashes
|
||||
result = normalize_url_path("https://example.com///////path")
|
||||
assert result == "https://example.com/path"
|
||||
|
||||
# Mixed single and multiple slashes
|
||||
result = normalize_url_path("https://example.com/a//b/c///d")
|
||||
assert result == "https://example.com/a/b/c/d"
|
||||
@@ -1,9 +0,0 @@
|
||||
import random
|
||||
import string
|
||||
|
||||
|
||||
def get_random_color():
|
||||
"""
|
||||
Get a random color in hex format
|
||||
"""
|
||||
return "#" + "".join(random.choices(string.hexdigits, k=6))
|
||||
@@ -1,339 +0,0 @@
|
||||
# Python imports
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
# Maximum allowed size for binary data (10MB)
|
||||
MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
# Maximum recursion depth to prevent stack overflow
|
||||
MAX_RECURSION_DEPTH = 20
|
||||
|
||||
# Dangerous text patterns that could indicate XSS or script injection
|
||||
DANGEROUS_TEXT_PATTERNS = [
|
||||
r"<script[^>]*>.*?</script>",
|
||||
r"javascript\s*:",
|
||||
r"data\s*:\s*text/html",
|
||||
r"eval\s*\(",
|
||||
r"document\s*\.",
|
||||
r"window\s*\.",
|
||||
r"location\s*\.",
|
||||
]
|
||||
|
||||
# Dangerous attribute patterns for HTML attributes
|
||||
DANGEROUS_ATTR_PATTERNS = [
|
||||
r"javascript\s*:",
|
||||
r"data\s*:\s*text/html",
|
||||
r"eval\s*\(",
|
||||
r"alert\s*\(",
|
||||
r"document\s*\.",
|
||||
r"window\s*\.",
|
||||
]
|
||||
|
||||
# Suspicious patterns for binary data content
|
||||
SUSPICIOUS_BINARY_PATTERNS = [
|
||||
"<html",
|
||||
"<!doctype",
|
||||
"<script",
|
||||
"javascript:",
|
||||
"data:",
|
||||
"<iframe",
|
||||
]
|
||||
|
||||
# Malicious HTML patterns for content validation
|
||||
MALICIOUS_HTML_PATTERNS = [
|
||||
# Script tags with any content
|
||||
r"<script[^>]*>",
|
||||
r"</script>",
|
||||
# JavaScript URLs in various attributes
|
||||
r'(?:href|src|action)\s*=\s*["\']?\s*javascript:',
|
||||
# Data URLs with text/html (potential XSS)
|
||||
r'(?:href|src|action)\s*=\s*["\']?\s*data:text/html',
|
||||
# Dangerous event handlers with JavaScript-like content
|
||||
r'on(?:load|error|click|focus|blur|change|submit|reset|select|resize|scroll|unload|beforeunload|hashchange|popstate|storage|message|offline|online)\s*=\s*["\']?[^"\']*(?:javascript|alert|eval|document\.|window\.|location\.|history\.)[^"\']*["\']?',
|
||||
# Object and embed tags that could load external content
|
||||
r"<(?:object|embed)[^>]*(?:data|src)\s*=",
|
||||
# Base tag that could change relative URL resolution
|
||||
r"<base[^>]*href\s*=",
|
||||
# Dangerous iframe sources
|
||||
r'<iframe[^>]*src\s*=\s*["\']?(?:javascript:|data:text/html)',
|
||||
# Meta refresh redirects
|
||||
r'<meta[^>]*http-equiv\s*=\s*["\']?refresh["\']?',
|
||||
# Link tags - simplified patterns
|
||||
r'<link[^>]*rel\s*=\s*["\']?stylesheet["\']?',
|
||||
r'<link[^>]*href\s*=\s*["\']?https?://',
|
||||
r'<link[^>]*href\s*=\s*["\']?//',
|
||||
r'<link[^>]*href\s*=\s*["\']?(?:data:|javascript:)',
|
||||
# Style tags with external imports
|
||||
r"<style[^>]*>.*?@import.*?(?:https?://|//)",
|
||||
# Link tags with dangerous rel types
|
||||
r'<link[^>]*rel\s*=\s*["\']?(?:import|preload|prefetch|dns-prefetch|preconnect)["\']?',
|
||||
# Forms with action attributes
|
||||
r"<form[^>]*action\s*=",
|
||||
]
|
||||
|
||||
# Dangerous JavaScript patterns for event handlers
|
||||
DANGEROUS_JS_PATTERNS = [
|
||||
r"alert\s*\(",
|
||||
r"eval\s*\(",
|
||||
r"document\s*\.",
|
||||
r"window\s*\.",
|
||||
r"location\s*\.",
|
||||
r"fetch\s*\(",
|
||||
r"XMLHttpRequest",
|
||||
r"innerHTML\s*=",
|
||||
r"outerHTML\s*=",
|
||||
r"document\.write",
|
||||
r"script\s*>",
|
||||
]
|
||||
|
||||
# HTML self-closing tags that don't need closing tags
|
||||
SELF_CLOSING_TAGS = {
|
||||
"img",
|
||||
"br",
|
||||
"hr",
|
||||
"input",
|
||||
"meta",
|
||||
"link",
|
||||
"area",
|
||||
"base",
|
||||
"col",
|
||||
"embed",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
}
|
||||
|
||||
|
||||
def validate_binary_data(data):
|
||||
"""
|
||||
Validate that binary data appears to be valid document format and doesn't contain malicious content.
|
||||
|
||||
Args:
|
||||
data (bytes or str): The binary data to validate, or base64-encoded string
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid: bool, error_message: str or None)
|
||||
"""
|
||||
if not data:
|
||||
return True, None # Empty is OK
|
||||
|
||||
# Handle base64-encoded strings by decoding them first
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
binary_data = base64.b64decode(data)
|
||||
except Exception:
|
||||
return False, "Invalid base64 encoding"
|
||||
else:
|
||||
binary_data = data
|
||||
|
||||
# Size check - 10MB limit
|
||||
if len(binary_data) > MAX_SIZE:
|
||||
return False, "Binary data exceeds maximum size limit (10MB)"
|
||||
|
||||
# Basic format validation
|
||||
if len(binary_data) < 4:
|
||||
return False, "Binary data too short to be valid document format"
|
||||
|
||||
# Check for suspicious text patterns (HTML/JS)
|
||||
try:
|
||||
decoded_text = binary_data.decode("utf-8", errors="ignore")[:200]
|
||||
if any(
|
||||
pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS
|
||||
):
|
||||
return False, "Binary data contains suspicious content patterns"
|
||||
except Exception:
|
||||
pass # Binary data might not be decodable as text, which is fine
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_html_content(html_content):
|
||||
"""
|
||||
Validate that HTML content is safe and doesn't contain malicious patterns.
|
||||
|
||||
Args:
|
||||
html_content (str): The HTML content to validate
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid: bool, error_message: str or None)
|
||||
"""
|
||||
if not html_content:
|
||||
return True, None # Empty is OK
|
||||
|
||||
# Size check - 10MB limit (consistent with binary validation)
|
||||
if len(html_content.encode("utf-8")) > MAX_SIZE:
|
||||
return False, "HTML content exceeds maximum size limit (10MB)"
|
||||
|
||||
# Check for specific malicious patterns (simplified and more reliable)
|
||||
for pattern in MALICIOUS_HTML_PATTERNS:
|
||||
if re.search(pattern, html_content, re.IGNORECASE | re.DOTALL):
|
||||
return (
|
||||
False,
|
||||
f"HTML content contains potentially malicious patterns: {pattern}",
|
||||
)
|
||||
|
||||
# Additional check for inline event handlers that contain suspicious content
|
||||
# This is more permissive - only blocks if the event handler contains actual dangerous code
|
||||
event_handler_pattern = r'on\w+\s*=\s*["\']([^"\']*)["\']'
|
||||
event_matches = re.findall(event_handler_pattern, html_content, re.IGNORECASE)
|
||||
|
||||
for handler_content in event_matches:
|
||||
for js_pattern in DANGEROUS_JS_PATTERNS:
|
||||
if re.search(js_pattern, handler_content, re.IGNORECASE):
|
||||
return (
|
||||
False,
|
||||
f"HTML content contains dangerous JavaScript in event handler: {handler_content[:100]}",
|
||||
)
|
||||
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_json_content(json_content):
|
||||
"""
|
||||
Validate that JSON content is safe and doesn't contain malicious patterns.
|
||||
|
||||
Args:
|
||||
json_content (dict): The JSON content to validate
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid: bool, error_message: str or None)
|
||||
"""
|
||||
if not json_content:
|
||||
return True, None # Empty is OK
|
||||
|
||||
try:
|
||||
# Size check - 10MB limit (consistent with other validations)
|
||||
json_str = json.dumps(json_content)
|
||||
if len(json_str.encode("utf-8")) > MAX_SIZE:
|
||||
return False, "JSON content exceeds maximum size limit (10MB)"
|
||||
|
||||
# Basic structure validation for page description JSON
|
||||
if isinstance(json_content, dict):
|
||||
# Check for expected page description structure
|
||||
# This is based on ProseMirror/Tiptap JSON structure
|
||||
if "type" in json_content and json_content.get("type") == "doc":
|
||||
# Valid document structure
|
||||
if "content" in json_content and isinstance(
|
||||
json_content["content"], list
|
||||
):
|
||||
# Recursively check content for suspicious patterns
|
||||
is_valid, error_msg = _validate_json_content_array(
|
||||
json_content["content"]
|
||||
)
|
||||
if not is_valid:
|
||||
return False, error_msg
|
||||
elif "type" not in json_content and "content" not in json_content:
|
||||
# Allow other JSON structures but validate for suspicious content
|
||||
is_valid, error_msg = _validate_json_content_recursive(json_content)
|
||||
if not is_valid:
|
||||
return False, error_msg
|
||||
else:
|
||||
return False, "JSON description must be a valid object"
|
||||
|
||||
except (TypeError, ValueError) as e:
|
||||
return False, "Invalid JSON structure"
|
||||
except Exception as e:
|
||||
return False, "Failed to validate JSON content"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def _validate_json_content_array(content, depth=0):
|
||||
"""
|
||||
Validate JSON content array for suspicious patterns.
|
||||
|
||||
Args:
|
||||
content (list): Array of content nodes to validate
|
||||
depth (int): Current recursion depth (default: 0)
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid: bool, error_message: str or None)
|
||||
"""
|
||||
# Check recursion depth to prevent stack overflow
|
||||
if depth > MAX_RECURSION_DEPTH:
|
||||
return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded"
|
||||
|
||||
if not isinstance(content, list):
|
||||
return True, None
|
||||
|
||||
for node in content:
|
||||
if isinstance(node, dict):
|
||||
# Check text content for suspicious patterns (more targeted)
|
||||
if node.get("type") == "text" and "text" in node:
|
||||
text_content = node["text"]
|
||||
for pattern in DANGEROUS_TEXT_PATTERNS:
|
||||
if re.search(pattern, text_content, re.IGNORECASE):
|
||||
return (
|
||||
False,
|
||||
"JSON content contains suspicious script patterns in text",
|
||||
)
|
||||
|
||||
# Check attributes for suspicious content (more targeted)
|
||||
if "attrs" in node and isinstance(node["attrs"], dict):
|
||||
for attr_name, attr_value in node["attrs"].items():
|
||||
if isinstance(attr_value, str):
|
||||
# Only check specific attributes that could be dangerous
|
||||
if attr_name.lower() in [
|
||||
"href",
|
||||
"src",
|
||||
"action",
|
||||
"onclick",
|
||||
"onload",
|
||||
"onerror",
|
||||
]:
|
||||
for pattern in DANGEROUS_ATTR_PATTERNS:
|
||||
if re.search(pattern, attr_value, re.IGNORECASE):
|
||||
return (
|
||||
False,
|
||||
f"JSON content contains dangerous pattern in {attr_name} attribute",
|
||||
)
|
||||
|
||||
# Recursively check nested content
|
||||
if "content" in node and isinstance(node["content"], list):
|
||||
is_valid, error_msg = _validate_json_content_array(
|
||||
node["content"], depth + 1
|
||||
)
|
||||
if not is_valid:
|
||||
return False, error_msg
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def _validate_json_content_recursive(obj, depth=0):
|
||||
"""
|
||||
Recursively validate JSON object for suspicious content.
|
||||
|
||||
Args:
|
||||
obj: JSON object (dict, list, or primitive) to validate
|
||||
depth (int): Current recursion depth (default: 0)
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid: bool, error_message: str or None)
|
||||
"""
|
||||
# Check recursion depth to prevent stack overflow
|
||||
if depth > MAX_RECURSION_DEPTH:
|
||||
return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded"
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if isinstance(value, str):
|
||||
# Check for dangerous patterns using module constants
|
||||
for pattern in DANGEROUS_TEXT_PATTERNS:
|
||||
if re.search(pattern, value, re.IGNORECASE):
|
||||
return (
|
||||
False,
|
||||
"JSON content contains suspicious script patterns",
|
||||
)
|
||||
elif isinstance(value, (dict, list)):
|
||||
is_valid, error_msg = _validate_json_content_recursive(value, depth + 1)
|
||||
if not is_valid:
|
||||
return False, error_msg
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
is_valid, error_msg = _validate_json_content_recursive(item, depth + 1)
|
||||
if not is_valid:
|
||||
return False, error_msg
|
||||
|
||||
return True, None
|
||||
@@ -1,21 +0,0 @@
|
||||
"""
|
||||
Core utilities for Plane database routing and request scoping.
|
||||
This package contains essential components for managing read replica routing
|
||||
and request-scoped context in the Plane application.
|
||||
"""
|
||||
|
||||
from .dbrouters import ReadReplicaRouter
|
||||
from .mixins import ReadReplicaControlMixin
|
||||
from .request_scope import (
|
||||
set_use_read_replica,
|
||||
should_use_read_replica,
|
||||
clear_read_replica_context,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ReadReplicaRouter",
|
||||
"ReadReplicaControlMixin",
|
||||
"set_use_read_replica",
|
||||
"should_use_read_replica",
|
||||
"clear_read_replica_context",
|
||||
]
|
||||