Compare commits

..
Author SHA1 Message Date
NarayanBavisetti 0c3f5636ba chore: support cyclic characters in subject 2025-07-03 16:35:28 +05:30
2780 changed files with 30084 additions and 55261 deletions
-45
View File
@@ -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
+3 -13
View File
@@ -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
+139
View File
@@ -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 }}
+207
View File
@@ -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 }}
+15 -120
View File
@@ -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
+3 -7
View File
@@ -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
-34
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
lts/jod
+1
View File
@@ -0,0 +1 @@
nodeLinker: node-modules
+40 -51
View File
@@ -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.
+88
View File
@@ -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.
+40 -23
View File
@@ -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 | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://developers.plane.so/self-hosting/methods/docker-compose) |
| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](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 teams momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
Maintain your teams 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
[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)
[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/)
[![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](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).
+182
View File
@@ -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"]
+73
View File
@@ -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"]
+45
View File
@@ -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"]
+7
View File
@@ -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;'
+72
View File
@@ -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/;
}
}
}
+14
View File
@@ -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
+815
View File
@@ -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
+71
View File
@@ -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
+38
View File
@@ -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
+14
View File
@@ -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
+73
View File
@@ -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 -2
View File
@@ -2,5 +2,5 @@
.vercel
.tubro
out/
dist/
build/
dis/
build/
+6 -17
View File
@@ -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
+4 -4
View File
@@ -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(() =>
+20 -65
View File
@@ -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.&nbsp;
<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.&nbsp;
<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>
</>
);
+1 -1
View File
@@ -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>
);
+28 -2
View File
@@ -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>
);
}
+28 -6
View File
@@ -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);
+70 -85
View File
@@ -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>
);
};
+17 -21
View File
@@ -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>
);
};
+204 -199
View File
@@ -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>
);
};
-27
View File
@@ -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;
}
};
}
+22 -22
View File
@@ -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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 919 B

+2 -2
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 954 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+81 -37
View File
@@ -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 {
-12
View File
@@ -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,
)
-123
View File
@@ -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",
]
-140
View File
@@ -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"
)
-270
View File
@@ -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
-40
View File
@@ -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",
),
]
-97
View File
@@ -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",
),
]
-16
View File
@@ -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",
),
]
-47
View File
@@ -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",
),
]
-27
View File
@@ -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",
),
]
-20
View File
@@ -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",
),
]
-11
View File
@@ -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",
),
]
-55
View File
@@ -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
-631
View File
@@ -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
)
File diff suppressed because it is too large Load Diff
-137
View File
@@ -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)
-306
View File
@@ -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)
-37
View File
@@ -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)
-423
View File
@@ -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",),
},
),
]
-56
View File
@@ -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)
-162
View File
@@ -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
-121
View File
@@ -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
-272
View File
@@ -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
-262
View File
@@ -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"
View File
-9
View File
@@ -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))
-339
View File
@@ -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
-21
View File
@@ -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",
]

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