Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e40f75770 | |||
| 27fc28b7dc | |||
| 0082eff4a0 | |||
| 3557bc024b | |||
| 7c86fbc554 | |||
| 57c25c9a5a | |||
| c593d5df1b | |||
| 9065b5d368 | |||
| a9e2e21641 | |||
| e175d50ab7 | |||
| 75b8e3350a | |||
| 6e1cd4194a | |||
| 615ccf9459 | |||
| 13362590b6 | |||
| 7833ca7bea | |||
| a1d27a1bf0 | |||
| 8fbd4a059b | |||
| e751686683 | |||
| 8ee5ba96ce | |||
| 9e8885df5f | |||
| bc48010377 | |||
| 9fde539b1d | |||
| ec26bf6e68 | |||
| e9ef3fb32a | |||
| ee2c7c5fa1 | |||
| d64ae9a2e4 | |||
| f58a00a4ab | |||
| a3e5284f71 | |||
| 1c06c3f43e | |||
| da1496fe65 | |||
| 3d489e186f | |||
| 57d5ff7646 | |||
| 3c9926d383 | |||
| ece4d5b1ed | |||
| 73eed69aa6 | |||
| 09603cf189 | |||
| 23e53df3ad | |||
| 57594aac4e | |||
| 8b884ab681 | |||
| 08e5f2b156 | |||
| cb3a73e515 | |||
| eef9edff24 | |||
| cb2a7d0930 | |||
| c38e048ce8 | |||
| 94b72effbf | |||
| eccb1f5d10 | |||
| a71491ecb9 | |||
| 455c2cc787 | |||
| 44dc602ac3 | |||
| 81f6557908 | |||
| 2f10f35191 | |||
| cf64c7bbc6 | |||
| 9dd8c8ba14 | |||
| bb4bee00cb | |||
| d8f1404462 | |||
| 927ab50ac6 | |||
| d98b688342 | |||
| ce21630388 | |||
| 0927fa150c | |||
| eec411baaf | |||
| ecc8fbd79b | |||
| c9b628e578 |
@@ -1,30 +1,61 @@
|
||||
name: Branch Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch_name:
|
||||
description: "Branch Name"
|
||||
required: true
|
||||
default: "preview"
|
||||
push:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- master
|
||||
- preview
|
||||
- qa
|
||||
- develop
|
||||
- release-*
|
||||
release:
|
||||
types: [released, prereleased]
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }}
|
||||
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }}
|
||||
|
||||
jobs:
|
||||
branch_build_setup:
|
||||
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }}
|
||||
name: Build-Push Web/Space/API/Proxy Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.3.0
|
||||
|
||||
- name: Uploading Proxy Source
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: proxy-src-code
|
||||
path: ./nginx
|
||||
- name: Uploading Backend Source
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: backend-src-code
|
||||
path: ./apiserver
|
||||
- name: Uploading Web Source
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: web-src-code
|
||||
path: |
|
||||
./
|
||||
!./apiserver
|
||||
!./nginx
|
||||
!./deploy
|
||||
!./space
|
||||
- name: Uploading Space Source
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: space-src-code
|
||||
path: |
|
||||
./
|
||||
!./apiserver
|
||||
!./nginx
|
||||
!./deploy
|
||||
!./web
|
||||
outputs:
|
||||
gh_branch_name: ${{ env.TARGET_BRANCH }}
|
||||
|
||||
@@ -32,38 +63,33 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
steps:
|
||||
- name: Set Frontend Docker Tag
|
||||
- name: Set Frontend Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
||||
else
|
||||
TAG=${{ env.FRONTEND_TAG }}
|
||||
fi
|
||||
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
uses: docker/setup-buildx-action@v2.5.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Downloading Web Source Code
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: web-src-code
|
||||
|
||||
- name: Build and Push Frontend to Docker Container Registry
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./web/Dockerfile.web
|
||||
@@ -79,39 +105,33 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
steps:
|
||||
- name: Set Space Docker Tag
|
||||
- name: Set Space Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
||||
else
|
||||
TAG=${{ env.SPACE_TAG }}
|
||||
fi
|
||||
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
uses: docker/setup-buildx-action@v2.5.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Downloading Space Source Code
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: space-src-code
|
||||
|
||||
- name: Build and Push Space to Docker Hub
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./space/Dockerfile.space
|
||||
@@ -127,42 +147,36 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
steps:
|
||||
- name: Set Backend Docker Tag
|
||||
- name: Set Backend Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
||||
else
|
||||
TAG=${{ env.BACKEND_TAG }}
|
||||
fi
|
||||
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
uses: docker/setup-buildx-action@v2.5.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Downloading Backend Source Code
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: backend-src-code
|
||||
|
||||
- name: Build and Push Backend to Docker Hub
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: ./apiserver
|
||||
file: ./apiserver/Dockerfile.api
|
||||
context: .
|
||||
file: ./Dockerfile.api
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.BACKEND_TAG }}
|
||||
@@ -175,42 +189,37 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
steps:
|
||||
- name: Set Proxy Docker Tag
|
||||
- name: Set Proxy Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
||||
else
|
||||
TAG=${{ env.PROXY_TAG }}
|
||||
fi
|
||||
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
uses: docker/setup-buildx-action@v2.5.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Downloading Proxy Source Code
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: proxy-src-code
|
||||
|
||||
- name: Build and Push Plane-Proxy to Docker Hub
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: ./nginx
|
||||
file: ./nginx/Dockerfile
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
tags: ${{ env.PROXY_TAG }}
|
||||
push: true
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
name: Create Sync Action
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- preview
|
||||
|
||||
types:
|
||||
- closed
|
||||
env:
|
||||
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
|
||||
SOURCE_BRANCH_NAME: ${{github.event.pull_request.base.ref}}
|
||||
|
||||
jobs:
|
||||
sync_changes:
|
||||
# Only run the job when a PR is merged
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4.1.1
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana
|
||||
|
||||
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
|
||||
|
||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
|
||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting/docker-compose).
|
||||
|
||||
## ⚡️ Contributors Quick Start
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ SENTRY_DSN=""
|
||||
SENTRY_ENVIRONMENT="development"
|
||||
|
||||
# Database Settings
|
||||
POSTGRES_USER="plane"
|
||||
POSTGRES_PASSWORD="plane"
|
||||
POSTGRES_HOST="plane-db"
|
||||
POSTGRES_DB="plane"
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
|
||||
PGUSER="plane"
|
||||
PGPASSWORD="plane"
|
||||
PGHOST="plane-db"
|
||||
PGDATABASE="plane"
|
||||
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
|
||||
|
||||
# Oauth variables
|
||||
GOOGLE_CLIENT_ID=""
|
||||
|
||||
@@ -41,7 +41,6 @@ USER captain
|
||||
|
||||
# Add in Django deps and generate Django's static files
|
||||
COPY manage.py manage.py
|
||||
COPY server.py server.py
|
||||
COPY plane plane/
|
||||
COPY templates templates/
|
||||
COPY package.json package.json
|
||||
|
||||
@@ -33,10 +33,15 @@ RUN pip install -r requirements/local.txt --compile --no-cache-dir
|
||||
RUN addgroup -S plane && \
|
||||
adduser -S captain -G plane
|
||||
|
||||
COPY . .
|
||||
RUN chown captain.plane /code
|
||||
|
||||
RUN chown -R captain.plane /code
|
||||
RUN chmod -R +x /code/bin
|
||||
USER captain
|
||||
|
||||
# Add in Django deps and generate Django's static files
|
||||
|
||||
USER root
|
||||
|
||||
# RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
USER captain
|
||||
|
||||
Executable → Regular
-3
@@ -2,7 +2,4 @@
|
||||
set -e
|
||||
|
||||
python manage.py wait_for_db
|
||||
# Wait for migrations
|
||||
python manage.py wait_for_migrations
|
||||
# Run the processes
|
||||
celery -A plane beat -l info
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
python manage.py wait_for_db
|
||||
# Wait for migrations
|
||||
python manage.py wait_for_migrations
|
||||
python manage.py migrate
|
||||
|
||||
# Create the default bucket
|
||||
#!/bin/bash
|
||||
@@ -28,4 +27,4 @@ python manage.py configure_instance
|
||||
# Create the default bucket
|
||||
python manage.py create_bucket
|
||||
|
||||
python server.py
|
||||
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
python manage.py wait_for_db
|
||||
# Wait for migrations
|
||||
python manage.py wait_for_migrations
|
||||
python manage.py migrate
|
||||
|
||||
# Create the default bucket
|
||||
#!/bin/bash
|
||||
|
||||
@@ -2,7 +2,4 @@
|
||||
set -e
|
||||
|
||||
python manage.py wait_for_db
|
||||
# Wait for migrations
|
||||
python manage.py wait_for_migrations
|
||||
# Run the processes
|
||||
celery -A plane worker -l info
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.15.0"
|
||||
"version": "0.14.0"
|
||||
}
|
||||
|
||||
@@ -243,29 +243,6 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
):
|
||||
serializer = CycleSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and Cycle.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Cycle with the same external id and external source already exists",
|
||||
"id": str(cycle.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
owned_by=request.user,
|
||||
@@ -312,23 +289,6 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
|
||||
serializer = CycleSerializer(cycle, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and (cycle.external_id != request.data.get("external_id"))
|
||||
and Cycle.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source", cycle.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Cycle with the same external id and external source already exists",
|
||||
"id": str(cycle.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)
|
||||
|
||||
@@ -220,30 +220,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
issue = Issue.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": "Issue with the same external id and external source already exists",
|
||||
"id": str(issue.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
serializer.save()
|
||||
|
||||
# Track the issue
|
||||
@@ -280,26 +256,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
partial=True,
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
str(request.data.get("external_id"))
|
||||
and (issue.external_id != str(request.data.get("external_id")))
|
||||
and Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", issue.external_source
|
||||
),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Issue with the same external id and external source already exists",
|
||||
"id": str(issue.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
serializer.save()
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
@@ -307,8 +263,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(pk),
|
||||
project_id=str(project_id),
|
||||
external_id__isnull=False,
|
||||
external_source__isnull=False,
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
@@ -364,30 +318,6 @@ class LabelAPIEndpoint(BaseAPIView):
|
||||
try:
|
||||
serializer = LabelSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and Label.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
label = Label.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": "Label with the same external id and external source already exists",
|
||||
"id": str(label.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED
|
||||
@@ -396,17 +326,11 @@ class LabelAPIEndpoint(BaseAPIView):
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except IntegrityError:
|
||||
label = Label.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
name=request.data.get("name"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Label with the same name already exists in the project",
|
||||
"id": str(label.id),
|
||||
"error": "Label with the same name already exists in the project"
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
@@ -433,25 +357,6 @@ class LabelAPIEndpoint(BaseAPIView):
|
||||
label = self.get_queryset().get(pk=pk)
|
||||
serializer = LabelSerializer(label, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
str(request.data.get("external_id"))
|
||||
and (label.external_id != str(request.data.get("external_id")))
|
||||
and Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", label.external_source
|
||||
),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Label with the same external id and external source already exists",
|
||||
"id": str(label.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)
|
||||
|
||||
@@ -132,29 +132,6 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
},
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and Module.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
module = Module.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Module with the same external id and external source already exists",
|
||||
"id": str(module.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
module = Module.objects.get(pk=serializer.data["id"])
|
||||
serializer = ModuleSerializer(module)
|
||||
@@ -172,25 +149,8 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
partial=True,
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and (module.external_id != request.data.get("external_id"))
|
||||
and Module.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source", module.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Module with the same external id and external source already exists",
|
||||
"id": str(module.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
|
||||
@@ -38,30 +38,6 @@ class StateAPIEndpoint(BaseAPIView):
|
||||
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)
|
||||
@@ -115,23 +91,6 @@ class StateAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
serializer = StateSerializer(state, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
str(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)
|
||||
|
||||
@@ -104,19 +104,17 @@ from .estimate import (
|
||||
EstimateSerializer,
|
||||
EstimatePointSerializer,
|
||||
EstimateReadSerializer,
|
||||
WorkspaceEstimateSerializer,
|
||||
)
|
||||
|
||||
from .inbox import (
|
||||
InboxSerializer,
|
||||
InboxIssueSerializer,
|
||||
IssueStateInboxSerializer,
|
||||
InboxIssueLiteSerializer,
|
||||
)
|
||||
|
||||
from .analytic import AnalyticViewSerializer
|
||||
|
||||
from .notification import NotificationSerializer, UserNotificationPreferenceSerializer
|
||||
from .notification import NotificationSerializer
|
||||
|
||||
from .exporter import ExporterHistorySerializer
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
CycleIssueSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueRelationSerializer,
|
||||
InboxIssueLiteSerializer
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@@ -81,10 +80,9 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"issue_cycle": CycleIssueSerializer,
|
||||
"parent": IssueSerializer,
|
||||
"issue_relation": IssueRelationSerializer,
|
||||
"issue_inbox" : InboxIssueLiteSerializer,
|
||||
}
|
||||
|
||||
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False)
|
||||
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation"] else False)
|
||||
|
||||
return self.fields
|
||||
|
||||
@@ -105,7 +103,6 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
LabelSerializer,
|
||||
CycleIssueSerializer,
|
||||
IssueRelationSerializer,
|
||||
InboxIssueLiteSerializer
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@@ -125,8 +122,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"labels": LabelSerializer,
|
||||
"issue_cycle": CycleIssueSerializer,
|
||||
"parent": IssueSerializer,
|
||||
"issue_relation": IssueRelationSerializer,
|
||||
"issue_inbox" : InboxIssueLiteSerializer,
|
||||
"issue_relation": IssueRelationSerializer
|
||||
}
|
||||
# Check if field in expansion then expand the field
|
||||
if expand in expansion:
|
||||
|
||||
@@ -33,6 +33,7 @@ class CycleWriteSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class CycleSerializer(BaseSerializer):
|
||||
owned_by = UserLiteSerializer(read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
cancelled_issues = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -61,18 +61,3 @@ class EstimateReadSerializer(BaseSerializer):
|
||||
"name",
|
||||
"description",
|
||||
]
|
||||
|
||||
|
||||
class WorkspaceEstimateSerializer(BaseSerializer):
|
||||
points = EstimatePointSerializer(read_only=True, many=True)
|
||||
|
||||
class Meta:
|
||||
model = Estimate
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"points",
|
||||
"name",
|
||||
"description",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -259,17 +259,14 @@ class IssuePropertySerializer(BaseSerializer):
|
||||
|
||||
|
||||
class LabelSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
source="workspace", read_only=True
|
||||
)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Label
|
||||
fields = [
|
||||
"parent",
|
||||
"name",
|
||||
"color",
|
||||
"id",
|
||||
"project_id",
|
||||
"workspace_id",
|
||||
"sort_order",
|
||||
]
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
@@ -298,13 +295,8 @@ class IssueLabelSerializer(BaseSerializer):
|
||||
|
||||
class IssueRelationSerializer(BaseSerializer):
|
||||
id = serializers.UUIDField(source="related_issue.id", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(
|
||||
source="related_issue.project_id", read_only=True
|
||||
)
|
||||
sequence_id = serializers.IntegerField(
|
||||
source="related_issue.sequence_id", read_only=True
|
||||
)
|
||||
name = serializers.CharField(source="related_issue.name", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(source="related_issue.project_id", read_only=True)
|
||||
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -314,7 +306,6 @@ class IssueRelationSerializer(BaseSerializer):
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
@@ -324,13 +315,8 @@ class IssueRelationSerializer(BaseSerializer):
|
||||
|
||||
class RelatedIssueSerializer(BaseSerializer):
|
||||
id = serializers.UUIDField(source="issue.id", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(
|
||||
source="issue.project_id", read_only=True
|
||||
)
|
||||
sequence_id = serializers.IntegerField(
|
||||
source="issue.sequence_id", read_only=True
|
||||
)
|
||||
name = serializers.CharField(source="issue.name", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
|
||||
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -340,7 +326,6 @@ class RelatedIssueSerializer(BaseSerializer):
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
@@ -473,6 +458,19 @@ class IssueReactionSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class CommentReactionLiteSerializer(BaseSerializer):
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
|
||||
class Meta:
|
||||
model = CommentReaction
|
||||
fields = [
|
||||
"id",
|
||||
"reaction",
|
||||
"comment",
|
||||
"actor_detail",
|
||||
]
|
||||
|
||||
|
||||
class CommentReactionSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = CommentReaction
|
||||
@@ -503,7 +501,7 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
read_only=True, source="workspace"
|
||||
)
|
||||
comment_reactions = CommentReactionSerializer(
|
||||
comment_reactions = CommentReactionLiteSerializer(
|
||||
read_only=True, many=True
|
||||
)
|
||||
is_member = serializers.BooleanField(read_only=True)
|
||||
@@ -562,7 +560,7 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
state_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
parent_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
module_ids = serializers.SerializerMethodField()
|
||||
module_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
# Many to many
|
||||
label_ids = serializers.PrimaryKeyRelatedField(
|
||||
@@ -597,7 +595,7 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"module_id",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
@@ -613,10 +611,6 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_module_ids(self, obj):
|
||||
# Access the prefetched modules and extract module IDs
|
||||
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
|
||||
|
||||
|
||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from plane.db.models import Notification, UserNotificationPreference
|
||||
from plane.db.models import Notification
|
||||
|
||||
|
||||
class NotificationSerializer(BaseSerializer):
|
||||
@@ -12,10 +12,3 @@ class NotificationSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class UserNotificationPreferenceSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = UserNotificationPreference
|
||||
fields = "__all__"
|
||||
|
||||
@@ -8,17 +8,7 @@ from plane.db.models import State
|
||||
class StateSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = State
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"workspace_id",
|
||||
"name",
|
||||
"color",
|
||||
"group",
|
||||
"default",
|
||||
"description",
|
||||
"sequence",
|
||||
]
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
|
||||
@@ -8,10 +8,16 @@ from plane.app.views import (
|
||||
CycleFavoriteViewSet,
|
||||
TransferCycleIssueEndpoint,
|
||||
CycleUserPropertiesEndpoint,
|
||||
ActiveCycleEndpoint
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/active-cycles/",
|
||||
ActiveCycleEndpoint.as_view(),
|
||||
name="workspace-active-cycle",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/",
|
||||
CycleViewSet.as_view(
|
||||
|
||||
@@ -35,26 +35,17 @@ urlpatterns = [
|
||||
name="project-modules",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/modules/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
|
||||
ModuleIssueViewSet.as_view(
|
||||
{
|
||||
"post": "create_issue_modules",
|
||||
}
|
||||
),
|
||||
name="issue-module",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/issues/",
|
||||
ModuleIssueViewSet.as_view(
|
||||
{
|
||||
"post": "create_module_issues",
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-module-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/issues/<uuid:issue_id>/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
|
||||
ModuleIssueViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
|
||||
@@ -5,7 +5,6 @@ from plane.app.views import (
|
||||
NotificationViewSet,
|
||||
UnreadNotificationEndpoint,
|
||||
MarkAllReadNotificationViewSet,
|
||||
UserNotificationPreferenceEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -64,9 +63,4 @@ urlpatterns = [
|
||||
),
|
||||
name="mark-all-read-notifications",
|
||||
),
|
||||
path(
|
||||
"users/me/notification-preferences/",
|
||||
UserNotificationPreferenceEndpoint.as_view(),
|
||||
name="user-notification-preferences",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -20,8 +20,6 @@ from plane.app.views import (
|
||||
WorkspaceLabelsEndpoint,
|
||||
WorkspaceProjectMemberEndpoint,
|
||||
WorkspaceUserPropertiesEndpoint,
|
||||
WorkspaceStatesEndpoint,
|
||||
WorkspaceEstimatesEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -209,14 +207,4 @@ urlpatterns = [
|
||||
WorkspaceUserPropertiesEndpoint.as_view(),
|
||||
name="workspace-user-filters",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/states/",
|
||||
WorkspaceStatesEndpoint.as_view(),
|
||||
name="workspace-state",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/estimates/",
|
||||
WorkspaceEstimatesEndpoint.as_view(),
|
||||
name="workspace-estimate",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -47,8 +47,6 @@ from .workspace import (
|
||||
WorkspaceLabelsEndpoint,
|
||||
WorkspaceProjectMemberEndpoint,
|
||||
WorkspaceUserPropertiesEndpoint,
|
||||
WorkspaceStatesEndpoint,
|
||||
WorkspaceEstimatesEndpoint,
|
||||
)
|
||||
from .state import StateViewSet
|
||||
from .view import (
|
||||
@@ -64,6 +62,7 @@ from .cycle import (
|
||||
CycleFavoriteViewSet,
|
||||
TransferCycleIssueEndpoint,
|
||||
CycleUserPropertiesEndpoint,
|
||||
ActiveCycleEndpoint,
|
||||
)
|
||||
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||
from .issue import (
|
||||
@@ -167,7 +166,6 @@ from .notification import (
|
||||
NotificationViewSet,
|
||||
UnreadNotificationEndpoint,
|
||||
MarkAllReadNotificationViewSet,
|
||||
UserNotificationPreferenceEndpoint,
|
||||
)
|
||||
|
||||
from .exporter import ExportIssuesEndpoint
|
||||
|
||||
@@ -39,6 +39,7 @@ from plane.app.serializers import (
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
WorkspaceUserPermission
|
||||
)
|
||||
from plane.db.models import (
|
||||
User,
|
||||
@@ -242,13 +243,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
"assignee_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
"assignee_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -258,7 +259,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
"assignee_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -281,13 +282,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
"label_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
"label_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -297,7 +298,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
"label_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -419,13 +420,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
"assignee_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
"assignee_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -435,7 +436,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
"assignee_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -459,13 +460,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
"label_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
"label_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -475,7 +476,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
"label_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -530,8 +531,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
# Delete the cycle
|
||||
cycle.delete()
|
||||
@@ -599,11 +598,16 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.order_by(order_by)
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -718,8 +722,6 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
# Return all Cycle Issues
|
||||
@@ -752,8 +754,6 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
cycle_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -910,3 +910,235 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
)
|
||||
serializer = CycleUserPropertiesSerializer(cycle_properties)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ActiveCycleEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceUserPermission,
|
||||
]
|
||||
def get(self, request, slug):
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
active_cycles = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
start_date__lte=timezone.now(),
|
||||
end_date__gte=timezone.now(),
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
|
||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||
When(
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
),
|
||||
default=Value("DRAFT"),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__labels",
|
||||
queryset=Label.objects.only("name", "color", "id").distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
cycles = CycleSerializer(active_cycles, many=True).data
|
||||
|
||||
for cycle in cycles:
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=cycle["id"],
|
||||
project_id=cycle["project"],
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=cycle["id"],
|
||||
project_id=cycle["project"],
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
cycle["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
if cycle["start_date"] and cycle["end_date"]:
|
||||
cycle["distribution"][
|
||||
"completion_chart"
|
||||
] = burndown_plot(
|
||||
queryset=active_cycles.get(pk=cycle["id"]),
|
||||
slug=slug,
|
||||
project_id=cycle["project"],
|
||||
cycle_id=cycle["id"],
|
||||
)
|
||||
|
||||
return Response(cycles, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -100,7 +100,7 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_relation",
|
||||
@@ -110,6 +110,7 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
)
|
||||
)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -145,23 +146,6 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
if issue_type == "pending":
|
||||
pending_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
).count()
|
||||
pending_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
pending_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": pending_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "completed":
|
||||
completed_issues_count = assigned_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
@@ -237,8 +221,9 @@ def dashboard_created_issues(self, request, slug):
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -274,23 +259,6 @@ def dashboard_created_issues(self, request, slug):
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
if issue_type == "pending":
|
||||
pending_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
).count()
|
||||
pending_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
pending_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": pending_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "completed":
|
||||
completed_issues_count = created_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
|
||||
@@ -88,23 +88,39 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
Q(snoozed_till__gte=timezone.now())
|
||||
| Q(snoozed_till__isnull=True),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
issue_inbox__inbox_id=self.kwargs.get("inbox_id")
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
inbox_id=self.kwargs.get("inbox_id"),
|
||||
)
|
||||
.select_related("issue", "workspace", "project")
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id, inbox_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
issue_inbox__inbox_id=inbox_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_inbox",
|
||||
queryset=InboxIssue.objects.only(
|
||||
"status", "duplicate_to", "snoozed_till", "source"
|
||||
),
|
||||
.prefetch_related("assignees", "labels")
|
||||
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -119,20 +135,16 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_inbox",
|
||||
queryset=InboxIssue.objects.only(
|
||||
"status", "duplicate_to", "snoozed_till", "source"
|
||||
),
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
).distinct()
|
||||
|
||||
def list(self, request, slug, project_id, inbox_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status")
|
||||
issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data
|
||||
)
|
||||
issues_data = IssueStateInboxSerializer(issues, many=True).data
|
||||
return Response(
|
||||
issues_data,
|
||||
status=status.HTTP_200_OK,
|
||||
@@ -188,8 +200,6 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
# create an inbox issue
|
||||
InboxIssue.objects.create(
|
||||
@@ -199,8 +209,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
source=request.data.get("source", "in-app"),
|
||||
)
|
||||
|
||||
issue = (self.get_queryset().filter(pk=issue.id).first())
|
||||
serializer = IssueSerializer(issue ,expand=self.expand)
|
||||
serializer = IssueStateInboxSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
|
||||
@@ -268,8 +277,6 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
@@ -320,20 +327,22 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
issue = (self.get_queryset().filter(pk=issue_id).first())
|
||||
serializer = IssueSerializer(issue, expand=self.expand)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
else:
|
||||
issue = (self.get_queryset().filter(pk=issue_id).first())
|
||||
serializer = IssueSerializer(issue ,expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
InboxIssueSerializer(inbox_issue).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
|
||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
||||
serializer = IssueSerializer(issue, expand=self.expand,)
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = IssueStateInboxSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
||||
|
||||
@@ -48,8 +48,10 @@ from plane.app.serializers import (
|
||||
ProjectMemberLiteSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
IssueRelationSerializer,
|
||||
RelatedIssueSerializer,
|
||||
IssuePublicSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
@@ -81,7 +83,6 @@ from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
def get_serializer_class(self):
|
||||
return (
|
||||
@@ -112,8 +113,12 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
project_id=self.kwargs.get("project_id")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
@@ -121,6 +126,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -254,8 +260,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue = (
|
||||
self.get_queryset().filter(pk=serializer.data["id"]).first()
|
||||
@@ -294,8 +298,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
return Response(
|
||||
@@ -319,8 +321,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -492,27 +492,17 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
filters = {}
|
||||
if request.GET.get("created_at__gt", None) is not None:
|
||||
filters = {"created_at__gt": request.GET.get("created_at__gt")}
|
||||
|
||||
issue_activities = (
|
||||
IssueActivity.objects.filter(issue_id=issue_id)
|
||||
.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("actor", "workspace", "issue", "project")
|
||||
).order_by("created_at")
|
||||
issue_comments = (
|
||||
IssueComment.objects.filter(issue_id=issue_id)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.filter(**filters)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.order_by("created_at")
|
||||
.select_related("actor", "issue", "project", "workspace")
|
||||
.prefetch_related(
|
||||
@@ -527,12 +517,6 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
).data
|
||||
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
|
||||
|
||||
if request.GET.get("activity_type", None) == "issue-property":
|
||||
return Response(issue_activities, status=status.HTTP_200_OK)
|
||||
|
||||
if request.GET.get("activity_type", None) == "issue-comment":
|
||||
return Response(issue_comments, status=status.HTTP_200_OK)
|
||||
|
||||
result_list = sorted(
|
||||
chain(issue_activities, issue_comments),
|
||||
key=lambda instance: instance["created_at"],
|
||||
@@ -596,8 +580,6 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
|
||||
project_id=str(self.kwargs.get("project_id")),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -627,8 +609,6 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -653,8 +633,6 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -843,9 +821,7 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
|
||||
_ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10)
|
||||
|
||||
updated_sub_issues = Issue.issue_objects.filter(
|
||||
id__in=sub_issue_ids
|
||||
).annotate(state_group=F("state__group"))
|
||||
updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids).annotate(state_group=F("state__group"))
|
||||
|
||||
# Track the issue
|
||||
_ = [
|
||||
@@ -857,12 +833,10 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps({"parent": str(sub_issue_id)}),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for sub_issue_id in sub_issue_ids
|
||||
]
|
||||
|
||||
|
||||
# create's a dict with state group name with their respective issue id's
|
||||
result = defaultdict(list)
|
||||
for sub_issue in updated_sub_issues:
|
||||
@@ -879,6 +853,7 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
|
||||
class IssueLinkViewSet(BaseViewSet):
|
||||
@@ -918,8 +893,6 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
project_id=str(self.kwargs.get("project_id")),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -949,8 +922,6 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -974,8 +945,6 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue_link.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1032,8 +1001,6 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1050,8 +1017,6 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1082,31 +1047,12 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@@ -1134,6 +1080,22 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
@@ -1233,8 +1195,6 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue.archived_at = None
|
||||
issue.save()
|
||||
@@ -1380,8 +1340,6 @@ class IssueReactionViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1407,8 +1365,6 @@ class IssueReactionViewSet(BaseViewSet):
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1449,8 +1405,6 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1477,8 +1431,6 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
comment_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1607,8 +1559,6 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
if relation_type == "blocking":
|
||||
@@ -1653,8 +1603,6 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1679,37 +1627,18 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(is_draft=True)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@@ -1736,6 +1665,22 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
@@ -1829,8 +1774,6 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1861,8 +1804,6 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1889,7 +1830,5 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
+157
-126
@@ -7,8 +7,6 @@ from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
|
||||
from django.core import serializers
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -197,7 +195,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
"assignee_id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
@@ -206,7 +204,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
"assignee_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -216,7 +214,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
"assignee_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -239,7 +237,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
"label_id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
@@ -248,7 +246,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
"label_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -258,7 +256,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
"label_id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -298,20 +296,21 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
_ = [
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps({"module_id": str(pk)}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue),
|
||||
project_id=project_id,
|
||||
current_instance=json.dumps({"module_name": str(module.name)}),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for issue in module_issues
|
||||
]
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"module_id": str(pk),
|
||||
"module_name": str(module.name),
|
||||
"issues": [str(issue_id) for issue_id in module_issues],
|
||||
}
|
||||
),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(pk),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
module.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -331,18 +330,62 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
issue_module__module_id=self.kwargs.get("module_id")
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("issue")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("labels", "assignees")
|
||||
.prefetch_related('issue_module__module')
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(module_id=self.kwargs.get("module_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("module")
|
||||
.select_related("issue", "issue__state", "issue__project")
|
||||
.prefetch_related("issue__assignees", "issue__labels")
|
||||
.prefetch_related("module__members")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id, module_id):
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(issue_module__module_id=module_id)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.order_by(order_by)
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -358,118 +401,103 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
subscriber=self.request.user, issue_id=OuterRef("id")
|
||||
)
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
).distinct()
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id, module_id):
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
)
|
||||
serializer = IssueSerializer(
|
||||
issue_queryset, many=True, fields=fields if fields else None
|
||||
issues, many=True, fields=fields if fields else None
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# create multiple issues inside a module
|
||||
def create_module_issues(self, request, slug, project_id, module_id):
|
||||
def create(self, request, slug, project_id, module_id):
|
||||
issues = request.data.get("issues", [])
|
||||
if not len(issues):
|
||||
return Response(
|
||||
{"error": "Issues are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
_ = ModuleIssue.objects.bulk_create(
|
||||
[
|
||||
ModuleIssue(
|
||||
issue_id=str(issue),
|
||||
module_id=module_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
module = Module.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=module_id
|
||||
)
|
||||
|
||||
module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
|
||||
|
||||
update_module_issue_activity = []
|
||||
records_to_update = []
|
||||
record_to_create = []
|
||||
|
||||
for issue in issues:
|
||||
module_issue = [
|
||||
module_issue
|
||||
for module_issue in module_issues
|
||||
if str(module_issue.issue_id) in issues
|
||||
]
|
||||
|
||||
if len(module_issue):
|
||||
if module_issue[0].module_id != module_id:
|
||||
update_module_issue_activity.append(
|
||||
{
|
||||
"old_module_id": str(module_issue[0].module_id),
|
||||
"new_module_id": str(module_id),
|
||||
"issue_id": str(module_issue[0].issue_id),
|
||||
}
|
||||
)
|
||||
module_issue[0].module_id = module_id
|
||||
records_to_update.append(module_issue[0])
|
||||
else:
|
||||
record_to_create.append(
|
||||
ModuleIssue(
|
||||
module=module,
|
||||
issue_id=issue,
|
||||
project_id=project_id,
|
||||
workspace=module.workspace,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
|
||||
ModuleIssue.objects.bulk_create(
|
||||
record_to_create,
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
# Bulk Update the activity
|
||||
_ = [
|
||||
issue_activity.delay(
|
||||
type="module.activity.created",
|
||||
requested_data=json.dumps({"module_id": str(module_id)}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue),
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for issue in issues
|
||||
]
|
||||
issues = (self.get_queryset().filter(pk__in=issues))
|
||||
serializer = IssueSerializer(issues , many=True)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
# create multiple module inside an issue
|
||||
def create_issue_modules(self, request, slug, project_id, issue_id):
|
||||
modules = request.data.get("modules", [])
|
||||
if not len(modules):
|
||||
return Response(
|
||||
{"error": "Modules are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
_ = ModuleIssue.objects.bulk_create(
|
||||
[
|
||||
ModuleIssue(
|
||||
issue_id=issue_id,
|
||||
module_id=module,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for module in modules
|
||||
],
|
||||
ModuleIssue.objects.bulk_update(
|
||||
records_to_update,
|
||||
["module"],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
# Bulk Update the activity
|
||||
_ = [
|
||||
issue_activity.delay(
|
||||
type="module.activity.created",
|
||||
requested_data=json.dumps({"module_id": module}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for module in modules
|
||||
]
|
||||
|
||||
issue = (self.get_queryset().filter(pk=issue_id).first())
|
||||
serializer = IssueSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="module.activity.created",
|
||||
requested_data=json.dumps({"modules_list": issues}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_module_issues": update_module_issue_activity,
|
||||
"created_module_issues": serializers.serialize(
|
||||
"json", record_to_create
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
|
||||
issues = self.get_queryset().values_list("issue_id", flat=True)
|
||||
|
||||
return Response(
|
||||
IssueSerializer(
|
||||
Issue.objects.filter(pk__in=issues), many=True
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, module_id, issue_id):
|
||||
module_issue = ModuleIssue.objects.get(
|
||||
@@ -480,14 +508,17 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps({"module_id": str(module_id)}),
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"module_id": str(module_id),
|
||||
"issues": [str(issue_id)],
|
||||
}
|
||||
),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps({"module_name": module_issue.module.name}),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
module_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Django imports
|
||||
from django.db.models import Q, OuterRef, Exists
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
@@ -15,9 +15,8 @@ from plane.db.models import (
|
||||
IssueSubscriber,
|
||||
Issue,
|
||||
WorkspaceMember,
|
||||
UserNotificationPreference,
|
||||
)
|
||||
from plane.app.serializers import NotificationSerializer, UserNotificationPreferenceSerializer
|
||||
from plane.app.serializers import NotificationSerializer
|
||||
|
||||
|
||||
class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||
@@ -72,29 +71,11 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||
|
||||
# Subscribed issues
|
||||
if type == "watching":
|
||||
issue_ids = (
|
||||
IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug, subscriber_id=request.user.id
|
||||
)
|
||||
.annotate(
|
||||
created=Exists(
|
||||
Issue.objects.filter(
|
||||
created_by=request.user, pk=OuterRef("issue_id")
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
assigned=Exists(
|
||||
IssueAssignee.objects.filter(
|
||||
pk=OuterRef("issue_id"), assignee=request.user
|
||||
)
|
||||
)
|
||||
)
|
||||
.filter(created=False, assigned=False)
|
||||
.values_list("issue_id", flat=True)
|
||||
)
|
||||
issue_ids = IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug, subscriber_id=request.user.id
|
||||
).values_list("issue_id", flat=True)
|
||||
notifications = notifications.filter(
|
||||
entity_identifier__in=issue_ids,
|
||||
entity_identifier__in=issue_ids
|
||||
)
|
||||
|
||||
# Assigned Issues
|
||||
@@ -314,31 +295,3 @@ class MarkAllReadNotificationViewSet(BaseViewSet):
|
||||
updated_notifications, ["read_at"], batch_size=100
|
||||
)
|
||||
return Response({"message": "Successful"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UserNotificationPreferenceEndpoint(BaseAPIView):
|
||||
model = UserNotificationPreference
|
||||
serializer_class = UserNotificationPreferenceSerializer
|
||||
|
||||
# request the object
|
||||
def get(self, request):
|
||||
user_notification_preference = UserNotificationPreference.objects.get(
|
||||
user=request.user
|
||||
)
|
||||
serializer = UserNotificationPreferenceSerializer(
|
||||
user_notification_preference
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# update the object
|
||||
def patch(self, request):
|
||||
user_notification_preference = UserNotificationPreference.objects.get(
|
||||
user=request.user
|
||||
)
|
||||
serializer = UserNotificationPreferenceSerializer(
|
||||
user_notification_preference, data=request.data, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Python imports
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import timedelta, date, datetime
|
||||
|
||||
# Django imports
|
||||
from django.db import connection
|
||||
@@ -7,19 +7,30 @@ from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer,
|
||||
PageLogSerializer, PageSerializer,
|
||||
SubPageSerializer)
|
||||
from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page,
|
||||
PageFavorite, PageLog, ProjectMember)
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView, BaseViewSet
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
Page,
|
||||
PageFavorite,
|
||||
Issue,
|
||||
IssueAssignee,
|
||||
IssueActivity,
|
||||
PageLog,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
PageSerializer,
|
||||
PageFavoriteSerializer,
|
||||
PageLogSerializer,
|
||||
IssueLiteSerializer,
|
||||
SubPageSerializer,
|
||||
)
|
||||
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
@@ -158,18 +169,18 @@ class PageViewSet(BaseViewSet):
|
||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
# only the owner or admin can archive the page
|
||||
# only the owner and admin can archive the page
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
role__lte=15,
|
||||
role__gt=20,
|
||||
).exists()
|
||||
and request.user.id != page.owned_by_id
|
||||
or request.user.id != page.owned_by_id
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only the owner or admin can archive the page"},
|
||||
{"error": "Only the owner and admin can archive the page"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -182,18 +193,18 @@ class PageViewSet(BaseViewSet):
|
||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
# only the owner or admin can un archive the page
|
||||
# only the owner and admin can un archive the page
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
role__lte=15,
|
||||
role__gt=20,
|
||||
).exists()
|
||||
and request.user.id != page.owned_by_id
|
||||
or request.user.id != page.owned_by_id
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only the owner or admin can un archive the page"},
|
||||
{"error": "Only the owner and admin can un archive the page"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ from plane.bgtasks.project_invitation_task import project_invitation
|
||||
|
||||
|
||||
class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
serializer_class = ProjectListSerializer
|
||||
serializer_class = ProjectSerializer
|
||||
model = Project
|
||||
webhook_event = "project"
|
||||
|
||||
@@ -76,6 +76,11 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def get_serializer_class(self, *args, **kwargs):
|
||||
if self.action in ["update", "partial_update"]:
|
||||
return ProjectSerializer
|
||||
return ProjectDetailSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
@@ -685,19 +690,6 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
.order_by("sort_order")
|
||||
)
|
||||
|
||||
bulk_project_members = []
|
||||
member_roles = {member.get("member_id"): member.get("role") for member in members}
|
||||
# Update roles in the members array based on the member_roles dictionary
|
||||
for project_member in ProjectMember.objects.filter(project_id=project_id, member_id__in=[member.get("member_id") for member in members]):
|
||||
project_member.role = member_roles[str(project_member.member_id)]
|
||||
project_member.is_active = True
|
||||
bulk_project_members.append(project_member)
|
||||
|
||||
# Update the roles of the existing members
|
||||
ProjectMember.objects.bulk_update(
|
||||
bulk_project_members, ["is_active", "role"], batch_size=100
|
||||
)
|
||||
|
||||
for member in members:
|
||||
sort_order = [
|
||||
project_member.get("sort_order")
|
||||
@@ -724,6 +716,25 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
# Check if the user is already a member of the project and is inactive
|
||||
if ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member_id=member.get("member_id"),
|
||||
is_active=False,
|
||||
).exists():
|
||||
member_detail = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member_id=member.get("member_id"),
|
||||
is_active=False,
|
||||
)
|
||||
# Check if the user has not deactivated the account
|
||||
user = User.objects.filter(pk=member.get("member_id")).first()
|
||||
if user.is_active:
|
||||
member_detail.is_active = True
|
||||
member_detail.save(update_fields=["is_active"])
|
||||
|
||||
project_members = ProjectMember.objects.bulk_create(
|
||||
bulk_project_members,
|
||||
batch_size=10,
|
||||
@@ -734,8 +745,8 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
bulk_issue_props, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
project_members = ProjectMember.objects.filter(project_id=project_id, member_id__in=[member.get("member_id") for member in members])
|
||||
serializer = ProjectMemberRoleSerializer(project_members, many=True)
|
||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
|
||||
@@ -228,7 +228,7 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
parent = request.query_params.get("parent", "false")
|
||||
issue_relation = request.query_params.get("issue_relation", "false")
|
||||
cycle = request.query_params.get("cycle", "false")
|
||||
module = request.query_params.get("module", False)
|
||||
module = request.query_params.get("module", "false")
|
||||
sub_issue = request.query_params.get("sub_issue", "false")
|
||||
|
||||
issue_id = request.query_params.get("issue_id", False)
|
||||
@@ -269,8 +269,8 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
if cycle == "true":
|
||||
issues = issues.exclude(issue_cycle__isnull=False)
|
||||
|
||||
if module:
|
||||
issues = issues.exclude(issue_module__module=module)
|
||||
if module == "true":
|
||||
issues = issues.exclude(issue_module__isnull=False)
|
||||
|
||||
return Response(
|
||||
issues.values(
|
||||
|
||||
@@ -9,12 +9,9 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
from . import BaseViewSet
|
||||
from plane.app.serializers import StateSerializer
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
WorkspaceEntityPermission,
|
||||
)
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import State, Issue
|
||||
|
||||
|
||||
@@ -25,6 +22,9 @@ class StateViewSet(BaseViewSet):
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
|
||||
@@ -87,8 +87,12 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
@@ -123,6 +127,7 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
.filter(**filters)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -145,6 +150,13 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
subscriber=self.request.user, issue_id=OuterRef("id")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
|
||||
@@ -41,19 +41,15 @@ from plane.app.serializers import (
|
||||
ProjectMemberSerializer,
|
||||
WorkspaceThemeSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueSerializer,
|
||||
IssueLiteSerializer,
|
||||
WorkspaceMemberAdminSerializer,
|
||||
WorkspaceMemberMeSerializer,
|
||||
ProjectMemberRoleSerializer,
|
||||
WorkspaceUserPropertiesSerializer,
|
||||
WorkspaceEstimateSerializer,
|
||||
StateSerializer,
|
||||
LabelSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from . import BaseViewSet
|
||||
from plane.db.models import (
|
||||
State,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMemberInvite,
|
||||
@@ -71,8 +67,6 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
IssueReaction,
|
||||
WorkspaceUserProperties,
|
||||
Estimate,
|
||||
EstimatePoint,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
WorkSpaceBasePermission,
|
||||
@@ -1345,9 +1339,23 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
project__project_projectmember__member=request.user,
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.select_related("project", "workspace", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -1362,15 +1370,6 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.order_by("created_at")
|
||||
).distinct()
|
||||
|
||||
# Priority Ordering
|
||||
@@ -1433,7 +1432,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issues = IssueSerializer(
|
||||
issues = IssueLiteSerializer(
|
||||
issue_queryset, many=True, fields=fields if fields else None
|
||||
).data
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
@@ -1448,46 +1447,10 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
|
||||
labels = Label.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
).values(
|
||||
"parent", "name", "color", "id", "project_id", "workspace__slug"
|
||||
)
|
||||
serializer = LabelSerializer(labels, many=True).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkspaceStatesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
states = State.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
)
|
||||
serializer = StateSerializer(states, many=True).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkspaceEstimatesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
estimate_ids = Project.objects.filter(
|
||||
workspace__slug=slug, estimate__isnull=False
|
||||
).values_list("estimate_id", flat=True)
|
||||
estimates = Estimate.objects.filter(
|
||||
pk__in=estimate_ids
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
"points",
|
||||
queryset=EstimatePoint.objects.select_related(
|
||||
"estimate", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
serializer = WorkspaceEstimateSerializer(estimates, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(labels, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkspaceUserPropertiesEndpoint(BaseAPIView):
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EmailNotificationLog, User, Issue
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.settings.redis import redis_instance
|
||||
|
||||
@shared_task
|
||||
def stack_email_notification():
|
||||
# get all email notifications
|
||||
email_notifications = (
|
||||
EmailNotificationLog.objects.filter(processed_at__isnull=True)
|
||||
.order_by("receiver")
|
||||
.values()
|
||||
)
|
||||
|
||||
# Create the below format for each of the issues
|
||||
# {"issue_id" : { "actor_id1": [ { data }, { data } ], "actor_id2": [ { data }, { data } ] }}
|
||||
|
||||
# Convert to unique receivers list
|
||||
receivers = list(
|
||||
set(
|
||||
[
|
||||
str(notification.get("receiver_id"))
|
||||
for notification in email_notifications
|
||||
]
|
||||
)
|
||||
)
|
||||
processed_notifications = []
|
||||
# Loop through all the issues to create the emails
|
||||
for receiver_id in receivers:
|
||||
# Notifcation triggered for the receiver
|
||||
receiver_notifications = [
|
||||
notification
|
||||
for notification in email_notifications
|
||||
if str(notification.get("receiver_id")) == receiver_id
|
||||
]
|
||||
# create payload for all issues
|
||||
payload = {}
|
||||
email_notification_ids = []
|
||||
for receiver_notification in receiver_notifications:
|
||||
payload.setdefault(
|
||||
receiver_notification.get("entity_identifier"), {}
|
||||
).setdefault(
|
||||
str(receiver_notification.get("triggered_by_id")), []
|
||||
).append(
|
||||
receiver_notification.get("data")
|
||||
)
|
||||
# append processed notifications
|
||||
processed_notifications.append(receiver_notification.get("id"))
|
||||
email_notification_ids.append(receiver_notification.get("id"))
|
||||
|
||||
# Create emails for all the issues
|
||||
for issue_id, notification_data in payload.items():
|
||||
send_email_notification.delay(
|
||||
issue_id=issue_id,
|
||||
notification_data=notification_data,
|
||||
receiver_id=receiver_id,
|
||||
email_notification_ids=email_notification_ids,
|
||||
)
|
||||
|
||||
# Update the email notification log
|
||||
EmailNotificationLog.objects.filter(pk__in=processed_notifications).update(
|
||||
processed_at=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
def create_payload(notification_data):
|
||||
# return format {"actor_id": { "key": { "old_value": [], "new_value": [] } }}
|
||||
data = {}
|
||||
for actor_id, changes in notification_data.items():
|
||||
for change in changes:
|
||||
issue_activity = change.get("issue_activity")
|
||||
if issue_activity: # Ensure issue_activity is not None
|
||||
field = issue_activity.get("field")
|
||||
old_value = str(issue_activity.get("old_value"))
|
||||
new_value = str(issue_activity.get("new_value"))
|
||||
|
||||
# Append old_value if it's not empty and not already in the list
|
||||
if old_value:
|
||||
data.setdefault(actor_id, {}).setdefault(
|
||||
field, {}
|
||||
).setdefault("old_value", []).append(
|
||||
old_value
|
||||
) if old_value not in data.setdefault(
|
||||
actor_id, {}
|
||||
).setdefault(
|
||||
field, {}
|
||||
).get(
|
||||
"old_value", []
|
||||
) else None
|
||||
|
||||
# Append new_value if it's not empty and not already in the list
|
||||
if new_value:
|
||||
data.setdefault(actor_id, {}).setdefault(
|
||||
field, {}
|
||||
).setdefault("new_value", []).append(
|
||||
new_value
|
||||
) if new_value not in data.setdefault(
|
||||
actor_id, {}
|
||||
).setdefault(
|
||||
field, {}
|
||||
).get(
|
||||
"new_value", []
|
||||
) else None
|
||||
|
||||
if not data.get("actor_id", {}).get("activity_time", False):
|
||||
data[actor_id]["activity_time"] = str(
|
||||
datetime.fromisoformat(
|
||||
issue_activity.get("activity_time").rstrip("Z")
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email_notification(
|
||||
issue_id, notification_data, receiver_id, email_notification_ids
|
||||
):
|
||||
ri = redis_instance()
|
||||
base_api = (ri.get(str(issue_id)).decode())
|
||||
data = create_payload(notification_data=notification_data)
|
||||
|
||||
# Get email configurations
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
receiver = User.objects.get(pk=receiver_id)
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
template_data = []
|
||||
total_changes = 0
|
||||
comments = []
|
||||
actors_involved = []
|
||||
for actor_id, changes in data.items():
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
total_changes = total_changes + len(changes)
|
||||
comment = changes.pop("comment", False)
|
||||
actors_involved.append(actor_id)
|
||||
if comment:
|
||||
comments.append(
|
||||
{
|
||||
"actor_comments": comment,
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
activity_time = changes.pop("activity_time")
|
||||
# Parse the input string into a datetime object
|
||||
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
|
||||
|
||||
if changes:
|
||||
template_data.append(
|
||||
{
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
"changes": changes,
|
||||
"issue_details": {
|
||||
"name": issue.name,
|
||||
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
|
||||
},
|
||||
"activity_time": str(formatted_time),
|
||||
}
|
||||
)
|
||||
|
||||
summary = "Updates were made to the issue by"
|
||||
|
||||
# Send the mail
|
||||
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
|
||||
context = {
|
||||
"data": template_data,
|
||||
"summary": summary,
|
||||
"actors_involved": len(set(actors_involved)),
|
||||
"issue": {
|
||||
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
|
||||
"name": issue.name,
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||
},
|
||||
"receiver": {
|
||||
"email": receiver.email,
|
||||
},
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
|
||||
"workspace":str(issue.project.workspace.slug),
|
||||
"project": str(issue.project.name),
|
||||
"user_preference": f"{base_api}/profile/preferences/email",
|
||||
"comments": comments,
|
||||
}
|
||||
html_content = render_to_string(
|
||||
"emails/notifications/issue-updates.html", context
|
||||
)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
try:
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[receiver.email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
EmailNotificationLog.objects.filter(
|
||||
pk__in=email_notification_ids
|
||||
).update(sent_at=timezone.now())
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return
|
||||
@@ -97,7 +97,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
||||
)
|
||||
# Create the new url with updated domain and protocol
|
||||
presigned_url = presigned_url.replace(
|
||||
f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}/",
|
||||
"http://plane-minio:9000/uploads/",
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/",
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -21,7 +21,7 @@ from plane.license.utils.instance_value import get_email_configuration
|
||||
def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
try:
|
||||
relative_link = (
|
||||
f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}"
|
||||
f"/accounts/password/?uidb64={uidb64}&token={token}&email={email}"
|
||||
)
|
||||
abs_url = str(current_site) + relative_link
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ from plane.db.models import (
|
||||
Label,
|
||||
User,
|
||||
IssueProperty,
|
||||
UserNotificationPreference,
|
||||
)
|
||||
|
||||
|
||||
@@ -51,24 +50,10 @@ def service_importer(service, importer_id):
|
||||
for user in users
|
||||
if user.get("import", False) == "invite"
|
||||
],
|
||||
batch_size=100,
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
_ = UserNotificationPreference.objects.bulk_create(
|
||||
[UserNotificationPreference(user=user) for user in new_users],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
_ = [
|
||||
send_welcome_slack.delay(
|
||||
str(user.id),
|
||||
True,
|
||||
f"{user.email} was imported to Plane from {service}",
|
||||
)
|
||||
for user in new_users
|
||||
]
|
||||
|
||||
workspace_users = User.objects.filter(
|
||||
email__in=[
|
||||
user.get("email").strip().lower()
|
||||
|
||||
@@ -24,11 +24,9 @@ from plane.db.models import (
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueComment,
|
||||
IssueSubscriber,
|
||||
)
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
from plane.settings.redis import redis_instance
|
||||
|
||||
|
||||
# Track Changes in name
|
||||
@@ -113,15 +111,15 @@ def track_parent(
|
||||
issue_activities,
|
||||
epoch,
|
||||
):
|
||||
if current_instance.get("parent_id") != requested_data.get("parent_id"):
|
||||
if current_instance.get("parent") != requested_data.get("parent"):
|
||||
old_parent = (
|
||||
Issue.objects.filter(pk=current_instance.get("parent_id")).first()
|
||||
if current_instance.get("parent_id") is not None
|
||||
Issue.objects.filter(pk=current_instance.get("parent")).first()
|
||||
if current_instance.get("parent") is not None
|
||||
else None
|
||||
)
|
||||
new_parent = (
|
||||
Issue.objects.filter(pk=requested_data.get("parent_id")).first()
|
||||
if requested_data.get("parent_id") is not None
|
||||
Issue.objects.filter(pk=requested_data.get("parent")).first()
|
||||
if requested_data.get("parent") is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -190,11 +188,9 @@ def track_state(
|
||||
issue_activities,
|
||||
epoch,
|
||||
):
|
||||
if current_instance.get("state_id") != requested_data.get("state_id"):
|
||||
new_state = State.objects.get(pk=requested_data.get("state_id", None))
|
||||
old_state = State.objects.get(
|
||||
pk=current_instance.get("state_id", None)
|
||||
)
|
||||
if current_instance.get("state") != requested_data.get("state"):
|
||||
new_state = State.objects.get(pk=requested_data.get("state", None))
|
||||
old_state = State.objects.get(pk=current_instance.get("state", None))
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -292,10 +288,10 @@ def track_labels(
|
||||
epoch,
|
||||
):
|
||||
requested_labels = set(
|
||||
[str(lab) for lab in requested_data.get("label_ids", [])]
|
||||
[str(lab) for lab in requested_data.get("labels", [])]
|
||||
)
|
||||
current_labels = set(
|
||||
[str(lab) for lab in current_instance.get("label_ids", [])]
|
||||
[str(lab) for lab in current_instance.get("labels", [])]
|
||||
)
|
||||
|
||||
added_labels = requested_labels - current_labels
|
||||
@@ -353,22 +349,16 @@ def track_assignees(
|
||||
issue_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_assignees = (
|
||||
set([str(asg) for asg in requested_data.get("assignee_ids", [])])
|
||||
if requested_data is not None
|
||||
else set()
|
||||
requested_assignees = set(
|
||||
[str(asg) for asg in requested_data.get("assignees", [])]
|
||||
)
|
||||
current_assignees = (
|
||||
set([str(asg) for asg in current_instance.get("assignee_ids", [])])
|
||||
if current_instance is not None
|
||||
else set()
|
||||
current_assignees = set(
|
||||
[str(asg) for asg in current_instance.get("assignees", [])]
|
||||
)
|
||||
|
||||
|
||||
added_assignees = requested_assignees - current_assignees
|
||||
dropped_assginees = current_assignees - requested_assignees
|
||||
|
||||
bulk_subscribers = []
|
||||
for added_asignee in added_assignees:
|
||||
assignee = User.objects.get(pk=added_asignee)
|
||||
issue_activities.append(
|
||||
@@ -386,21 +376,6 @@ def track_assignees(
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
bulk_subscribers.append(
|
||||
IssueSubscriber(
|
||||
subscriber_id=assignee.id,
|
||||
issue_id=issue_id,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
created_by_id=assignee.id,
|
||||
updated_by_id=assignee.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Create assignees subscribers to the issue and ignore if already
|
||||
IssueSubscriber.objects.bulk_create(
|
||||
bulk_subscribers, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
for dropped_assignee in dropped_assginees:
|
||||
assignee = User.objects.get(pk=dropped_assignee)
|
||||
@@ -552,20 +527,6 @@ def create_issue_activity(
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
if requested_data.get("assignee_ids") is not None:
|
||||
track_assignees(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project_id,
|
||||
workspace_id,
|
||||
actor_id,
|
||||
issue_activities,
|
||||
epoch,
|
||||
)
|
||||
|
||||
|
||||
def update_issue_activity(
|
||||
@@ -580,14 +541,14 @@ def update_issue_activity(
|
||||
):
|
||||
ISSUE_ACTIVITY_MAPPER = {
|
||||
"name": track_name,
|
||||
"parent_id": track_parent,
|
||||
"parent": track_parent,
|
||||
"priority": track_priority,
|
||||
"state_id": track_state,
|
||||
"state": track_state,
|
||||
"description_html": track_description,
|
||||
"target_date": track_target_date,
|
||||
"start_date": track_start_date,
|
||||
"label_ids": track_labels,
|
||||
"assignee_ids": track_assignees,
|
||||
"labels": track_labels,
|
||||
"assignees": track_assignees,
|
||||
"estimate_point": track_estimate_points,
|
||||
"archived_at": track_archive_at,
|
||||
"closed_to": track_closed_to,
|
||||
@@ -872,27 +833,71 @@ def create_module_issue_activity(
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
module = Module.objects.filter(pk=requested_data.get("module_id")).first()
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
if issue:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=module.name,
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"added module {module.name}",
|
||||
new_identifier=requested_data.get("module_id"),
|
||||
epoch=epoch,
|
||||
)
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
# Updated Records:
|
||||
updated_records = current_instance.get("updated_module_issues", [])
|
||||
created_records = json.loads(
|
||||
current_instance.get("created_module_issues", [])
|
||||
)
|
||||
|
||||
for updated_record in updated_records:
|
||||
old_module = Module.objects.filter(
|
||||
pk=updated_record.get("old_module_id", None)
|
||||
).first()
|
||||
new_module = Module.objects.filter(
|
||||
pk=updated_record.get("new_module_id", None)
|
||||
).first()
|
||||
issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first()
|
||||
if issue:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=updated_record.get("issue_id"),
|
||||
actor_id=actor_id,
|
||||
verb="updated",
|
||||
old_value=old_module.name,
|
||||
new_value=new_module.name,
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"updated module to ",
|
||||
old_identifier=old_module.id,
|
||||
new_identifier=new_module.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
for created_record in created_records:
|
||||
module = Module.objects.filter(
|
||||
pk=created_record.get("fields").get("module")
|
||||
).first()
|
||||
issue = Issue.objects.filter(
|
||||
pk=created_record.get("fields").get("issue")
|
||||
).first()
|
||||
if issue:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=created_record.get("fields").get("issue"),
|
||||
actor_id=actor_id,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=module.name,
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"added module {module.name}",
|
||||
new_identifier=module.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_module_issue_activity(
|
||||
requested_data,
|
||||
@@ -910,26 +915,32 @@ def delete_module_issue_activity(
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
module_name = current_instance.get("module_name")
|
||||
current_issue = Issue.objects.filter(pk=issue_id).first()
|
||||
if current_issue:
|
||||
current_issue.updated_at = timezone.now()
|
||||
current_issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
verb="deleted",
|
||||
old_value=module_name,
|
||||
new_value="",
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"removed this issue from {module_name}",
|
||||
old_identifier=requested_data.get("module_id") if requested_data.get("module_id") is not None else None,
|
||||
epoch=epoch,
|
||||
|
||||
module_id = requested_data.get("module_id", "")
|
||||
module_name = requested_data.get("module_name", "")
|
||||
module = Module.objects.filter(pk=module_id).first()
|
||||
issues = requested_data.get("issues")
|
||||
|
||||
for issue in issues:
|
||||
current_issue = Issue.objects.filter(pk=issue).first()
|
||||
if issue:
|
||||
current_issue.updated_at = timezone.now()
|
||||
current_issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue,
|
||||
actor_id=actor_id,
|
||||
verb="deleted",
|
||||
old_value=module.name if module is not None else module_name,
|
||||
new_value="",
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"removed this issue from {module.name if module is not None else module_name}",
|
||||
old_identifier=module_id if module_id is not None else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_link_activity(
|
||||
@@ -1532,8 +1543,6 @@ def issue_activity(
|
||||
project_id,
|
||||
epoch,
|
||||
subscriber=True,
|
||||
notification=False,
|
||||
origin=None,
|
||||
):
|
||||
try:
|
||||
issue_activities = []
|
||||
@@ -1542,10 +1551,6 @@ def issue_activity(
|
||||
workspace_id = project.workspace_id
|
||||
|
||||
if issue_id is not None:
|
||||
if origin:
|
||||
ri = redis_instance()
|
||||
# set the request origin in redis
|
||||
ri.set(str(issue_id), origin, ex=600)
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
if issue:
|
||||
try:
|
||||
@@ -1619,22 +1624,21 @@ def issue_activity(
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
if notification:
|
||||
notifications.delay(
|
||||
type=type,
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
project_id=project_id,
|
||||
subscriber=subscriber,
|
||||
issue_activities_created=json.dumps(
|
||||
IssueActivitySerializer(
|
||||
issue_activities_created, many=True
|
||||
).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
requested_data=requested_data,
|
||||
current_instance=current_instance,
|
||||
)
|
||||
notifications.delay(
|
||||
type=type,
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
project_id=project_id,
|
||||
subscriber=subscriber,
|
||||
issue_activities_created=json.dumps(
|
||||
IssueActivitySerializer(
|
||||
issue_activities_created, many=True
|
||||
).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
requested_data=requested_data,
|
||||
current_instance=current_instance,
|
||||
)
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
|
||||
@@ -87,7 +87,6 @@ def archive_old_issues():
|
||||
current_instance=json.dumps({"archived_at": None}),
|
||||
subscriber=False,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
)
|
||||
for issue in issues_to_update
|
||||
]
|
||||
@@ -170,7 +169,6 @@ def close_old_issues():
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
)
|
||||
for issue in issues_to_update
|
||||
]
|
||||
|
||||
@@ -10,12 +10,9 @@ from plane.db.models import (
|
||||
User,
|
||||
IssueAssignee,
|
||||
Issue,
|
||||
State,
|
||||
EmailNotificationLog,
|
||||
Notification,
|
||||
IssueComment,
|
||||
IssueActivity,
|
||||
UserNotificationPreference,
|
||||
)
|
||||
|
||||
# Third Party imports
|
||||
@@ -23,7 +20,7 @@ from celery import shared_task
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
# =========== Issue Description Html Parsing and notification Functions ======================
|
||||
# =========== Issue Description Html Parsing and Notification Functions ======================
|
||||
|
||||
|
||||
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
|
||||
@@ -40,7 +37,9 @@ def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
|
||||
)
|
||||
|
||||
IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100)
|
||||
IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete()
|
||||
IssueMention.objects.filter(
|
||||
issue=issue, mention__in=removed_mention
|
||||
).delete()
|
||||
|
||||
|
||||
def get_new_mentions(requested_instance, current_instance):
|
||||
@@ -61,6 +60,8 @@ def get_new_mentions(requested_instance, current_instance):
|
||||
|
||||
|
||||
# Get Removed Mention
|
||||
|
||||
|
||||
def get_removed_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# current_instance is the older instance of the current issue, saved in the database
|
||||
@@ -78,6 +79,8 @@ def get_removed_mentions(requested_instance, current_instance):
|
||||
|
||||
|
||||
# Adds mentions as subscribers
|
||||
|
||||
|
||||
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
||||
# mentions is an array of User IDs representing the FILTERED set of mentioned users
|
||||
|
||||
@@ -92,7 +95,9 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
||||
project_id=project_id,
|
||||
).exists()
|
||||
and not IssueAssignee.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id, assignee_id=mention_id
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
assignee_id=mention_id,
|
||||
).exists()
|
||||
and not Issue.objects.filter(
|
||||
project_id=project_id, pk=issue_id, created_by_id=mention_id
|
||||
@@ -120,7 +125,9 @@ def extract_mentions(issue_instance):
|
||||
data = json.loads(issue_instance)
|
||||
html = data.get("description_html")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
mention_tags = soup.find_all("mention-component", attrs={"target": "users"})
|
||||
mention_tags = soup.find_all(
|
||||
"mention-component", attrs={"target": "users"}
|
||||
)
|
||||
|
||||
mentions = [mention_tag["id"] for mention_tag in mention_tags]
|
||||
|
||||
@@ -129,12 +136,14 @@ def extract_mentions(issue_instance):
|
||||
return []
|
||||
|
||||
|
||||
# =========== Comment Parsing and notification Functions ======================
|
||||
# =========== Comment Parsing and Notification Functions ======================
|
||||
def extract_comment_mentions(comment_value):
|
||||
try:
|
||||
mentions = []
|
||||
soup = BeautifulSoup(comment_value, "html.parser")
|
||||
mentions_tags = soup.find_all("mention-component", attrs={"target": "users"})
|
||||
mentions_tags = soup.find_all(
|
||||
"mention-component", attrs={"target": "users"}
|
||||
)
|
||||
for mention_tag in mentions_tags:
|
||||
mentions.append(mention_tag["id"])
|
||||
return list(set(mentions))
|
||||
@@ -156,8 +165,14 @@ def get_new_comment_mentions(new_value, old_value):
|
||||
return new_mentions
|
||||
|
||||
|
||||
def create_mention_notification(
|
||||
project, notification_comment, issue, actor_id, mention_id, issue_id, activity
|
||||
def createMentionNotification(
|
||||
project,
|
||||
notification_comment,
|
||||
issue,
|
||||
actor_id,
|
||||
mention_id,
|
||||
issue_id,
|
||||
activity,
|
||||
):
|
||||
return Notification(
|
||||
workspace=project.workspace,
|
||||
@@ -200,199 +215,244 @@ def notifications(
|
||||
requested_data,
|
||||
current_instance,
|
||||
):
|
||||
try:
|
||||
issue_activities_created = (
|
||||
json.loads(issue_activities_created)
|
||||
if issue_activities_created is not None
|
||||
else None
|
||||
issue_activities_created = (
|
||||
json.loads(issue_activities_created)
|
||||
if issue_activities_created is not None
|
||||
else None
|
||||
)
|
||||
if type not in [
|
||||
"issue.activity.deleted",
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
"issue_reaction.activity.created",
|
||||
"issue_reaction.activity.deleted",
|
||||
"comment_reaction.activity.created",
|
||||
"comment_reaction.activity.deleted",
|
||||
"issue_vote.activity.created",
|
||||
"issue_vote.activity.deleted",
|
||||
"issue_draft.activity.created",
|
||||
"issue_draft.activity.updated",
|
||||
"issue_draft.activity.deleted",
|
||||
]:
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
|
||||
"""
|
||||
Mention Tasks
|
||||
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
|
||||
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
|
||||
"""
|
||||
|
||||
# Get new mentions from the newer instance
|
||||
new_mentions = get_new_mentions(
|
||||
requested_instance=requested_data,
|
||||
current_instance=current_instance,
|
||||
)
|
||||
removed_mention = get_removed_mentions(
|
||||
requested_instance=requested_data,
|
||||
current_instance=current_instance,
|
||||
)
|
||||
if type not in [
|
||||
"issue.activity.deleted",
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
"issue_reaction.activity.created",
|
||||
"issue_reaction.activity.deleted",
|
||||
"comment_reaction.activity.created",
|
||||
"comment_reaction.activity.deleted",
|
||||
"issue_vote.activity.created",
|
||||
"issue_vote.activity.deleted",
|
||||
"issue_draft.activity.created",
|
||||
"issue_draft.activity.updated",
|
||||
"issue_draft.activity.deleted",
|
||||
]:
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
bulk_email_logs = []
|
||||
|
||||
"""
|
||||
Mention Tasks
|
||||
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
|
||||
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
|
||||
"""
|
||||
comment_mentions = []
|
||||
all_comment_mentions = []
|
||||
|
||||
# Get new mentions from the newer instance
|
||||
new_mentions = get_new_mentions(
|
||||
requested_instance=requested_data,
|
||||
current_instance=current_instance,
|
||||
)
|
||||
removed_mention = get_removed_mentions(
|
||||
requested_instance=requested_data,
|
||||
current_instance=current_instance,
|
||||
)
|
||||
# Get New Subscribers from the mentions of the newer instance
|
||||
requested_mentions = extract_mentions(issue_instance=requested_data)
|
||||
mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
mentions=requested_mentions,
|
||||
)
|
||||
|
||||
comment_mentions = []
|
||||
all_comment_mentions = []
|
||||
for issue_activity in issue_activities_created:
|
||||
issue_comment = issue_activity.get("issue_comment")
|
||||
issue_comment_new_value = issue_activity.get("new_value")
|
||||
issue_comment_old_value = issue_activity.get("old_value")
|
||||
if issue_comment is not None:
|
||||
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
|
||||
|
||||
# Get New Subscribers from the mentions of the newer instance
|
||||
requested_mentions = extract_mentions(
|
||||
issue_instance=requested_data
|
||||
all_comment_mentions = (
|
||||
all_comment_mentions
|
||||
+ extract_comment_mentions(issue_comment_new_value)
|
||||
)
|
||||
|
||||
new_comment_mentions = get_new_comment_mentions(
|
||||
old_value=issue_comment_old_value,
|
||||
new_value=issue_comment_new_value,
|
||||
)
|
||||
comment_mentions = comment_mentions + new_comment_mentions
|
||||
|
||||
comment_mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
mentions=all_comment_mentions,
|
||||
)
|
||||
"""
|
||||
We will not send subscription activity notification to the below mentioned user sets
|
||||
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
|
||||
- When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification
|
||||
- When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification
|
||||
"""
|
||||
|
||||
issue_assignees = list(
|
||||
IssueAssignee.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id
|
||||
)
|
||||
mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
mentions=requested_mentions,
|
||||
.exclude(assignee_id__in=list(new_mentions + comment_mentions))
|
||||
.values_list("assignee", flat=True)
|
||||
)
|
||||
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id
|
||||
)
|
||||
.exclude(
|
||||
subscriber_id__in=list(
|
||||
new_mentions + comment_mentions + [actor_id]
|
||||
)
|
||||
)
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
|
||||
if issue.created_by_id is not None and str(issue.created_by_id) != str(
|
||||
actor_id
|
||||
):
|
||||
issue_subscribers = issue_subscribers + [issue.created_by_id]
|
||||
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
if (
|
||||
str(issue.created_by_id) != str(actor_id)
|
||||
and uuid.UUID(actor_id) not in issue_assignees
|
||||
):
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
subscriber_id=actor_id,
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
issue_subscribers = list(
|
||||
set(issue_subscribers + issue_assignees) - {uuid.UUID(actor_id)}
|
||||
)
|
||||
|
||||
for subscriber in issue_subscribers:
|
||||
if subscriber in issue_subscribers:
|
||||
sender = "in_app:issue_activities:subscribed"
|
||||
if (
|
||||
issue.created_by_id is not None
|
||||
and subscriber == issue.created_by_id
|
||||
):
|
||||
sender = "in_app:issue_activities:created"
|
||||
if subscriber in issue_assignees:
|
||||
sender = "in_app:issue_activities:assigned"
|
||||
|
||||
for issue_activity in issue_activities_created:
|
||||
# Do not send notification for description update
|
||||
if issue_activity.get("field") == "description":
|
||||
continue
|
||||
issue_comment = issue_activity.get("issue_comment")
|
||||
issue_comment_new_value = issue_activity.get("new_value")
|
||||
issue_comment_old_value = issue_activity.get("old_value")
|
||||
if issue_comment is not None:
|
||||
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
|
||||
|
||||
all_comment_mentions = (
|
||||
all_comment_mentions
|
||||
+ extract_comment_mentions(issue_comment_new_value)
|
||||
issue_comment = IssueComment.objects.get(
|
||||
id=issue_comment,
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
|
||||
new_comment_mentions = get_new_comment_mentions(
|
||||
old_value=issue_comment_old_value,
|
||||
new_value=issue_comment_new_value,
|
||||
)
|
||||
comment_mentions = comment_mentions + new_comment_mentions
|
||||
|
||||
comment_mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
mentions=all_comment_mentions,
|
||||
)
|
||||
"""
|
||||
We will not send subscription activity notification to the below mentioned user sets
|
||||
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
|
||||
- When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification
|
||||
- When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification
|
||||
"""
|
||||
|
||||
# ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id
|
||||
)
|
||||
.exclude(
|
||||
subscriber_id__in=list(
|
||||
new_mentions + comment_mentions + [actor_id]
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender=sender,
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
title=issue_activity.get("comment"),
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str(issue_activity.get("field")),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(
|
||||
issue_activity.get("new_value")
|
||||
),
|
||||
"old_value": str(
|
||||
issue_activity.get("old_value")
|
||||
),
|
||||
"issue_comment": str(
|
||||
issue_comment.comment_stripped
|
||||
if issue_activity.get("issue_comment")
|
||||
is not None
|
||||
else ""
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
# Add Mentioned as Issue Subscribers
|
||||
IssueSubscriber.objects.bulk_create(
|
||||
mention_subscribers + comment_mention_subscribers, batch_size=100
|
||||
)
|
||||
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
|
||||
last_activity = (
|
||||
IssueActivity.objects.filter(issue_id=issue_id)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
|
||||
for mention_id in comment_mentions:
|
||||
if mention_id != actor_id:
|
||||
for issue_activity in issue_activities_created:
|
||||
notification = createMentionNotification(
|
||||
project=project,
|
||||
issue=issue,
|
||||
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}",
|
||||
actor_id=actor_id,
|
||||
mention_id=mention_id,
|
||||
issue_id=issue_id,
|
||||
activity=issue_activity,
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
bulk_notifications.append(notification)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
issue_assignees = IssueAssignee.objects.filter(
|
||||
issue_id=issue_id, project_id=project_id
|
||||
).values_list("assignee", flat=True)
|
||||
|
||||
issue_subscribers = list(
|
||||
set(issue_subscribers) - {uuid.UUID(actor_id)}
|
||||
)
|
||||
|
||||
for subscriber in issue_subscribers:
|
||||
if issue.created_by_id and issue.created_by_id == subscriber:
|
||||
sender = "in_app:issue_activities:created"
|
||||
elif (
|
||||
subscriber in issue_assignees
|
||||
and issue.created_by_id not in issue_assignees
|
||||
for mention_id in new_mentions:
|
||||
if mention_id != actor_id:
|
||||
if (
|
||||
last_activity is not None
|
||||
and last_activity.field == "description"
|
||||
and actor_id == str(last_activity.actor_id)
|
||||
):
|
||||
sender = "in_app:issue_activities:assigned"
|
||||
else:
|
||||
sender = "in_app:issue_activities:subscribed"
|
||||
|
||||
preference = UserNotificationPreference.objects.get(
|
||||
user_id=subscriber
|
||||
)
|
||||
|
||||
for issue_activity in issue_activities_created:
|
||||
# If activity done in blocking then blocked by email should not go
|
||||
if issue_activity.get("issue_detail").get("id") != issue_id:
|
||||
continue;
|
||||
|
||||
# Do not send notification for description update
|
||||
if issue_activity.get("field") == "description":
|
||||
continue
|
||||
|
||||
# Check if the value should be sent or not
|
||||
send_email = False
|
||||
if (
|
||||
issue_activity.get("field") == "state"
|
||||
and preference.state_change
|
||||
):
|
||||
send_email = True
|
||||
elif (
|
||||
issue_activity.get("field") == "state"
|
||||
and preference.issue_completed
|
||||
and State.objects.filter(
|
||||
project_id=project_id,
|
||||
pk=issue_activity.get("new_identifier"),
|
||||
group="completed",
|
||||
).exists()
|
||||
):
|
||||
send_email = True
|
||||
elif (
|
||||
issue_activity.get("field") == "comment"
|
||||
and preference.comment
|
||||
):
|
||||
send_email = True
|
||||
elif preference.property_change:
|
||||
send_email = True
|
||||
else:
|
||||
send_email = False
|
||||
|
||||
# If activity is of issue comment fetch the comment
|
||||
issue_comment = (
|
||||
IssueComment.objects.filter(
|
||||
id=issue_activity.get("issue_comment"),
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
).first()
|
||||
if issue_activity.get("issue_comment")
|
||||
else None
|
||||
)
|
||||
|
||||
# Create in app notification
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender=sender,
|
||||
sender="in_app:issue_activities:mentioned",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
title=issue_activity.get("comment"),
|
||||
message=f"You have been mentioned in the issue {issue.name}",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
@@ -405,317 +465,36 @@ def notifications(
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str(issue_activity.get("field")),
|
||||
"actor": str(
|
||||
issue_activity.get("actor_id")
|
||||
),
|
||||
"new_value": str(
|
||||
issue_activity.get("new_value")
|
||||
),
|
||||
"old_value": str(
|
||||
issue_activity.get("old_value")
|
||||
),
|
||||
"issue_comment": str(
|
||||
issue_comment.comment_stripped
|
||||
if issue_comment is not None
|
||||
else ""
|
||||
),
|
||||
"id": str(last_activity.id),
|
||||
"verb": str(last_activity.verb),
|
||||
"field": str(last_activity.field),
|
||||
"actor": str(last_activity.actor_id),
|
||||
"new_value": str(last_activity.new_value),
|
||||
"old_value": str(last_activity.old_value),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
# Create email notification
|
||||
if send_email:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(
|
||||
issue.project.identifier
|
||||
),
|
||||
"project_id": str(issue.project.id),
|
||||
"workspace_slug": str(
|
||||
issue.project.workspace.slug
|
||||
),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(
|
||||
issue_activity.get("verb")
|
||||
),
|
||||
"field": str(
|
||||
issue_activity.get("field")
|
||||
),
|
||||
"actor": str(
|
||||
issue_activity.get("actor_id")
|
||||
),
|
||||
"new_value": str(
|
||||
issue_activity.get("new_value")
|
||||
),
|
||||
"old_value": str(
|
||||
issue_activity.get("old_value")
|
||||
),
|
||||
"issue_comment": str(
|
||||
issue_comment.comment_stripped
|
||||
if issue_comment is not None
|
||||
else ""
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------------------------------------------------- #
|
||||
|
||||
# Add Mentioned as Issue Subscribers
|
||||
IssueSubscriber.objects.bulk_create(
|
||||
mention_subscribers + comment_mention_subscribers,
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
last_activity = (
|
||||
IssueActivity.objects.filter(issue_id=issue_id)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
|
||||
for mention_id in comment_mentions:
|
||||
if mention_id != actor_id:
|
||||
preference = UserNotificationPreference.objects.get(
|
||||
user_id=mention_id
|
||||
)
|
||||
else:
|
||||
for issue_activity in issue_activities_created:
|
||||
notification = create_mention_notification(
|
||||
notification = createMentionNotification(
|
||||
project=project,
|
||||
issue=issue,
|
||||
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}",
|
||||
notification_comment=f"You have been mentioned in the issue {issue.name}",
|
||||
actor_id=actor_id,
|
||||
mention_id=mention_id,
|
||||
issue_id=issue_id,
|
||||
activity=issue_activity,
|
||||
)
|
||||
|
||||
# check for email notifications
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(
|
||||
issue.project.identifier
|
||||
),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
"project_id": str(
|
||||
issue.project.id
|
||||
),
|
||||
"workspace_slug": str(
|
||||
issue.project.workspace.slug
|
||||
),
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(
|
||||
issue_activity.get("id")
|
||||
),
|
||||
"verb": str(
|
||||
issue_activity.get("verb")
|
||||
),
|
||||
"field": str("mention"),
|
||||
"actor": str(
|
||||
issue_activity.get("actor_id")
|
||||
),
|
||||
"new_value": str(
|
||||
issue_activity.get("new_value")
|
||||
),
|
||||
"old_value": str(
|
||||
issue_activity.get("old_value")
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
bulk_notifications.append(notification)
|
||||
|
||||
for mention_id in new_mentions:
|
||||
if mention_id != actor_id:
|
||||
preference = UserNotificationPreference.objects.get(
|
||||
user_id=mention_id
|
||||
)
|
||||
if (
|
||||
last_activity is not None
|
||||
and last_activity.field == "description"
|
||||
and actor_id == str(last_activity.actor_id)
|
||||
):
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities:mentioned",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
message=f"You have been mentioned in the issue {issue.name}",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(
|
||||
issue.project.identifier
|
||||
),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
"project_id": str(issue.project.id),
|
||||
"workspace_slug": str(
|
||||
issue.project.workspace.slug
|
||||
),
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(last_activity.id),
|
||||
"verb": str(last_activity.verb),
|
||||
"field": str(last_activity.field),
|
||||
"actor": str(last_activity.actor_id),
|
||||
"new_value": str(
|
||||
last_activity.new_value
|
||||
),
|
||||
"old_value": str(
|
||||
last_activity.old_value
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(
|
||||
issue.project.identifier
|
||||
),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(last_activity.id),
|
||||
"verb": str(last_activity.verb),
|
||||
"field": "mention",
|
||||
"actor": str(
|
||||
last_activity.actor_id
|
||||
),
|
||||
"new_value": str(
|
||||
last_activity.new_value
|
||||
),
|
||||
"old_value": str(
|
||||
last_activity.old_value
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
for issue_activity in issue_activities_created:
|
||||
notification = create_mention_notification(
|
||||
project=project,
|
||||
issue=issue,
|
||||
notification_comment=f"You have been mentioned in the issue {issue.name}",
|
||||
actor_id=actor_id,
|
||||
mention_id=mention_id,
|
||||
issue_id=issue_id,
|
||||
activity=issue_activity,
|
||||
)
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(
|
||||
issue.project.identifier
|
||||
),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(
|
||||
issue_activity.get("id")
|
||||
),
|
||||
"verb": str(
|
||||
issue_activity.get("verb")
|
||||
),
|
||||
"field": str("mention"),
|
||||
"actor": str(
|
||||
issue_activity.get(
|
||||
"actor_id"
|
||||
)
|
||||
),
|
||||
"new_value": str(
|
||||
issue_activity.get(
|
||||
"new_value"
|
||||
)
|
||||
),
|
||||
"old_value": str(
|
||||
issue_activity.get(
|
||||
"old_value"
|
||||
)
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
bulk_notifications.append(notification)
|
||||
# save new mentions for the particular issue and remove the mentions that has been deleted from the description
|
||||
update_mentions_for_issue(
|
||||
issue=issue,
|
||||
project=project,
|
||||
new_mentions=new_mentions,
|
||||
removed_mention=removed_mention,
|
||||
)
|
||||
|
||||
# save new mentions for the particular issue and remove the mentions that has been deleted from the description
|
||||
update_mentions_for_issue(
|
||||
issue=issue,
|
||||
project=project,
|
||||
new_mentions=new_mentions,
|
||||
removed_mention=removed_mention,
|
||||
)
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(
|
||||
bulk_notifications, batch_size=100
|
||||
)
|
||||
EmailNotificationLog.objects.bulk_create(
|
||||
bulk_email_logs, batch_size=100, ignore_conflicts=True
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
|
||||
@@ -2,7 +2,6 @@ import os
|
||||
from celery import Celery
|
||||
from plane.settings.redis import redis_instance
|
||||
from celery.schedules import crontab
|
||||
from django.utils.timezone import timedelta
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
||||
@@ -29,10 +28,6 @@ app.conf.beat_schedule = {
|
||||
"task": "plane.bgtasks.file_asset_task.delete_file_asset",
|
||||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
"check-every-five-minutes-to-send-email-notifications": {
|
||||
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
|
||||
"schedule": crontab(minute='*/5')
|
||||
},
|
||||
}
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# wait_for_migrations.py
|
||||
import time
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
from django.db import connections, DEFAULT_DB_ALIAS
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Wait for database migrations to complete before starting Celery worker/beat'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
while self._pending_migrations():
|
||||
self.stdout.write("Waiting for database migrations to complete...")
|
||||
time.sleep(10) # wait for 10 seconds before checking again
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("No migrations Pending. Starting processes ..."))
|
||||
|
||||
def _pending_migrations(self):
|
||||
connection = connections[DEFAULT_DB_ALIAS]
|
||||
executor = MigrationExecutor(connection)
|
||||
targets = executor.loader.graph.leaf_nodes()
|
||||
return bool(executor.migration_plan(targets))
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-22 08:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0055_auto_20240108_0648"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserNotificationPreference",
|
||||
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"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("property_change", models.BooleanField(default=True)),
|
||||
("state_change", models.BooleanField(default=True)),
|
||||
("comment", models.BooleanField(default=True)),
|
||||
("mention", models.BooleanField(default=True)),
|
||||
("issue_completed", models.BooleanField(default=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_notification_preferences",
|
||||
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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notification_preferences",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_notification_preferences",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "UserNotificationPreference",
|
||||
"verbose_name_plural": "UserNotificationPreferences",
|
||||
"db_table": "user_notification_preferences",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="EmailNotificationLog",
|
||||
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"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("entity_identifier", models.UUIDField(null=True)),
|
||||
("entity_name", models.CharField(max_length=255)),
|
||||
("data", models.JSONField(null=True)),
|
||||
("processed_at", models.DateTimeField(null=True)),
|
||||
("sent_at", models.DateTimeField(null=True)),
|
||||
("entity", models.CharField(max_length=200)),
|
||||
(
|
||||
"old_value",
|
||||
models.CharField(blank=True, max_length=300, null=True),
|
||||
),
|
||||
(
|
||||
"new_value",
|
||||
models.CharField(blank=True, max_length=300, 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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"receiver",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="email_notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"triggered_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="triggered_emails",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"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",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Email Notification Log",
|
||||
"verbose_name_plural": "Email Notification Logs",
|
||||
"db_table": "email_notification_logs",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-22 09:01
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
def create_notification_preferences(apps, schema_editor):
|
||||
UserNotificationPreference = apps.get_model("db", "UserNotificationPreference")
|
||||
User = apps.get_model("db", "User")
|
||||
|
||||
bulk_notification_preferences = []
|
||||
for user_id in User.objects.filter(is_bot=False).values_list("id", flat=True):
|
||||
bulk_notification_preferences.append(
|
||||
UserNotificationPreference(
|
||||
user_id=user_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
)
|
||||
UserNotificationPreference.objects.bulk_create(
|
||||
bulk_notification_preferences, batch_size=1000, ignore_conflicts=True
|
||||
)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0056_usernotificationpreference_emailnotificationlog"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_notification_preferences)
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-24 18:55
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0057_auto_20240122_0901'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='moduleissue',
|
||||
name='issue',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='moduleissue',
|
||||
unique_together={('issue', 'module')},
|
||||
),
|
||||
]
|
||||
@@ -85,7 +85,7 @@ from .inbox import Inbox, InboxIssue
|
||||
|
||||
from .analytic import AnalyticView
|
||||
|
||||
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
|
||||
from .notification import Notification
|
||||
|
||||
from .exporter import ExporterHistory
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from . import ProjectBaseModel
|
||||
@@ -184,17 +183,6 @@ class Issue(ProjectBaseModel):
|
||||
self.state = default_state
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
|
||||
# Check if the current issue state group is completed or not
|
||||
if self.state.group == "completed":
|
||||
self.completed_at = timezone.now()
|
||||
else:
|
||||
self.completed_at = None
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if self._state.adding:
|
||||
# Get the maximum display_id value from the database
|
||||
|
||||
@@ -7,17 +7,19 @@ from . import ProjectBaseModel
|
||||
|
||||
|
||||
def get_default_filters():
|
||||
return {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
}
|
||||
return (
|
||||
{
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_default_display_filters():
|
||||
@@ -134,12 +136,11 @@ class ModuleIssue(ProjectBaseModel):
|
||||
module = models.ForeignKey(
|
||||
"db.Module", on_delete=models.CASCADE, related_name="issue_module"
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
issue = models.OneToOneField(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="issue_module"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "module"]
|
||||
verbose_name = "Module Issue"
|
||||
verbose_name_plural = "Module Issues"
|
||||
db_table = "module_issues"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from . import BaseModel
|
||||
# Third party imports
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Notification(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
@@ -47,82 +47,3 @@ class Notification(BaseModel):
|
||||
def __str__(self):
|
||||
"""Return name of the notifications"""
|
||||
return f"{self.receiver.email} <{self.workspace.name}>"
|
||||
|
||||
|
||||
def get_default_preference():
|
||||
return {
|
||||
"property_change": {
|
||||
"email": True,
|
||||
},
|
||||
"state": {
|
||||
"email": True,
|
||||
},
|
||||
"comment": {
|
||||
"email": True,
|
||||
},
|
||||
"mentions": {
|
||||
"email": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class UserNotificationPreference(BaseModel):
|
||||
# user it is related to
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notification_preferences",
|
||||
)
|
||||
# workspace if it is applicable
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_notification_preferences",
|
||||
null=True,
|
||||
)
|
||||
# project
|
||||
project = models.ForeignKey(
|
||||
"db.Project",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="project_notification_preferences",
|
||||
null=True,
|
||||
)
|
||||
|
||||
# preference fields
|
||||
property_change = models.BooleanField(default=True)
|
||||
state_change = models.BooleanField(default=True)
|
||||
comment = models.BooleanField(default=True)
|
||||
mention = models.BooleanField(default=True)
|
||||
issue_completed = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "UserNotificationPreference"
|
||||
verbose_name_plural = "UserNotificationPreferences"
|
||||
db_table = "user_notification_preferences"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the user"""
|
||||
return f"<{self.user}>"
|
||||
|
||||
class EmailNotificationLog(BaseModel):
|
||||
# receiver
|
||||
receiver = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="email_notifications")
|
||||
triggered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="triggered_emails")
|
||||
# entity - can be issues, pages, etc.
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
entity_name = models.CharField(max_length=255)
|
||||
# data
|
||||
data = models.JSONField(null=True)
|
||||
# sent at
|
||||
processed_at = models.DateTimeField(null=True)
|
||||
sent_at = models.DateTimeField(null=True)
|
||||
entity = models.CharField(max_length=200)
|
||||
old_value = models.CharField(max_length=300, blank=True, null=True)
|
||||
new_value = models.CharField(max_length=300, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Email Notification Log"
|
||||
verbose_name_plural = "Email Notification Logs"
|
||||
db_table = "email_notification_logs"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
@@ -11,16 +11,8 @@ from django.contrib.auth.models import (
|
||||
UserManager,
|
||||
PermissionsMixin,
|
||||
)
|
||||
from django.db.models.signals import post_save
|
||||
from django.conf import settings
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from sentry_sdk import capture_exception
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
|
||||
def get_default_onboarding():
|
||||
return {
|
||||
@@ -142,39 +134,3 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
self.is_staff = True
|
||||
|
||||
super(User, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def send_welcome_slack(sender, instance, created, **kwargs):
|
||||
try:
|
||||
if created and not instance.is_bot:
|
||||
# Send message on slack as well
|
||||
if settings.SLACK_BOT_TOKEN:
|
||||
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
||||
try:
|
||||
_ = client.chat_postMessage(
|
||||
channel="#trackers",
|
||||
text=f"New user {instance.email} has signed up and begun the onboarding journey.",
|
||||
)
|
||||
except SlackApiError as e:
|
||||
print(f"Got an error: {e.response['error']}")
|
||||
return
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_notification(sender, instance, created, **kwargs):
|
||||
# create preferences
|
||||
if created and not instance.is_bot:
|
||||
# Module imports
|
||||
from plane.db.models import UserNotificationPreference
|
||||
UserNotificationPreference.objects.create(
|
||||
user=instance,
|
||||
property_change=False,
|
||||
state_change=False,
|
||||
comment=False,
|
||||
mention=False,
|
||||
issue_completed=False,
|
||||
)
|
||||
|
||||
@@ -291,7 +291,6 @@ CELERY_IMPORTS = (
|
||||
"plane.bgtasks.issue_automation_task",
|
||||
"plane.bgtasks.exporter_expired_task",
|
||||
"plane.bgtasks.file_asset_task",
|
||||
"plane.bgtasks.email_notification_task",
|
||||
)
|
||||
|
||||
# Sentry Settings
|
||||
|
||||
@@ -4,7 +4,6 @@ from datetime import timedelta
|
||||
|
||||
# Django import
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.db.models import Count, F, Sum, Value, Case, When, CharField
|
||||
from django.db.models.functions import (
|
||||
@@ -169,9 +168,6 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
|
||||
if item["date"] is not None and item["date"] <= date
|
||||
)
|
||||
cumulative_pending_issues -= total_completed
|
||||
if date > timezone.now().date():
|
||||
chart_data[str(date)] = None
|
||||
else:
|
||||
chart_data[str(date)] = cumulative_pending_issues
|
||||
chart_data[str(date)] = cumulative_pending_issues
|
||||
|
||||
return chart_data
|
||||
|
||||
@@ -30,7 +30,7 @@ openpyxl==3.1.2
|
||||
beautifulsoup4==4.12.2
|
||||
dj-database-url==2.1.0
|
||||
posthog==3.0.2
|
||||
cryptography==41.0.6
|
||||
cryptography==41.0.5
|
||||
lxml==4.9.3
|
||||
boto3==1.28.40
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
python-3.11.7
|
||||
python-3.11.6
|
||||
@@ -1,17 +0,0 @@
|
||||
import os
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault(
|
||||
"DJANGO_SETTINGS_MODULE", "plane.settings.production"
|
||||
)
|
||||
uvicorn.run(
|
||||
"plane.asgi:application",
|
||||
host=os.environ.get("HOST", "0.0.0.0"),
|
||||
port=os.environ.get("PORT", 8000),
|
||||
ws="auto",
|
||||
workers=int(os.environ.get("GUNICORN_WORKERS", 1)),
|
||||
log_level=os.environ.get("LOG_LEVEL", "info"),
|
||||
lifespan="off",
|
||||
access_log="on",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
if command -v curl &> /dev/null; then
|
||||
sudo curl -sSL \
|
||||
-o /usr/local/bin/plane-app \
|
||||
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
||||
else
|
||||
sudo wget -q \
|
||||
-O /usr/local/bin/plane-app \
|
||||
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
||||
fi
|
||||
|
||||
sudo chmod +x /usr/local/bin/plane-app
|
||||
sudo sed -i 's/export BRANCH=${BRANCH:-master}/export BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
|
||||
|
||||
sudo plane-app --help
|
||||
@@ -1,713 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
function print_header() {
|
||||
clear
|
||||
|
||||
cat <<"EOF"
|
||||
---------------------------------------
|
||||
____ _
|
||||
| _ \| | __ _ _ __ ___
|
||||
| |_) | |/ _` | '_ \ / _ \
|
||||
| __/| | (_| | | | | __/
|
||||
|_| |_|\__,_|_| |_|\___|
|
||||
|
||||
---------------------------------------
|
||||
Project management tool from the future
|
||||
---------------------------------------
|
||||
|
||||
EOF
|
||||
}
|
||||
function update_env_files() {
|
||||
config_file=$1
|
||||
key=$2
|
||||
value=$3
|
||||
|
||||
# Check if the config file exists
|
||||
if [ ! -f "$config_file" ]; then
|
||||
echo "Config file not found. Creating a new one..." >&2
|
||||
touch "$config_file"
|
||||
fi
|
||||
|
||||
# Check if the key already exists in the config file
|
||||
if grep -q "^$key=" "$config_file"; then
|
||||
awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
|
||||
else
|
||||
echo "$key=$value" >> "$config_file"
|
||||
fi
|
||||
}
|
||||
function read_env_file() {
|
||||
config_file=$1
|
||||
key=$2
|
||||
|
||||
# Check if the config file exists
|
||||
if [ ! -f "$config_file" ]; then
|
||||
echo "Config file not found. Creating a new one..." >&2
|
||||
touch "$config_file"
|
||||
fi
|
||||
|
||||
# Check if the key already exists in the config file
|
||||
if grep -q "^$key=" "$config_file"; then
|
||||
value=$(awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
|
||||
echo "$value"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
function update_config() {
|
||||
config_file="$PLANE_INSTALL_DIR/config.env"
|
||||
update_env_files "$config_file" "$1" "$2"
|
||||
}
|
||||
function read_config() {
|
||||
config_file="$PLANE_INSTALL_DIR/config.env"
|
||||
read_env_file "$config_file" "$1"
|
||||
}
|
||||
function update_env() {
|
||||
config_file="$PLANE_INSTALL_DIR/.env"
|
||||
update_env_files "$config_file" "$1" "$2"
|
||||
}
|
||||
function read_env() {
|
||||
config_file="$PLANE_INSTALL_DIR/.env"
|
||||
read_env_file "$config_file" "$1"
|
||||
}
|
||||
function show_message() {
|
||||
print_header
|
||||
|
||||
if [ "$2" == "replace_last_line" ]; then
|
||||
PROGRESS_MSG[-1]="$1"
|
||||
else
|
||||
PROGRESS_MSG+=("$1")
|
||||
fi
|
||||
|
||||
for statement in "${PROGRESS_MSG[@]}"; do
|
||||
echo "$statement"
|
||||
done
|
||||
|
||||
}
|
||||
function prepare_environment() {
|
||||
show_message "Prepare Environment..." >&2
|
||||
|
||||
show_message "- Updating OS with required tools ✋" >&2
|
||||
sudo apt-get update -y &> /dev/null
|
||||
sudo apt-get upgrade -y &> /dev/null
|
||||
|
||||
required_tools=("curl" "awk" "wget" "nano" "dialog" "git")
|
||||
|
||||
for tool in "${required_tools[@]}"; do
|
||||
if ! command -v $tool &> /dev/null; then
|
||||
sudo apt install -y $tool &> /dev/null
|
||||
fi
|
||||
done
|
||||
|
||||
show_message "- OS Updated ✅" "replace_last_line" >&2
|
||||
|
||||
# Install Docker if not installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
show_message "- Installing Docker ✋" >&2
|
||||
sudo curl -o- https://get.docker.com | bash -
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
dockerd-rootless-setuptool.sh install &> /dev/null
|
||||
fi
|
||||
show_message "- Docker Installed ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "- Docker is already installed ✅" >&2
|
||||
fi
|
||||
|
||||
update_config "PLANE_ARCH" "$CPU_ARCH"
|
||||
update_config "DOCKER_VERSION" "$(docker -v | awk '{print $3}' | sed 's/,//g')"
|
||||
update_config "PLANE_DATA_DIR" "$DATA_DIR"
|
||||
update_config "PLANE_LOG_DIR" "$LOG_DIR"
|
||||
|
||||
# echo "TRUE"
|
||||
echo "Environment prepared successfully ✅"
|
||||
show_message "Environment prepared successfully ✅" >&2
|
||||
show_message "" >&2
|
||||
return 0
|
||||
}
|
||||
function download_plane() {
|
||||
# Download Docker Compose File from github url
|
||||
show_message "Downloading Plane Setup Files ✋" >&2
|
||||
curl -H 'Cache-Control: no-cache, no-store' \
|
||||
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
|
||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
|
||||
|
||||
curl -H 'Cache-Control: no-cache, no-store' \
|
||||
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
|
||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
|
||||
|
||||
# if .env does not exists rename variables-upgrade.env to .env
|
||||
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
|
||||
mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
|
||||
fi
|
||||
|
||||
show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2
|
||||
show_message "" >&2
|
||||
|
||||
echo "PLANE_DOWNLOADED"
|
||||
return 0
|
||||
}
|
||||
function printUsageInstructions() {
|
||||
show_message "" >&2
|
||||
show_message "----------------------------------" >&2
|
||||
show_message "Usage Instructions" >&2
|
||||
show_message "----------------------------------" >&2
|
||||
show_message "" >&2
|
||||
show_message "To use the Plane Setup utility, use below commands" >&2
|
||||
show_message "" >&2
|
||||
|
||||
show_message "Usage: plane-app [OPTION]" >&2
|
||||
show_message "" >&2
|
||||
show_message " start Start Server" >&2
|
||||
show_message " stop Stop Server" >&2
|
||||
show_message " restart Restart Server" >&2
|
||||
show_message "" >&2
|
||||
show_message "other options" >&2
|
||||
show_message " -i, --install Install Plane" >&2
|
||||
show_message " -c, --configure Configure Plane" >&2
|
||||
show_message " -up, --upgrade Upgrade Plane" >&2
|
||||
show_message " -un, --uninstall Uninstall Plane" >&2
|
||||
show_message " -ui, --update-installer Update Plane Installer" >&2
|
||||
show_message " -h, --help Show help" >&2
|
||||
show_message "" >&2
|
||||
show_message "" >&2
|
||||
show_message "Application Data is stored in mentioned folders" >&2
|
||||
show_message " - DB Data: $DATA_DIR/postgres" >&2
|
||||
show_message " - Redis Data: $DATA_DIR/redis" >&2
|
||||
show_message " - Minio Data: $DATA_DIR/minio" >&2
|
||||
show_message "" >&2
|
||||
show_message "" >&2
|
||||
show_message "----------------------------------" >&2
|
||||
show_message "" >&2
|
||||
}
|
||||
function build_local_image() {
|
||||
show_message "- Downloading Plane Source Code ✋" >&2
|
||||
REPO=https://github.com/makeplane/plane.git
|
||||
CURR_DIR=$PWD
|
||||
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
|
||||
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
|
||||
|
||||
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch -q > /dev/null
|
||||
|
||||
sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml
|
||||
|
||||
show_message "- Plane Source Code Downloaded ✅" "replace_last_line" >&2
|
||||
|
||||
show_message "- Building Docker Images ✋" >&2
|
||||
sudo docker compose --env-file=$PLANE_INSTALL_DIR/.env -f $PLANE_TEMP_CODE_DIR/build.yml build --no-cache
|
||||
}
|
||||
function check_for_docker_images() {
|
||||
show_message "" >&2
|
||||
# show_message "Building Plane Images" >&2
|
||||
|
||||
update_env "DOCKERHUB_USER" "makeplane"
|
||||
update_env "PULL_POLICY" "always"
|
||||
CURR_DIR=$(pwd)
|
||||
|
||||
if [ "$BRANCH" == "master" ]; then
|
||||
update_env "APP_RELEASE" "latest"
|
||||
export APP_RELEASE=latest
|
||||
else
|
||||
update_env "APP_RELEASE" "$BRANCH"
|
||||
export APP_RELEASE=$BRANCH
|
||||
fi
|
||||
|
||||
if [ $CPU_ARCH == "amd64" ] || [ $CPU_ARCH == "x86_64" ]; then
|
||||
# show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2
|
||||
echo "Building Plane Images for $CPU_ARCH is not required. Skipping..."
|
||||
else
|
||||
export DOCKERHUB_USER=myplane
|
||||
show_message "Building Plane Images for $CPU_ARCH " >&2
|
||||
update_env "DOCKERHUB_USER" "myplane"
|
||||
update_env "PULL_POLICY" "never"
|
||||
|
||||
build_local_image
|
||||
|
||||
sudo rm -rf $PLANE_INSTALL_DIR/temp > /dev/null
|
||||
|
||||
show_message "- Docker Images Built ✅" "replace_last_line" >&2
|
||||
sudo cd $CURR_DIR
|
||||
fi
|
||||
|
||||
sudo sed -i "s|- pgdata:|- $DATA_DIR/postgres:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
sudo sed -i "s|- redisdata:|- $DATA_DIR/redis:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
|
||||
show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2
|
||||
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
|
||||
show_message "Plane Images Downloaded ✅" "replace_last_line" >&2
|
||||
}
|
||||
function configure_plane() {
|
||||
show_message "" >&2
|
||||
show_message "Configuring Plane" >&2
|
||||
show_message "" >&2
|
||||
|
||||
exec 3>&1
|
||||
|
||||
nginx_port=$(read_env "NGINX_PORT")
|
||||
domain_name=$(read_env "DOMAIN_NAME")
|
||||
upload_limit=$(read_env "FILE_SIZE_LIMIT")
|
||||
|
||||
NGINX_SETTINGS=$(dialog \
|
||||
--ok-label "Next" \
|
||||
--cancel-label "Skip" \
|
||||
--backtitle "Plane Configuration" \
|
||||
--title "Nginx Settings" \
|
||||
--form "" \
|
||||
0 0 0 \
|
||||
"Port:" 1 1 "${nginx_port:-80}" 1 10 50 0 \
|
||||
"Domain:" 2 1 "${domain_name:-localhost}" 2 10 50 0 \
|
||||
"Upload Limit:" 3 1 "${upload_limit:-5242880}" 3 10 15 0 \
|
||||
2>&1 1>&3)
|
||||
|
||||
save_nginx_settings=0
|
||||
if [ $? -eq 0 ]; then
|
||||
save_nginx_settings=1
|
||||
nginx_port=$(echo "$NGINX_SETTINGS" | sed -n 1p)
|
||||
domain_name=$(echo "$NGINX_SETTINGS" | sed -n 2p)
|
||||
upload_limit=$(echo "$NGINX_SETTINGS" | sed -n 3p)
|
||||
fi
|
||||
|
||||
|
||||
smtp_host=$(read_env "EMAIL_HOST")
|
||||
smtp_user=$(read_env "EMAIL_HOST_USER")
|
||||
smtp_password=$(read_env "EMAIL_HOST_PASSWORD")
|
||||
smtp_port=$(read_env "EMAIL_PORT")
|
||||
smtp_from=$(read_env "EMAIL_FROM")
|
||||
smtp_tls=$(read_env "EMAIL_USE_TLS")
|
||||
smtp_ssl=$(read_env "EMAIL_USE_SSL")
|
||||
|
||||
SMTP_SETTINGS=$(dialog \
|
||||
--ok-label "Next" \
|
||||
--cancel-label "Skip" \
|
||||
--backtitle "Plane Configuration" \
|
||||
--title "SMTP Settings" \
|
||||
--form "" \
|
||||
0 0 0 \
|
||||
"Host:" 1 1 "$smtp_host" 1 10 80 0 \
|
||||
"User:" 2 1 "$smtp_user" 2 10 80 0 \
|
||||
"Password:" 3 1 "$smtp_password" 3 10 80 0 \
|
||||
"Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \
|
||||
"From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \
|
||||
"TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \
|
||||
"SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \
|
||||
2>&1 1>&3)
|
||||
|
||||
save_smtp_settings=0
|
||||
if [ $? -eq 0 ]; then
|
||||
save_smtp_settings=1
|
||||
smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p)
|
||||
smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p)
|
||||
smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p)
|
||||
smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p)
|
||||
smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p)
|
||||
smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p)
|
||||
fi
|
||||
external_pgdb_url=$(dialog \
|
||||
--backtitle "Plane Configuration" \
|
||||
--title "Using External Postgres Database ?" \
|
||||
--ok-label "Next" \
|
||||
--cancel-label "Skip" \
|
||||
--inputbox "Enter your external database url" \
|
||||
8 60 3>&1 1>&2 2>&3)
|
||||
|
||||
|
||||
external_redis_url=$(dialog \
|
||||
--backtitle "Plane Configuration" \
|
||||
--title "Using External Redis Database ?" \
|
||||
--ok-label "Next" \
|
||||
--cancel-label "Skip" \
|
||||
--inputbox "Enter your external redis url" \
|
||||
8 60 3>&1 1>&2 2>&3)
|
||||
|
||||
|
||||
aws_region=$(read_env "AWS_REGION")
|
||||
aws_access_key=$(read_env "AWS_ACCESS_KEY_ID")
|
||||
aws_secret_key=$(read_env "AWS_SECRET_ACCESS_KEY")
|
||||
aws_bucket=$(read_env "AWS_S3_BUCKET_NAME")
|
||||
|
||||
|
||||
AWS_S3_SETTINGS=$(dialog \
|
||||
--ok-label "Next" \
|
||||
--cancel-label "Skip" \
|
||||
--backtitle "Plane Configuration" \
|
||||
--title "AWS S3 Bucket Configuration" \
|
||||
--form "" \
|
||||
0 0 0 \
|
||||
"Region:" 1 1 "$aws_region" 1 10 50 0 \
|
||||
"Access Key:" 2 1 "$aws_access_key" 2 10 50 0 \
|
||||
"Secret Key:" 3 1 "$aws_secret_key" 3 10 50 0 \
|
||||
"Bucket:" 4 1 "$aws_bucket" 4 10 50 0 \
|
||||
2>&1 1>&3)
|
||||
|
||||
save_aws_settings=0
|
||||
if [ $? -eq 0 ]; then
|
||||
save_aws_settings=1
|
||||
aws_region=$(echo "$AWS_S3_SETTINGS" | sed -n 1p)
|
||||
aws_access_key=$(echo "$AWS_S3_SETTINGS" | sed -n 2p)
|
||||
aws_secret_key=$(echo "$AWS_S3_SETTINGS" | sed -n 3p)
|
||||
aws_bucket=$(echo "$AWS_S3_SETTINGS" | sed -n 4p)
|
||||
fi
|
||||
|
||||
# display dialogbox asking for confirmation to continue
|
||||
CONFIRM_CONFIG=$(dialog \
|
||||
--title "Confirm Configuration" \
|
||||
--backtitle "Plane Configuration" \
|
||||
--yes-label "Confirm" \
|
||||
--no-label "Cancel" \
|
||||
--yesno \
|
||||
"
|
||||
save_ngnix_settings: $save_nginx_settings
|
||||
nginx_port: $nginx_port
|
||||
domain_name: $domain_name
|
||||
upload_limit: $upload_limit
|
||||
|
||||
save_smtp_settings: $save_smtp_settings
|
||||
smtp_host: $smtp_host
|
||||
smtp_user: $smtp_user
|
||||
smtp_password: $smtp_password
|
||||
smtp_port: $smtp_port
|
||||
smtp_from: $smtp_from
|
||||
smtp_tls: $smtp_tls
|
||||
smtp_ssl: $smtp_ssl
|
||||
|
||||
save_aws_settings: $save_aws_settings
|
||||
aws_region: $aws_region
|
||||
aws_access_key: $aws_access_key
|
||||
aws_secret_key: $aws_secret_key
|
||||
aws_bucket: $aws_bucket
|
||||
|
||||
pdgb_url: $external_pgdb_url
|
||||
redis_url: $external_redis_url
|
||||
" \
|
||||
0 0 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
if [ $save_nginx_settings == 1 ]; then
|
||||
update_env "NGINX_PORT" "$nginx_port"
|
||||
update_env "DOMAIN_NAME" "$domain_name"
|
||||
update_env "WEB_URL" "http://$domain_name"
|
||||
update_env "CORS_ALLOWED_ORIGINS" "http://$domain_name"
|
||||
update_env "FILE_SIZE_LIMIT" "$upload_limit"
|
||||
fi
|
||||
|
||||
# check enable smpt settings value
|
||||
if [ $save_smtp_settings == 1 ]; then
|
||||
update_env "EMAIL_HOST" "$smtp_host"
|
||||
update_env "EMAIL_HOST_USER" "$smtp_user"
|
||||
update_env "EMAIL_HOST_PASSWORD" "$smtp_password"
|
||||
update_env "EMAIL_PORT" "$smtp_port"
|
||||
update_env "EMAIL_FROM" "$smtp_from"
|
||||
update_env "EMAIL_USE_TLS" "$smtp_tls"
|
||||
update_env "EMAIL_USE_SSL" "$smtp_ssl"
|
||||
fi
|
||||
|
||||
# check enable aws settings value
|
||||
if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then
|
||||
update_env "USE_MINIO" "0"
|
||||
update_env "AWS_REGION" "$aws_region"
|
||||
update_env "AWS_ACCESS_KEY_ID" "$aws_access_key"
|
||||
update_env "AWS_SECRET_ACCESS_KEY" "$aws_secret_key"
|
||||
update_env "AWS_S3_BUCKET_NAME" "$aws_bucket"
|
||||
elif [[ -z $aws_access_key || -z $aws_secret_key ]] ; then
|
||||
update_env "USE_MINIO" "1"
|
||||
update_env "AWS_REGION" ""
|
||||
update_env "AWS_ACCESS_KEY_ID" ""
|
||||
update_env "AWS_SECRET_ACCESS_KEY" ""
|
||||
update_env "AWS_S3_BUCKET_NAME" "uploads"
|
||||
fi
|
||||
|
||||
if [ "$external_pgdb_url" != "" ]; then
|
||||
update_env "DATABASE_URL" "$external_pgdb_url"
|
||||
fi
|
||||
if [ "$external_redis_url" != "" ]; then
|
||||
update_env "REDIS_URL" "$external_redis_url"
|
||||
fi
|
||||
fi
|
||||
|
||||
exec 3>&-
|
||||
}
|
||||
function upgrade_configuration() {
|
||||
upg_env_file="$PLANE_INSTALL_DIR/variables-upgrade.env"
|
||||
# Check if the file exists
|
||||
if [ -f "$upg_env_file" ]; then
|
||||
# Read each line from the file
|
||||
while IFS= read -r line; do
|
||||
# Skip comments and empty lines
|
||||
if [[ "$line" =~ ^\s*#.*$ ]] || [[ -z "$line" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Split the line into key and value
|
||||
key=$(echo "$line" | cut -d'=' -f1)
|
||||
value=$(echo "$line" | cut -d'=' -f2-)
|
||||
|
||||
current_value=$(read_env "$key")
|
||||
|
||||
if [ -z "$current_value" ]; then
|
||||
update_env "$key" "$value"
|
||||
fi
|
||||
done < "$upg_env_file"
|
||||
fi
|
||||
}
|
||||
function install() {
|
||||
show_message ""
|
||||
if [ "$(uname)" == "Linux" ]; then
|
||||
OS="linux"
|
||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
# check the OS
|
||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
||||
OS_SUPPORTED=true
|
||||
show_message "******** Installing Plane ********"
|
||||
show_message ""
|
||||
|
||||
prepare_environment
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
download_plane
|
||||
if [ $? -eq 0 ]; then
|
||||
# create_service
|
||||
check_for_docker_images
|
||||
|
||||
last_installed_on=$(read_config "INSTALLATION_DATE")
|
||||
if [ "$last_installed_on" == "" ]; then
|
||||
configure_plane
|
||||
fi
|
||||
printUsageInstructions
|
||||
|
||||
update_config "INSTALLATION_DATE" "$(date)"
|
||||
|
||||
show_message "Plane Installed Successfully ✅"
|
||||
show_message ""
|
||||
else
|
||||
show_message "Download Failed ❌"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
show_message "Initialization Failed ❌"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
else
|
||||
PROGRESS_MSG="❌❌❌ Unsupported OS Detected ❌❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
function upgrade() {
|
||||
if [ "$(uname)" == "Linux" ]; then
|
||||
OS="linux"
|
||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
# check the OS
|
||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
||||
OS_SUPPORTED=true
|
||||
|
||||
prepare_environment
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
download_plane
|
||||
if [ $? -eq 0 ]; then
|
||||
check_for_docker_images
|
||||
upgrade_configuration
|
||||
update_config "UPGRADE_DATE" "$(date)"
|
||||
|
||||
show_message ""
|
||||
show_message "Plane Upgraded Successfully ✅"
|
||||
show_message ""
|
||||
printUsageInstructions
|
||||
else
|
||||
show_message "Download Failed ❌"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
show_message "Initialization Failed ❌"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected : $(uname)"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
function uninstall() {
|
||||
if [ "$(uname)" == "Linux" ]; then
|
||||
OS="linux"
|
||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
# check the OS
|
||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
||||
OS_SUPPORTED=true
|
||||
show_message "******** Uninstalling Plane ********"
|
||||
show_message ""
|
||||
|
||||
stop_server
|
||||
# CHECK IF PLANE SERVICE EXISTS
|
||||
# if [ -f "/etc/systemd/system/plane.service" ]; then
|
||||
# sudo systemctl stop plane.service &> /dev/null
|
||||
# sudo systemctl disable plane.service &> /dev/null
|
||||
# sudo rm /etc/systemd/system/plane.service &> /dev/null
|
||||
# sudo systemctl daemon-reload &> /dev/null
|
||||
# fi
|
||||
# show_message "- Plane Service removed ✅"
|
||||
|
||||
if ! [ -x "$(command -v docker)" ]; then
|
||||
echo "DOCKER_NOT_INSTALLED" &> /dev/null
|
||||
else
|
||||
# Ask of user input to confirm uninstall docker ?
|
||||
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
|
||||
if [ $? -eq 0 ]; then
|
||||
show_message "- Uninstalling Docker ✋"
|
||||
sudo apt-get purge -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
|
||||
sudo apt-get autoremove -y --purge docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
|
||||
show_message "- Docker Uninstalled ✅" "replace_last_line" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
rm $PLANE_INSTALL_DIR/.env &> /dev/null
|
||||
rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
|
||||
rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
||||
rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
||||
|
||||
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
|
||||
show_message "- Configuration Cleaned ✅"
|
||||
|
||||
show_message ""
|
||||
show_message "******** Plane Uninstalled ********"
|
||||
show_message ""
|
||||
show_message ""
|
||||
show_message "Plane Configuration Cleaned with some exceptions"
|
||||
show_message "- DB Data: $DATA_DIR/postgres"
|
||||
show_message "- Redis Data: $DATA_DIR/redis"
|
||||
show_message "- Minio Data: $DATA_DIR/minio"
|
||||
show_message ""
|
||||
show_message ""
|
||||
show_message "Thank you for using Plane. We hope to see you again soon."
|
||||
show_message ""
|
||||
show_message ""
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
function start_server() {
|
||||
docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml"
|
||||
env_file="$PLANE_INSTALL_DIR/.env"
|
||||
# check if both the files exits
|
||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||
show_message "Starting Plane Server ✋"
|
||||
docker compose -f $docker_compose_file --env-file=$env_file up -d
|
||||
|
||||
# Wait for containers to be running
|
||||
echo "Waiting for containers to start..."
|
||||
while ! docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
|
||||
sleep 1
|
||||
done
|
||||
show_message "Plane Server Started ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||
fi
|
||||
}
|
||||
function stop_server() {
|
||||
docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml"
|
||||
env_file="$PLANE_INSTALL_DIR/.env"
|
||||
# check if both the files exits
|
||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||
show_message "Stopping Plane Server ✋"
|
||||
docker compose -f $docker_compose_file --env-file=$env_file down
|
||||
show_message "Plane Server Stopped ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||
fi
|
||||
}
|
||||
function restart_server() {
|
||||
docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml"
|
||||
env_file="$PLANE_INSTALL_DIR/.env"
|
||||
# check if both the files exits
|
||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||
show_message "Restarting Plane Server ✋"
|
||||
docker compose -f $docker_compose_file --env-file=$env_file restart
|
||||
show_message "Plane Server Restarted ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||
fi
|
||||
}
|
||||
function show_help() {
|
||||
# print_header
|
||||
show_message "Usage: plane-app [OPTION]" >&2
|
||||
show_message "" >&2
|
||||
show_message " start Start Server" >&2
|
||||
show_message " stop Stop Server" >&2
|
||||
show_message " restart Restart Server" >&2
|
||||
show_message "" >&2
|
||||
show_message "other options" >&2
|
||||
show_message " -i, --install Install Plane" >&2
|
||||
show_message " -c, --configure Configure Plane" >&2
|
||||
show_message " -up, --upgrade Upgrade Plane" >&2
|
||||
show_message " -un, --uninstall Uninstall Plane" >&2
|
||||
show_message " -ui, --update-installer Update Plane Installer" >&2
|
||||
show_message " -h, --help Show help" >&2
|
||||
show_message "" >&2
|
||||
exit 1
|
||||
|
||||
}
|
||||
function update_installer() {
|
||||
show_message "Updating Plane Installer ✋" >&2
|
||||
curl -H 'Cache-Control: no-cache, no-store' \
|
||||
-s -o /usr/local/bin/plane-app \
|
||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/1-click/install.sh?token=$(date +%s)
|
||||
|
||||
chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
|
||||
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
|
||||
}
|
||||
|
||||
export BRANCH=${BRANCH:-master}
|
||||
export APP_RELEASE=$BRANCH
|
||||
export DOCKERHUB_USER=makeplane
|
||||
export PULL_POLICY=always
|
||||
|
||||
PLANE_INSTALL_DIR=/opt/plane
|
||||
DATA_DIR=$PLANE_INSTALL_DIR/data
|
||||
LOG_DIR=$PLANE_INSTALL_DIR/log
|
||||
OS_SUPPORTED=false
|
||||
CPU_ARCH=$(uname -m)
|
||||
PROGRESS_MSG=""
|
||||
USE_GLOBAL_IMAGES=1
|
||||
|
||||
mkdir -p $PLANE_INSTALL_DIR/{data,log}
|
||||
|
||||
if [ "$1" == "start" ]; then
|
||||
start_server
|
||||
elif [ "$1" == "stop" ]; then
|
||||
stop_server
|
||||
elif [ "$1" == "restart" ]; then
|
||||
restart_server
|
||||
elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then
|
||||
install
|
||||
elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then
|
||||
configure_plane
|
||||
printUsageInstructions
|
||||
elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then
|
||||
upgrade
|
||||
elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then
|
||||
uninstall
|
||||
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ] ; then
|
||||
update_installer
|
||||
elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_help
|
||||
else
|
||||
show_help
|
||||
fi
|
||||
@@ -122,18 +122,6 @@ services:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
migrator:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-latest}
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
restart: no
|
||||
command: >
|
||||
sh -c "python manage.py wait_for_db &&
|
||||
python manage.py migrate"
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
plane-db:
|
||||
<<: *app-env
|
||||
image: postgres:15.2-alpine
|
||||
|
||||
@@ -49,7 +49,7 @@ function buildLocalImage() {
|
||||
cd $PLANE_TEMP_CODE_DIR
|
||||
if [ "$BRANCH" == "master" ];
|
||||
then
|
||||
export APP_RELEASE=latest
|
||||
APP_RELEASE=latest
|
||||
fi
|
||||
|
||||
docker compose -f build.yml build --no-cache >&2
|
||||
@@ -205,11 +205,6 @@ else
|
||||
PULL_POLICY=never
|
||||
fi
|
||||
|
||||
if [ "$BRANCH" == "master" ];
|
||||
then
|
||||
export APP_RELEASE=latest
|
||||
fi
|
||||
|
||||
# REMOVE SPECIAL CHARACTERS FROM BRANCH NAME
|
||||
if [ "$BRANCH" != "master" ];
|
||||
then
|
||||
|
||||
@@ -86,7 +86,7 @@ services:
|
||||
- dev_env
|
||||
volumes:
|
||||
- ./apiserver:/code
|
||||
command: ./bin/takeoff.local
|
||||
# command: /bin/sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local"
|
||||
env_file:
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
@@ -104,7 +104,7 @@ services:
|
||||
- dev_env
|
||||
volumes:
|
||||
- ./apiserver:/code
|
||||
command: ./bin/worker
|
||||
command: /bin/sh -c "celery -A plane worker -l info"
|
||||
env_file:
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
@@ -123,7 +123,7 @@ services:
|
||||
- dev_env
|
||||
volumes:
|
||||
- ./apiserver:/code
|
||||
command: ./bin/beat
|
||||
command: /bin/sh -c "celery -A plane beat -l info"
|
||||
env_file:
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
@@ -131,26 +131,6 @@ services:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
migrator:
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.dev
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
restart: no
|
||||
networks:
|
||||
- dev_env
|
||||
volumes:
|
||||
- ./apiserver:/code
|
||||
command: >
|
||||
sh -c "python manage.py wait_for_db --settings=plane.settings.local &&
|
||||
python manage.py migrate --settings=plane.settings.local"
|
||||
env_file:
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
proxy:
|
||||
build:
|
||||
context: ./nginx
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.15.0",
|
||||
"version": "0.14.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
@@ -15,7 +15,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --concurrency=13",
|
||||
"dev": "turbo run dev",
|
||||
"start": "turbo run start",
|
||||
"lint": "turbo run lint",
|
||||
"clean": "turbo run clean",
|
||||
@@ -34,4 +34,4 @@
|
||||
"@types/react": "18.2.42"
|
||||
},
|
||||
"packageManager": "yarn@1.22.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor-core",
|
||||
"version": "0.15.0",
|
||||
"version": "0.14.0",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
@@ -30,6 +30,7 @@
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-blockquote": "^2.1.13",
|
||||
"@tiptap/extension-code": "^2.1.13",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.1.13",
|
||||
"@tiptap/extension-color": "^2.1.13",
|
||||
"@tiptap/extension-image": "^2.1.13",
|
||||
|
||||
@@ -34,32 +34,8 @@ export const toggleUnderline = (editor: Editor, range?: Range) => {
|
||||
};
|
||||
|
||||
export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
// Check if code block is active then toggle code block
|
||||
if (editor.isActive("codeBlock")) {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user hasn't selected any text
|
||||
const isSelectionEmpty = editor.state.selection.empty;
|
||||
|
||||
if (isSelectionEmpty) {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
} else {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).toggleCode().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().toggleCode().run();
|
||||
}
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
else editor.chain().focus().toggleCodeBlock().run();
|
||||
};
|
||||
|
||||
export const toggleOrderedList = (editor: Editor, range?: Range) => {
|
||||
@@ -83,8 +59,8 @@ export const toggleStrike = (editor: Editor, range?: Range) => {
|
||||
};
|
||||
|
||||
export const toggleBlockquote = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||
else editor.chain().focus().toggleBlockquote().run();
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run();
|
||||
else editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run();
|
||||
};
|
||||
|
||||
export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
@@ -97,8 +73,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run();
|
||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run();
|
||||
};
|
||||
|
||||
export const unsetLinkEditor = (editor: Editor) => {
|
||||
|
||||
@@ -170,68 +170,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
}
|
||||
}
|
||||
|
||||
#editor-container {
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin: 0.5em 0 0.5em 0;
|
||||
|
||||
border: 1px solid rgb(var(--color-border-200));
|
||||
width: 100%;
|
||||
|
||||
td,
|
||||
th {
|
||||
min-width: 1em;
|
||||
border: 1px solid rgb(var(--color-border-200));
|
||||
padding: 10px 15px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
> * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
background-color: rgb(var(--color-primary-100));
|
||||
}
|
||||
|
||||
td:hover {
|
||||
background-color: rgba(var(--color-primary-300), 0.1);
|
||||
}
|
||||
|
||||
.selectedCell:after {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
content: "";
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(var(--color-primary-300), 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: -2px;
|
||||
width: 2px;
|
||||
background-color: rgb(var(--color-primary-400));
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin: 0;
|
||||
margin-bottom: 3rem;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
margin-bottom: 1rem;
|
||||
border: 2px solid rgba(var(--color-border-200));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -118,9 +118,7 @@
|
||||
background-size: 1.25rem;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
transition:
|
||||
transform ease-out 100ms,
|
||||
background-color ease-out 100ms;
|
||||
transition: transform ease-out 100ms, background-color ease-out 100ms;
|
||||
outline: none;
|
||||
box-shadow: #000 0px 2px 4px;
|
||||
cursor: pointer;
|
||||
@@ -133,13 +131,12 @@
|
||||
background-size: 1.25rem;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
transition:
|
||||
transform ease-out 100ms,
|
||||
background-color ease-out 100ms;
|
||||
transition: transform ease-out 100ms, background-color ease-out 100ms;
|
||||
outline: none;
|
||||
box-shadow: #000 0px 2px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .tableToolbox,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox {
|
||||
border: 1px solid rgba(var(--color-border-300));
|
||||
|
||||
@@ -12,7 +12,7 @@ export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, chil
|
||||
<div
|
||||
id="editor-container"
|
||||
onClick={() => {
|
||||
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
|
||||
editor?.chain().focus().run();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
hideDragHandle?.();
|
||||
|
||||
@@ -1,80 +1,12 @@
|
||||
import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core";
|
||||
import { markInputRule, markPasteRule } from "@tiptap/core";
|
||||
import Code from "@tiptap/extension-code";
|
||||
|
||||
export interface CodeOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
code: {
|
||||
/**
|
||||
* Set a code mark
|
||||
*/
|
||||
setCode: () => ReturnType;
|
||||
/**
|
||||
* Toggle inline code
|
||||
*/
|
||||
toggleCode: () => ReturnType;
|
||||
/**
|
||||
* Unset a code mark
|
||||
*/
|
||||
unsetCode: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/;
|
||||
export const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g;
|
||||
|
||||
export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
|
||||
name: "code",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000",
|
||||
spellcheck: "false",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
excludes: "_",
|
||||
|
||||
code: true,
|
||||
export const inputRegex = /(?<!`)`([^`]*)`(?!`)/;
|
||||
export const pasteRegex = /(?<!`)`([^`]+)`(?!`)/g;
|
||||
|
||||
export const CustomCodeInlineExtension = Code.extend({
|
||||
exitable: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "code" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["code", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setCode:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.setMark(this.name),
|
||||
toggleCode:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.toggleMark(this.name),
|
||||
unsetCode:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.unsetMark(this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-e": () => this.editor.commands.toggleCode(),
|
||||
};
|
||||
},
|
||||
|
||||
inclusive: false,
|
||||
addInputRules() {
|
||||
return [
|
||||
markInputRule({
|
||||
@@ -83,7 +15,6 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [
|
||||
markPasteRule({
|
||||
@@ -92,4 +23,9 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
|
||||
}),
|
||||
];
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000",
|
||||
spellcheck: "false",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
|
||||
import { InputRule, mergeAttributes, Node, nodeInputRule, wrappingInputRule } from "@tiptap/core";
|
||||
|
||||
/**
|
||||
* Extension based on:
|
||||
* - Tiptap HorizontalRule extension (https://tiptap.dev/api/nodes/horizontal-rule)
|
||||
*/
|
||||
|
||||
export interface HorizontalRuleOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
horizontalRule: {
|
||||
/**
|
||||
* Add a horizontal rule
|
||||
*/
|
||||
setHorizontalRule: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const HorizontalRule = Node.create<HorizontalRuleOptions>({
|
||||
name: "horizontalRule",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
group: "block",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
color: {
|
||||
default: "#dddddd",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
"data-type": this.name,
|
||||
}),
|
||||
["div", {}],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setHorizontalRule:
|
||||
() =>
|
||||
({ chain }) => {
|
||||
return (
|
||||
chain()
|
||||
.insertContent({ type: this.name })
|
||||
// set cursor after horizontal rule
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
const { $to } = tr.selection;
|
||||
const posAfter = $to.end();
|
||||
|
||||
if ($to.nodeAfter) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, $to.pos));
|
||||
} else {
|
||||
// add node after horizontal rule if it’s the end of the document
|
||||
const node = $to.parent.type.contentMatch.defaultType?.create();
|
||||
|
||||
if (node) {
|
||||
tr.insert(posAfter, node);
|
||||
tr.setSelection(TextSelection.create(tr.doc, posAfter));
|
||||
}
|
||||
}
|
||||
|
||||
tr.scrollIntoView();
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.run()
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
||||
handler: ({ state, range, match }) => {
|
||||
state.tr.replaceRangeWith(range.from, range.to, this.type.create());
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,25 +1,26 @@
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
|
||||
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
|
||||
import { Table } from "src/ui/extensions/table/table";
|
||||
import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
|
||||
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
|
||||
import { TableRow } from "src/ui/extensions/table/table-row/table-row";
|
||||
import { HorizontalRule } from "src/ui/extensions/horizontal-rule";
|
||||
|
||||
import { ImageExtension } from "src/ui/extensions/image";
|
||||
|
||||
import { isValidHttpUrl } from "src/lib/utils";
|
||||
import { Mentions } from "src/ui/mentions";
|
||||
|
||||
import { CustomCodeBlockExtension } from "src/ui/extensions/code";
|
||||
import { ListKeymap } from "src/ui/extensions/custom-list-keymap";
|
||||
import { CustomKeymap } from "src/ui/extensions/keymap";
|
||||
import { CustomCodeBlockExtension } from "src/ui/extensions/code";
|
||||
import { CustomQuoteExtension } from "src/ui/extensions/quote";
|
||||
import { ListKeymap } from "src/ui/extensions/custom-list-keymap";
|
||||
|
||||
import { DeleteImage } from "src/types/delete-image";
|
||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
@@ -54,9 +55,7 @@ export const CoreEditorExtensions = (
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: {
|
||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||
},
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
@@ -105,6 +104,7 @@ export const CoreEditorExtensions = (
|
||||
transformCopiedText: true,
|
||||
transformPastedText: true,
|
||||
}),
|
||||
HorizontalRule,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
|
||||
@@ -12,6 +12,7 @@ export const CustomQuoteExtension = Blockquote.extend({
|
||||
if (parent.type.name !== "blockquote") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($from.pos !== $to.pos) return false;
|
||||
// if ($head.parentOffset < $head.parent.content.size) return false;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
};
|
||||
},
|
||||
|
||||
content: "paragraph+",
|
||||
content: "block+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,14 @@ export const TableRow = Node.create<TableRowOptions>({
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
backgroundColor: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
content: "(tableCell | tableHeader)*",
|
||||
|
||||
tableRole: "row",
|
||||
@@ -21,7 +29,17 @@ export const TableRow = Node.create<TableRowOptions>({
|
||||
return [{ tag: "tr" }];
|
||||
},
|
||||
|
||||
// renderHTML({ HTMLAttributes }) {
|
||||
// return ["tr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
// },
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["tr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
// Check if backgroundColor attribute is set and create a style string accordingly
|
||||
const style = HTMLAttributes.backgroundColor ? `background-color: ${HTMLAttributes.backgroundColor};` : "";
|
||||
|
||||
// Merge any existing HTMLAttributes with the style for backgroundColor
|
||||
const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style });
|
||||
|
||||
return ["tr", attributes, 0];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ export const icons = {
|
||||
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
|
||||
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
|
||||
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`,
|
||||
toggleColumnHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
toggleRowHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
insertLeftTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={24}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "jsx-dom-cjs";
|
||||
import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model";
|
||||
// import { setCellAttr } from "prosemirror-tables";
|
||||
import { Decoration, NodeView } from "@tiptap/pm/view";
|
||||
import tippy, { Instance, Props } from "tippy.js";
|
||||
|
||||
@@ -95,6 +96,11 @@ function setCellsBackgroundColor(editor: Editor, backgroundColor: string) {
|
||||
}
|
||||
|
||||
const columnsToolboxItems: ToolboxItem[] = [
|
||||
{
|
||||
label: "Toggle Column Header",
|
||||
icon: icons.toggleColumnHeader,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(),
|
||||
},
|
||||
{
|
||||
label: "Add Column Before",
|
||||
icon: icons.insertLeftTableIcon,
|
||||
@@ -133,7 +139,46 @@ const columnsToolboxItems: ToolboxItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function setTableRowBackgroundColor(editor: Editor, backgroundColor: string) {
|
||||
const { state, dispatch } = editor.view;
|
||||
const { selection } = state;
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the position of the hovered cell in the selection to determine the row.
|
||||
const hoveredCell = selection.$headCell || selection.$anchorCell;
|
||||
|
||||
// Find the depth of the table row node
|
||||
let rowDepth = hoveredCell.depth;
|
||||
while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||
rowDepth--;
|
||||
}
|
||||
|
||||
// If we couldn't find a tableRow node, we can't set the background color
|
||||
if (hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the position where the table row starts
|
||||
const rowStartPos = hoveredCell.start(rowDepth);
|
||||
|
||||
// Create a transaction that sets the background color on the tableRow node.
|
||||
const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, {
|
||||
...hoveredCell.node(rowDepth).attrs,
|
||||
backgroundColor: backgroundColor,
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
const rowsToolboxItems: ToolboxItem[] = [
|
||||
{
|
||||
label: "Toggle Row Header",
|
||||
icon: icons.toggleRowHeader,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(),
|
||||
},
|
||||
{
|
||||
label: "Add Row Above",
|
||||
icon: icons.insertTopTableIcon,
|
||||
@@ -161,7 +206,7 @@ const rowsToolboxItems: ToolboxItem[] = [
|
||||
tippyOptions: {
|
||||
appendTo: controlsContainer,
|
||||
},
|
||||
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
|
||||
onSelectColor: (color) => setTableRowBackgroundColor(editor, color),
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -437,16 +482,19 @@ export class TableView implements NodeView {
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => {
|
||||
if (curr.spec.hoveredCell !== undefined) {
|
||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||
}
|
||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce(
|
||||
(acc, curr) => {
|
||||
if (curr.spec.hoveredCell !== undefined) {
|
||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||
}
|
||||
|
||||
if (curr.spec.hoveredTable !== undefined) {
|
||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, HTMLElement>) as any;
|
||||
if (curr.spec.hoveredTable !== undefined) {
|
||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, HTMLElement>
|
||||
) as any;
|
||||
|
||||
if (table === undefined || cell === undefined) {
|
||||
return this.root.classList.add("controls--disabled");
|
||||
|
||||
@@ -107,7 +107,7 @@ export const Table = Node.create({
|
||||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
|
||||
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
|
||||
({ tr, dispatch, editor }) => {
|
||||
const node = createTable(editor.schema, rows, cols, withHeaderRow);
|
||||
|
||||
|
||||
@@ -10,11 +10,6 @@ export interface CustomMentionOptions extends MentionOptions {
|
||||
}
|
||||
|
||||
export const CustomMention = Mention.extend<CustomMentionOptions>({
|
||||
addStorage(this) {
|
||||
return {
|
||||
mentionsOpen: false,
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
|
||||
@@ -14,7 +14,6 @@ export const Suggestion = (suggestions: IMentionSuggestion[]) => ({
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
props.editor.storage.mentionsOpen = true;
|
||||
reactRenderer = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
@@ -46,18 +45,10 @@ export const Suggestion = (suggestions: IMentionSuggestion[]) => ({
|
||||
return true;
|
||||
}
|
||||
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
|
||||
if (navigationKeys.includes(props.event.key)) {
|
||||
// @ts-ignore
|
||||
reactRenderer?.ref?.onKeyDown(props);
|
||||
event?.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
// @ts-ignore
|
||||
return reactRenderer?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
|
||||
props.editor.storage.mentionsOpen = false;
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
reactRenderer?.destroy();
|
||||
},
|
||||
|
||||
@@ -106,7 +106,7 @@ export const TodoListItem = (editor: Editor): EditorMenuItem => ({
|
||||
|
||||
export const CodeItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "code",
|
||||
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
||||
isActive: () => editor?.isActive("code"),
|
||||
command: () => toggleCodeBlock(editor),
|
||||
icon: CodeIcon,
|
||||
});
|
||||
@@ -120,7 +120,7 @@ export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
|
||||
|
||||
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "quote",
|
||||
isActive: () => editor?.isActive("blockquote"),
|
||||
isActive: () => editor?.isActive("quote"),
|
||||
command: () => toggleBlockquote(editor),
|
||||
icon: QuoteIcon,
|
||||
});
|
||||
|
||||
@@ -42,15 +42,6 @@ export function CoreEditorProps(
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view, event, _slice, moved) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const selection: any = window?.getSelection();
|
||||
if (selection.rangeCount !== 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
|
||||
import { Table } from "src/ui/extensions/table/table";
|
||||
import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
|
||||
import { TableRow } from "src/ui/extensions/table/table-row/table-row";
|
||||
import { HorizontalRule } from "src/ui/extensions/horizontal-rule";
|
||||
|
||||
import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image";
|
||||
import { isValidHttpUrl } from "src/lib/utils";
|
||||
@@ -50,9 +51,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
},
|
||||
},
|
||||
codeBlock: false,
|
||||
horizontalRule: {
|
||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||
},
|
||||
horizontalRule: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 2,
|
||||
@@ -73,6 +72,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
class: "rounded-lg border border-custom-border-300",
|
||||
},
|
||||
}),
|
||||
HorizontalRule,
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
Color,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/document-editor",
|
||||
"version": "0.15.0",
|
||||
"version": "0.14.0",
|
||||
"description": "Package that powers Plane's Pages Editor",
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -32,7 +32,6 @@
|
||||
"@plane/editor-core": "*",
|
||||
"@plane/editor-extensions": "*",
|
||||
"@plane/ui": "*",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-placeholder": "^2.1.13",
|
||||
"@tiptap/pm": "^2.1.13",
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
|
||||
type IPageRenderer = {
|
||||
documentDetails: DocumentDetails;
|
||||
updatePageTitle: (title: string) => void;
|
||||
updatePageTitle: (title: string) => Promise<void>;
|
||||
editor: Editor;
|
||||
onActionCompleteHandler: (action: {
|
||||
title: string;
|
||||
@@ -27,20 +27,23 @@ type IPageRenderer = {
|
||||
}) => void;
|
||||
editorClassNames: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
hideDragHandle?: () => void;
|
||||
readonly: boolean;
|
||||
};
|
||||
|
||||
const debounce = (func: (...args: any[]) => void, wait: number) => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
return function executedFunction(...args: any[]) {
|
||||
const later = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
const {
|
||||
documentDetails,
|
||||
editor,
|
||||
editorClassNames,
|
||||
editorContentCustomClassNames,
|
||||
updatePageTitle,
|
||||
readonly,
|
||||
hideDragHandle,
|
||||
} = props;
|
||||
const { documentDetails, editor, editorClassNames, editorContentCustomClassNames, updatePageTitle, readonly } = props;
|
||||
|
||||
const [pageTitle, setPagetitle] = useState(documentDetails.title);
|
||||
|
||||
@@ -61,9 +64,11 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
|
||||
const { getFloatingProps } = useInteractions([dismiss]);
|
||||
|
||||
const debouncedUpdatePageTitle = debounce(updatePageTitle, 300);
|
||||
|
||||
const handlePageTitleChange = (title: string) => {
|
||||
setPagetitle(title);
|
||||
updatePageTitle(title);
|
||||
debouncedUpdatePageTitle(title);
|
||||
};
|
||||
|
||||
const [cleanup, setcleanup] = useState(() => () => {});
|
||||
@@ -74,6 +79,14 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const switchLinkView = (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => {
|
||||
if (!linkViewProps) return;
|
||||
setLinkViewProps({
|
||||
...linkViewProps,
|
||||
view: view,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLinkHover = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (!editor) return;
|
||||
@@ -168,7 +181,7 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
/>
|
||||
)}
|
||||
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
|
||||
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
</EditorContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-widget";
|
||||
import { IssueWidgetExtension } from "src/ui/extensions/widgets/issue-embed-widget";
|
||||
|
||||
import { IIssueEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types";
|
||||
|
||||
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
|
||||
import { UploadImage } from "@plane/editor-core";
|
||||
import { ISlashCommandItem, UploadImage } from "@plane/editor-core";
|
||||
import { IssueSuggestions } from "src/ui/extensions/widgets/issue-embed-suggestion-list";
|
||||
import { LayersIcon } from "@plane/ui";
|
||||
|
||||
export const DocumentEditorExtensions = (
|
||||
uploadFile: UploadImage,
|
||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void,
|
||||
issueEmbedConfig?: IIssueEmbedConfig,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
) => [
|
||||
SlashCommand(uploadFile, setIsSubmitting),
|
||||
DragAndDrop(setHideDragHandle),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
if (node.type.name === "image" || node.type.name === "table") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
) => {
|
||||
const additionalOptions: ISlashCommandItem[] = [
|
||||
{
|
||||
key: "issue_embed",
|
||||
title: "Issue embed",
|
||||
description: "Embed an issue from the project.",
|
||||
searchTerms: ["issue", "link", "embed"],
|
||||
icon: <LayersIcon className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
range,
|
||||
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>"
|
||||
)
|
||||
.run();
|
||||
},
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
IssueWidgetPlaceholder(),
|
||||
];
|
||||
];
|
||||
|
||||
return [
|
||||
SlashCommand(uploadFile, setIsSubmitting, additionalOptions),
|
||||
DragAndDrop,
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
if (node.type.name === "image" || node.type.name === "table") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
IssueWidgetExtension({ issueEmbedConfig }),
|
||||
IssueSuggestions(issueEmbedConfig ? issueEmbedConfig.issues : []),
|
||||
];
|
||||
};
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ export const IssueSuggestions = (suggestions: any[]) => {
|
||||
title: suggestion.name,
|
||||
priority: suggestion.priority.toString(),
|
||||
identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`,
|
||||
state: suggestion.state_detail && suggestion.state_detail.name ? suggestion.state_detail.name : "Todo",
|
||||
state: suggestion.state_detail.name,
|
||||
command: ({ editor, range }) => {
|
||||
editor
|
||||
.chain()
|
||||
|
||||
+3
-2
@@ -9,8 +9,6 @@ export const IssueEmbedSuggestions = Extension.create({
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "#issue_",
|
||||
allowSpaces: true,
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
@@ -20,8 +18,11 @@ export const IssueEmbedSuggestions = Extension.create({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
char: "#issue_",
|
||||
pluginKey: new PluginKey("issue-embed-suggestions"),
|
||||
editor: this.editor,
|
||||
allowSpaces: true,
|
||||
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
|
||||
+16
-31
@@ -53,7 +53,7 @@ const IssueSuggestionList = ({
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
|
||||
let newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
|
||||
let totalLength = 0;
|
||||
sections.forEach((section) => {
|
||||
newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5);
|
||||
@@ -65,8 +65,8 @@ const IssueSuggestionList = ({
|
||||
}, [items]);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(section: string, index: number) => {
|
||||
const item = displayedItems[section][index];
|
||||
(index: number) => {
|
||||
const item = displayedItems[currentSection][index];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
@@ -78,6 +78,7 @@ const IssueSuggestionList = ({
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
// if (editor.isFocused) {
|
||||
// editor.chain().blur();
|
||||
// commandListContainer.current?.focus();
|
||||
@@ -103,7 +104,7 @@ const IssueSuggestionList = ({
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(currentSection, selectedIndex);
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
@@ -145,7 +146,7 @@ const IssueSuggestionList = ({
|
||||
<div
|
||||
id="issue-list-container"
|
||||
ref={commandListContainer}
|
||||
className=" fixed z-[10] max-h-80 w-60 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
|
||||
className="fixed z-[10] max-h-80 w-60 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
|
||||
>
|
||||
{sections.map((section) => {
|
||||
const sectionItems = displayedItems[section];
|
||||
@@ -171,7 +172,7 @@ const IssueSuggestionList = ({
|
||||
}
|
||||
)}
|
||||
key={item.identifier}
|
||||
onClick={() => selectItem(section, index)}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
|
||||
<PriorityIcon priority={item.priority} />
|
||||
@@ -188,37 +189,31 @@ const IssueSuggestionList = ({
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const IssueListRenderer = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
const container = document.querySelector(".frame-renderer") as HTMLElement;
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component = new ReactRenderer(IssueSuggestionList, {
|
||||
props,
|
||||
// @ts-ignore
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy(".frame-renderer", {
|
||||
flipbehavior: ["bottom", "top"],
|
||||
appendTo: () => document.querySelector(".frame-renderer") as HTMLElement,
|
||||
flip: true,
|
||||
flipOnUpdate: true,
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector("#editor-container"),
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
container.addEventListener("scroll", () => {
|
||||
popup?.[0].destroy();
|
||||
placement: "right",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup &&
|
||||
@@ -231,20 +226,10 @@ export const IssueListRenderer = () => {
|
||||
popup?.[0].hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
|
||||
if (navigationKeys.includes(props.event.key)) {
|
||||
// @ts-ignore
|
||||
component?.ref?.onKeyDown(props);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
// @ts-ignore
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: (e) => {
|
||||
const container = document.querySelector(".frame-renderer") as HTMLElement;
|
||||
if (container) {
|
||||
container.removeEventListener("scroll", () => {});
|
||||
}
|
||||
popup?.[0].destroy();
|
||||
setTimeout(() => {
|
||||
component?.destroy();
|
||||
|
||||
+9
-1
@@ -1,3 +1,11 @@
|
||||
import { IssueWidget } from "src/ui/extensions/widgets/issue-embed-widget/issue-widget-node";
|
||||
import { IIssueEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types";
|
||||
|
||||
export const IssueWidgetPlaceholder = () => IssueWidget.configure({});
|
||||
interface IssueWidgetExtensionProps {
|
||||
issueEmbedConfig?: IIssueEmbedConfig;
|
||||
}
|
||||
|
||||
export const IssueWidgetExtension = ({ issueEmbedConfig }: IssueWidgetExtensionProps) =>
|
||||
IssueWidget.configure({
|
||||
issueEmbedConfig,
|
||||
});
|
||||
|
||||
+69
-26
@@ -1,33 +1,76 @@
|
||||
// @ts-nocheck
|
||||
import { Button } from "@plane/ui";
|
||||
import { useState, useEffect } from "react";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import { Crown } from "lucide-react";
|
||||
import { Avatar, AvatarGroup, Loader, PriorityIcon } from "@plane/ui";
|
||||
import { Calendar, AlertTriangle } from "lucide-react";
|
||||
|
||||
export const IssueWidgetCard = (props) => (
|
||||
<NodeViewWrapper className="issue-embed-component m-2">
|
||||
<div
|
||||
className={`${
|
||||
props.selected ? "border-custom-primary-200 border-[2px]" : ""
|
||||
} w-full h-[100px] cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 shadow-custom-shadow-2xs`}
|
||||
>
|
||||
<h5 className="h-[20%] text-xs text-custom-text-300 p-2">
|
||||
{props.node.attrs.project_identifier}-{props.node.attrs.sequence_id}
|
||||
</h5>
|
||||
<div className="relative h-[71%]">
|
||||
<div className="h-full backdrop-filter backdrop-blur-[30px] bg-custom-background-80 bg-opacity-30 flex items-center w-full justify-between gap-5 mt-2.5 pl-4 pr-5 py-3 max-md:max-w-full max-md:flex-wrap relative">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="rounded">
|
||||
<Crown className="m-2" size={16} color="#FFBA18" />
|
||||
export const IssueWidgetCard = (props) => {
|
||||
const [loading, setLoading] = useState<number>(1);
|
||||
const [issueDetails, setIssueDetails] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
props.issueEmbedConfig
|
||||
.fetchIssue(props.node.attrs.entity_identifier)
|
||||
.then((issue) => {
|
||||
setIssueDetails(issue);
|
||||
setLoading(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setLoading(-1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const completeIssueEmbedAction = () => {
|
||||
props.issueEmbedConfig.clickAction(issueDetails.id, props.node.attrs.title);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="issue-embed-component m-2">
|
||||
{loading == 0 ? (
|
||||
<div
|
||||
onClick={completeIssueEmbedAction}
|
||||
className="w-full cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs"
|
||||
>
|
||||
<h5 className="text-xs text-custom-text-300">
|
||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
||||
</h5>
|
||||
<h4 className="break-words text-sm font-medium">{issueDetails.name}</h4>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<div>
|
||||
<PriorityIcon priority={issueDetails.priority} />
|
||||
</div>
|
||||
<div className="text-custom-text text-sm">
|
||||
Embed and access issues in pages seamlessly, upgrade to plane pro now.
|
||||
<div>
|
||||
<AvatarGroup size="sm">
|
||||
{issueDetails.assignee_details.map((assignee) => (
|
||||
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} className={"m-0"} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
{issueDetails.target_date && (
|
||||
<div className="flex h-5 items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs text-custom-text-100">
|
||||
<Calendar className="h-3 w-3" strokeWidth={1.5} />
|
||||
{new Date(issueDetails.target_date).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<a href="https://plane.so/pricing" target="_blank" rel="noreferrer">
|
||||
<Button>Upgrade</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
) : loading == -1 ? (
|
||||
<div className="flex items-center gap-[8px] rounded border-2 border-[#D97706] bg-[#FFFBEB] pb-[10px] pl-[13px] pt-[10px] text-[#D97706]">
|
||||
<AlertTriangle color={"#D97706"} />
|
||||
{"This Issue embed is not found in any project. It can no longer be updated or accessed from here."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs">
|
||||
<Loader className={"px-6"}>
|
||||
<Loader.Item height={"30px"} />
|
||||
<div className={"mt-3 space-y-2"}>
|
||||
<Loader.Item height={"20px"} width={"70%"} />
|
||||
<Loader.Item height={"20px"} width={"60%"} />
|
||||
</div>
|
||||
</Loader>
|
||||
</div>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
+3
-1
@@ -34,7 +34,9 @@ export const IssueWidget = Node.create({
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((props: Object) => <IssueWidgetCard {...props} />);
|
||||
return ReactNodeViewRenderer((props: Object) => (
|
||||
<IssueWidgetCard {...props} issueEmbedConfig={this.options.issueEmbedConfig} />
|
||||
));
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface IEmbedConfig {
|
||||
issueEmbedConfig: IIssueEmbedConfig;
|
||||
}
|
||||
|
||||
export interface IIssueEmbedConfig {
|
||||
fetchIssue: (issueId: string) => Promise<any>;
|
||||
clickAction: (issueId: string, issueTitle: string) => void;
|
||||
issues: Array<any>;
|
||||
}
|
||||
@@ -10,12 +10,13 @@ import { DocumentDetails } from "src/types/editor-types";
|
||||
import { PageRenderer } from "src/ui/components/page-renderer";
|
||||
import { getMenuOptions } from "src/utils/menu-options";
|
||||
import { useRouter } from "next/router";
|
||||
import { IEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types";
|
||||
|
||||
interface IDocumentEditor {
|
||||
// document info
|
||||
documentDetails: DocumentDetails;
|
||||
value: string;
|
||||
rerenderOnPropsChange?: {
|
||||
rerenderOnPropsChange: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
@@ -38,7 +39,7 @@ interface IDocumentEditor {
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
updatePageTitle: (title: string) => void;
|
||||
updatePageTitle: (title: string) => Promise<void>;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
isSubmitting: "submitting" | "submitted" | "saved";
|
||||
|
||||
@@ -46,6 +47,7 @@ interface IDocumentEditor {
|
||||
duplicationConfig?: IDuplicationConfig;
|
||||
pageLockConfig?: IPageLockConfig;
|
||||
pageArchiveConfig?: IPageArchiveConfig;
|
||||
embedConfig?: IEmbedConfig;
|
||||
}
|
||||
interface DocumentEditorProps extends IDocumentEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
@@ -73,23 +75,17 @@ const DocumentEditor = ({
|
||||
duplicationConfig,
|
||||
pageLockConfig,
|
||||
pageArchiveConfig,
|
||||
embedConfig,
|
||||
updatePageTitle,
|
||||
cancelUploadImage,
|
||||
onActionCompleteHandler,
|
||||
rerenderOnPropsChange,
|
||||
}: IDocumentEditor) => {
|
||||
// const [alert, setAlert] = useState<string>("")
|
||||
const { markings, updateMarkings } = useEditorMarkings();
|
||||
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
|
||||
|
||||
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
||||
// loads such that we can invoke it from react when the cursor leaves the container
|
||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||
};
|
||||
|
||||
const editor = useEditor({
|
||||
onChange(json, html) {
|
||||
updateMarkings(json);
|
||||
@@ -108,7 +104,7 @@ const DocumentEditor = ({
|
||||
cancelUploadImage,
|
||||
rerenderOnPropsChange,
|
||||
forwardedRef,
|
||||
extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting),
|
||||
extensions: DocumentEditorExtensions(uploadFile, embedConfig?.issueEmbedConfig, setIsSubmitting),
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
@@ -149,14 +145,13 @@ const DocumentEditor = ({
|
||||
documentDetails={documentDetails}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
<div className="flex h-full w-full overflow-y-auto frame-renderer">
|
||||
<div className="flex h-full w-full overflow-y-auto">
|
||||
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72">
|
||||
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
|
||||
</div>
|
||||
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
||||
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)]">
|
||||
<PageRenderer
|
||||
onActionCompleteHandler={onActionCompleteHandler}
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
readonly={false}
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
|
||||
@@ -48,34 +48,13 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||
function getComplexItems(): BubbleMenuItem[] {
|
||||
const items: BubbleMenuItem[] = [TableItem(editor)];
|
||||
|
||||
if (shouldShowImageItem()) {
|
||||
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
|
||||
}
|
||||
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const complexItems: BubbleMenuItem[] = getComplexItems();
|
||||
|
||||
function shouldShowImageItem(): boolean {
|
||||
if (typeof window !== "undefined") {
|
||||
const selectionRange: any = window?.getSelection();
|
||||
const { selection } = props.editor.state;
|
||||
|
||||
if (selectionRange.rangeCount !== 0) {
|
||||
const range = selectionRange.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
return false;
|
||||
}
|
||||
if (isCellSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center divide-x divide-custom-border-200">
|
||||
<div className="flex items-center gap-0.5 pr-2">
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useState, forwardRef, useEffect } from "react";
|
||||
import { EditorHeader } from "src/ui/components/editor-header";
|
||||
import { PageRenderer } from "src/ui/components/page-renderer";
|
||||
import { SummarySideBar } from "src/ui/components/summary-side-bar";
|
||||
import { IssueWidgetExtension } from "src/ui/extensions/widgets/issue-embed-widget";
|
||||
import { IEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types";
|
||||
import { useEditorMarkings } from "src/hooks/use-editor-markings";
|
||||
import { DocumentDetails } from "src/types/editor-types";
|
||||
import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "src/types/menu-actions";
|
||||
import { getMenuOptions } from "src/utils/menu-options";
|
||||
import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
value: string;
|
||||
@@ -28,6 +29,7 @@ interface IDocumentReadOnlyEditor {
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
}) => void;
|
||||
embedConfig?: IEmbedConfig;
|
||||
}
|
||||
|
||||
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
|
||||
@@ -49,6 +51,7 @@ const DocumentReadOnlyEditor = ({
|
||||
pageDuplicationConfig,
|
||||
pageLockConfig,
|
||||
pageArchiveConfig,
|
||||
embedConfig,
|
||||
rerenderOnPropsChange,
|
||||
onActionCompleteHandler,
|
||||
}: DocumentReadOnlyEditorProps) => {
|
||||
@@ -60,7 +63,7 @@ const DocumentReadOnlyEditor = ({
|
||||
value,
|
||||
forwardedRef,
|
||||
rerenderOnPropsChange,
|
||||
extensions: [IssueWidgetPlaceholder()],
|
||||
extensions: [IssueWidgetExtension({ issueEmbedConfig: embedConfig?.issueEmbedConfig })],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,11 +105,11 @@ const DocumentReadOnlyEditor = ({
|
||||
documentDetails={documentDetails}
|
||||
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
|
||||
/>
|
||||
<div className="flex h-full w-full overflow-y-auto frame-renderer">
|
||||
<div className="flex h-full w-full overflow-y-auto">
|
||||
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-80">
|
||||
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
|
||||
</div>
|
||||
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
||||
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)]">
|
||||
<PageRenderer
|
||||
onActionCompleteHandler={onActionCompleteHandler}
|
||||
updatePageTitle={() => Promise.resolve()}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user