mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
271 Commits
fix/variab
...
chore/tran
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d313c43fa | ||
|
|
58b21644be | ||
|
|
cc7b34e399 | ||
|
|
2d6c26a5d6 | ||
|
|
f1acd46e15 | ||
|
|
c023f7d89b | ||
|
|
8fa45ef9a6 | ||
|
|
8bcc295061 | ||
|
|
1b080012ab | ||
|
|
f6dfca4fdc | ||
|
|
3de655cbd4 | ||
|
|
376f781052 | ||
|
|
827f47809b | ||
|
|
dd11ebf335 | ||
|
|
0c35e196be | ||
|
|
6303847026 | ||
|
|
214692f5b2 | ||
|
|
b7198234de | ||
|
|
7e0ac10fe8 | ||
|
|
f9d154dd82 | ||
|
|
1c6a2fb7dd | ||
|
|
5c272db83b | ||
|
|
602ae01b0b | ||
|
|
cd3fa94b9c | ||
|
|
51c2ea6fcb | ||
|
|
64752de3a8 | ||
|
|
84578a2764 | ||
|
|
126575d22a | ||
|
|
d3af913ec7 | ||
|
|
db4ecee475 | ||
|
|
527c4ece57 | ||
|
|
23b0d4339d | ||
|
|
1478e66dc4 | ||
|
|
a49d899ea1 | ||
|
|
3f6ef56a0f | ||
|
|
cba27c348d | ||
|
|
ffe87cc3b4 | ||
|
|
473932af0a | ||
|
|
a9aeeb6707 | ||
|
|
075eefe1a5 | ||
|
|
54bdd62d0c | ||
|
|
d4ee32cb41 | ||
|
|
31bba2926d | ||
|
|
d6c25a76f6 | ||
|
|
8a792d381b | ||
|
|
4353cc0c4a | ||
|
|
82eea3e802 | ||
|
|
bf1f12378e | ||
|
|
c4a3e1e8ac | ||
|
|
b62b2710f5 | ||
|
|
71b41fa22b | ||
|
|
3528d2c934 | ||
|
|
39ecfbe7e1 | ||
|
|
a95864ba11 | ||
|
|
b88ae112f9 | ||
|
|
2d20278c9b | ||
|
|
8cff059868 | ||
|
|
6a3ccafe35 | ||
|
|
cc9b448a9b | ||
|
|
e071bf4861 | ||
|
|
b9da7df6b7 | ||
|
|
03cc819601 | ||
|
|
e1943ee11e | ||
|
|
b47d2b8825 | ||
|
|
300b47f9a1 | ||
|
|
03a4a97375 | ||
|
|
6157d5771d | ||
|
|
eee43be99a | ||
|
|
4db95cc941 | ||
|
|
6aa139a851 | ||
|
|
ac74cd9e92 | ||
|
|
7ae841d525 | ||
|
|
7aa5b6aa91 | ||
|
|
28c3f9d0cc | ||
|
|
9d01a6d5d7 | ||
|
|
4fd8b4a3a9 | ||
|
|
49cc73b6ed | ||
|
|
363507f987 | ||
|
|
30453d1c79 | ||
|
|
dff12729c0 | ||
|
|
8efe692c80 | ||
|
|
ce57c1423c | ||
|
|
1eb1e82fe4 | ||
|
|
a2328d0cbe | ||
|
|
5096a15051 | ||
|
|
55c2511ab5 | ||
|
|
16bc64e2fa | ||
|
|
14083ea7da | ||
|
|
feb88e64a4 | ||
|
|
a00bb35e54 | ||
|
|
20ba91b98c | ||
|
|
456c7f55a9 | ||
|
|
c2da3ea4c8 | ||
|
|
2b595cfe62 | ||
|
|
7a6b50a6e1 | ||
|
|
a5c2acb5f1 | ||
|
|
4cf0c702ce | ||
|
|
d36c3acbf7 | ||
|
|
e244f48776 | ||
|
|
89d1926727 | ||
|
|
9bd70cdb4e | ||
|
|
99f3d5810d | ||
|
|
10b5c625ef | ||
|
|
c14fb814c4 | ||
|
|
c82dd6901e | ||
|
|
a03a41ea5f | ||
|
|
9f4dd771fc | ||
|
|
0deec92d91 | ||
|
|
d2a6307bb0 | ||
|
|
66be0b1862 | ||
|
|
ddad1767a2 | ||
|
|
6a37a2ce21 | ||
|
|
01bd1bde64 | ||
|
|
9268180aec | ||
|
|
ff778b98f5 | ||
|
|
8f5ce6b232 | ||
|
|
58a4ca9f36 | ||
|
|
312b077657 | ||
|
|
c65e42f807 | ||
|
|
f4af78c0fc | ||
|
|
c0b6abc3d5 | ||
|
|
2f2e6626c6 | ||
|
|
6a8d3202b7 | ||
|
|
51b52a7fc3 | ||
|
|
23ede81737 | ||
|
|
b698f44500 | ||
|
|
421839ec51 | ||
|
|
940b5e4e44 | ||
|
|
6003c88d62 | ||
|
|
74913a6659 | ||
|
|
97578684c6 | ||
|
|
88b4d32220 | ||
|
|
f32635a6a8 | ||
|
|
7fe58e0ea9 | ||
|
|
7f22cd1ac1 | ||
|
|
e2550e0b2d | ||
|
|
b016ed78cf | ||
|
|
c429ca7b36 | ||
|
|
ee22dbba1b | ||
|
|
f4a208bd44 | ||
|
|
8edff26ccd | ||
|
|
d08c03f557 | ||
|
|
0b53912295 | ||
|
|
586a320d86 | ||
|
|
8f3a0be177 | ||
|
|
0679e140a2 | ||
|
|
b611f5110f | ||
|
|
0f7bc6979f | ||
|
|
12501d0597 | ||
|
|
3a86fff7c1 | ||
|
|
58a4b45463 | ||
|
|
22836ea03e | ||
|
|
13cc8b0e96 | ||
|
|
9addcde553 | ||
|
|
26a9b7fced | ||
|
|
62dd80874f | ||
|
|
9ae1ce0a9a | ||
|
|
00cc338c07 | ||
|
|
4432be15e4 | ||
|
|
20893c6017 | ||
|
|
95f43a7bb6 | ||
|
|
fd7eedc343 | ||
|
|
d2c9b437f4 | ||
|
|
2f57d0e138 | ||
|
|
59ddc02a31 | ||
|
|
3ac20741d9 | ||
|
|
8ea0772a1b | ||
|
|
bddad8932b | ||
|
|
8acea7f599 | ||
|
|
a908bf9edd | ||
|
|
79fff4744a | ||
|
|
369d927321 | ||
|
|
996d11de12 | ||
|
|
0345336d90 | ||
|
|
75d14e7c3a | ||
|
|
76fdb81249 | ||
|
|
88669af141 | ||
|
|
71dcbd938e | ||
|
|
4060412b18 | ||
|
|
9d715683f7 | ||
|
|
0eb97e32cd | ||
|
|
3f708e451c | ||
|
|
e962770a5f | ||
|
|
4bd6ee5014 | ||
|
|
e9372adcf4 | ||
|
|
ae3b588081 | ||
|
|
7183cffdb7 | ||
|
|
c779fc095c | ||
|
|
b5493a31f8 | ||
|
|
25eb727eb9 | ||
|
|
ade4d290f5 | ||
|
|
39ca600091 | ||
|
|
cfdb3373c9 | ||
|
|
95175ab939 | ||
|
|
42e928138c | ||
|
|
8db51ab295 | ||
|
|
58ac1dcba1 | ||
|
|
7430ccf9a8 | ||
|
|
85ee7f9af0 | ||
|
|
06ecbe884c | ||
|
|
8a6a5a8ca7 | ||
|
|
87ea13c32e | ||
|
|
e3ceb4825a | ||
|
|
de009d6d10 | ||
|
|
7b3f206f57 | ||
|
|
2018114543 | ||
|
|
ff8c5ee910 | ||
|
|
2ddd7096e4 | ||
|
|
add35b5ea6 | ||
|
|
448a34aa5f | ||
|
|
8c57543f72 | ||
|
|
9062a7b67d | ||
|
|
89603bb2d6 | ||
|
|
3ab959c682 | ||
|
|
d96ab2e7af | ||
|
|
5d8f66ae22 | ||
|
|
ce4a375b62 | ||
|
|
92645c1cee | ||
|
|
ac14d57f8b | ||
|
|
71ebe5ca36 | ||
|
|
bb71e60fcb | ||
|
|
3691cef351 | ||
|
|
c2d8703acf | ||
|
|
507946886f | ||
|
|
24944a03c6 | ||
|
|
cb045abfe1 | ||
|
|
24cc69fd7b | ||
|
|
88c26b334d | ||
|
|
200be0ac7f | ||
|
|
ae657af958 | ||
|
|
f4c4848a0d | ||
|
|
6914dc9f42 | ||
|
|
742559bc63 | ||
|
|
70dacc5e6a | ||
|
|
5b96c970cd | ||
|
|
906d095a1e | ||
|
|
0af9b09844 | ||
|
|
edb68a1bc6 | ||
|
|
790ecee629 | ||
|
|
0cabde03f0 | ||
|
|
d3d3bf79f9 | ||
|
|
6321977f1f | ||
|
|
208df80c86 | ||
|
|
bc27bc9dd2 | ||
|
|
1acaae9d5e | ||
|
|
fbbca0c519 | ||
|
|
a6216beb7e | ||
|
|
732963b591 | ||
|
|
625cbf872b | ||
|
|
d26fb8ce02 | ||
|
|
e4f9d027fe | ||
|
|
c1407daa47 | ||
|
|
2622dd691c | ||
|
|
870ca17e13 | ||
|
|
4652ad0fc3 | ||
|
|
be9dbc1b18 | ||
|
|
3fd2550d69 | ||
|
|
d6bcd8dd15 | ||
|
|
873e4330bc | ||
|
|
ade0aa1643 | ||
|
|
6a13a64996 | ||
|
|
5e6c02358d | ||
|
|
61d6d928ba | ||
|
|
a555df27e7 | ||
|
|
df671d13c7 | ||
|
|
c7ebaf6ba0 | ||
|
|
119d46dc2b | ||
|
|
25f7d813b9 | ||
|
|
ec2af13258 | ||
|
|
8833e4e23b | ||
|
|
752a27a175 |
126
.github/actions/build-push-ce/action.yml
vendored
126
.github/actions/build-push-ce/action.yml
vendored
@@ -1,126 +0,0 @@
|
||||
name: "Build and Push Docker Image"
|
||||
description: "Reusable action for building and pushing Docker images"
|
||||
inputs:
|
||||
docker-username:
|
||||
description: "The Dockerhub username"
|
||||
required: true
|
||||
docker-token:
|
||||
description: "The Dockerhub Token"
|
||||
required: true
|
||||
|
||||
# Docker Image Options
|
||||
docker-image-owner:
|
||||
description: "The owner of the Docker image"
|
||||
required: true
|
||||
docker-image-name:
|
||||
description: "The name of the Docker image"
|
||||
required: true
|
||||
build-context:
|
||||
description: "The build context"
|
||||
required: true
|
||||
default: "."
|
||||
dockerfile-path:
|
||||
description: "The path to the Dockerfile"
|
||||
required: true
|
||||
build-args:
|
||||
description: "The build arguments"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Buildx Options
|
||||
buildx-driver:
|
||||
description: "Buildx driver"
|
||||
required: true
|
||||
default: "docker-container"
|
||||
buildx-version:
|
||||
description: "Buildx version"
|
||||
required: true
|
||||
default: "latest"
|
||||
buildx-platforms:
|
||||
description: "Buildx platforms"
|
||||
required: true
|
||||
default: "linux/amd64"
|
||||
buildx-endpoint:
|
||||
description: "Buildx endpoint"
|
||||
required: true
|
||||
default: "default"
|
||||
|
||||
# Release Build Options
|
||||
build-release:
|
||||
description: "Flag to publish release"
|
||||
required: false
|
||||
default: "false"
|
||||
build-prerelease:
|
||||
description: "Flag to publish prerelease"
|
||||
required: false
|
||||
default: "false"
|
||||
release-version:
|
||||
description: "The release version"
|
||||
required: false
|
||||
default: "latest"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set Docker Tag
|
||||
shell: bash
|
||||
env:
|
||||
IMG_OWNER: ${{ inputs.docker-image-owner }}
|
||||
IMG_NAME: ${{ inputs.docker-image-name }}
|
||||
BUILD_RELEASE: ${{ inputs.build-release }}
|
||||
IS_PRERELEASE: ${{ inputs.build-prerelease }}
|
||||
REL_VERSION: ${{ inputs.release-version }}
|
||||
run: |
|
||||
FLAT_BRANCH_VERSION=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9.-]//g')
|
||||
|
||||
if [ "${{ env.BUILD_RELEASE }}" == "true" ]; then
|
||||
semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$"
|
||||
if [[ ! ${{ env.REL_VERSION }} =~ $semver_regex ]]; then
|
||||
echo "Invalid Release Version Format : ${{ env.REL_VERSION }}"
|
||||
echo "Please provide a valid SemVer version"
|
||||
echo "e.g. v1.2.3 or v1.2.3-alpha-1"
|
||||
echo "Exiting the build process"
|
||||
exit 1 # Exit with status 1 to fail the step
|
||||
fi
|
||||
|
||||
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${{ env.REL_VERSION }}
|
||||
|
||||
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
|
||||
TAG=${TAG},${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:stable
|
||||
fi
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:latest
|
||||
else
|
||||
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${FLAT_BRANCH_VERSION}
|
||||
fi
|
||||
|
||||
echo "DOCKER_TAGS=${TAG}" >> $GITHUB_ENV
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-token}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ inputs.buildx-driver }}
|
||||
version: ${{ inputs.buildx-version }}
|
||||
endpoint: ${{ inputs.buildx-endpoint }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
with:
|
||||
context: ${{ inputs.build-context }}
|
||||
file: ${{ inputs.dockerfile-path }}
|
||||
platforms: ${{ inputs.buildx-platforms }}
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
push: true
|
||||
build-args: ${{ inputs.build-args }}
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ inputs.docker-username }}
|
||||
DOCKER_PASSWORD: ${{ inputs.docker-token }}
|
||||
76
.github/workflows/build-branch.yml
vendored
76
.github/workflows/build-branch.yml
vendored
@@ -25,6 +25,10 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
push:
|
||||
branches:
|
||||
- preview
|
||||
- canary
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.ref_name }}
|
||||
@@ -36,7 +40,7 @@ env:
|
||||
jobs:
|
||||
branch_build_setup:
|
||||
name: Build Setup
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
|
||||
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
|
||||
@@ -160,20 +164,17 @@ jobs:
|
||||
branch_build_push_admin:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_admin == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push Admin Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
- name: Admin Build and Push
|
||||
uses: ./.github/actions/build-push-ce
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }}
|
||||
build-context: .
|
||||
@@ -186,20 +187,17 @@ jobs:
|
||||
branch_build_push_web:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push Web Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
- name: Web Build and Push
|
||||
uses: ./.github/actions/build-push-ce
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }}
|
||||
build-context: .
|
||||
@@ -212,20 +210,17 @@ jobs:
|
||||
branch_build_push_space:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push Space Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
- name: Space Build and Push
|
||||
uses: ./.github/actions/build-push-ce
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }}
|
||||
build-context: .
|
||||
@@ -238,20 +233,17 @@ jobs:
|
||||
branch_build_push_live:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push Live Collaboration Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
- name: Live Build and Push
|
||||
uses: ./.github/actions/build-push-ce
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }}
|
||||
build-context: .
|
||||
@@ -264,20 +256,17 @@ jobs:
|
||||
branch_build_push_apiserver:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push API Server Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
- name: Backend Build and Push
|
||||
uses: ./.github/actions/build-push-ce
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }}
|
||||
build-context: ./apiserver
|
||||
@@ -290,20 +279,17 @@ jobs:
|
||||
branch_build_push_proxy:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push Proxy Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
- name: Proxy Build and Push
|
||||
uses: ./.github/actions/build-push-ce
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
|
||||
build-context: ./nginx
|
||||
@@ -316,7 +302,7 @@ jobs:
|
||||
attach_assets_to_build:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Attach Assets to Release
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -341,7 +327,7 @@ jobs:
|
||||
publish_release:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Build Release
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
[
|
||||
branch_build_setup,
|
||||
|
||||
12
.github/workflows/build-test-pull-request.yml
vendored
12
.github/workflows/build-test-pull-request.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=admin
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=space
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=web
|
||||
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=admin
|
||||
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=space
|
||||
|
||||
@@ -133,6 +133,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=web
|
||||
|
||||
134
CONTRIBUTING.md
134
CONTRIBUTING.md
@@ -62,17 +62,143 @@ To ensure consistency throughout the source code, please keep these rules in min
|
||||
- All features or bug fixes must be tested by one or more specs (unit-tests).
|
||||
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
|
||||
|
||||
## Need help? Questions and suggestions
|
||||
|
||||
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
- Try Plane Cloud and the self hosting platform and give feedback
|
||||
- Add new integrations
|
||||
- Add or update translations
|
||||
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
|
||||
- Share your thoughts and suggestions with us
|
||||
- Help create tutorials and blog posts
|
||||
- Request a feature by submitting a proposal
|
||||
- Report a bug
|
||||
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
|
||||
|
||||
## Contributing to language support
|
||||
This guide is designed to help contributors understand how to add or update translations in the application.
|
||||
|
||||
### Understanding translation structure
|
||||
|
||||
#### File organization
|
||||
Translations are organized by language in the locales directory. Each language has its own folder containing JSON files for translations. Here's how it looks:
|
||||
|
||||
```
|
||||
packages/i18n/src/locales/
|
||||
├── en/
|
||||
│ ├── core.json # Critical translations
|
||||
│ └── translations.json
|
||||
├── fr/
|
||||
│ └── translations.json
|
||||
└── [language]/
|
||||
└── translations.json
|
||||
```
|
||||
#### Nested structure
|
||||
To keep translations organized, we use a nested structure for keys. This makes it easier to manage and locate specific translations. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"issue": {
|
||||
"label": "Work item",
|
||||
"title": {
|
||||
"label": "Work item title"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Translation formatting guide
|
||||
We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) to handle dynamic content, such as variables and pluralization. Here's how to format your translations:
|
||||
|
||||
#### Examples
|
||||
- **Simple variables**
|
||||
```json
|
||||
{
|
||||
"greeting": "Hello, {name}!"
|
||||
}
|
||||
```
|
||||
|
||||
- **Pluralization**
|
||||
```json
|
||||
{
|
||||
"items": "{count, plural, one {Work item} other {Work items}}"
|
||||
}
|
||||
```
|
||||
|
||||
### Contributing guidelines
|
||||
|
||||
#### Updating existing translations
|
||||
1. Locate the key in `locales/<language>/translations.json`.
|
||||
|
||||
2. Update the value while ensuring the key structure remains intact.
|
||||
3. Preserve any existing ICU formats (e.g., variables, pluralization).
|
||||
|
||||
#### Adding new translation keys
|
||||
1. When introducing a new key, ensure it is added to **all** language files, even if translations are not immediately available. Use English as a placeholder if needed.
|
||||
|
||||
2. Keep the nesting structure consistent across all languages.
|
||||
|
||||
3. If the new key requires dynamic content (e.g., variables or pluralization), ensure the ICU format is applied uniformly across all languages.
|
||||
|
||||
### Adding new languages
|
||||
Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully:
|
||||
|
||||
1. **Update type definitions**
|
||||
Add the new language to the TLanguage type in the language definitions file:
|
||||
|
||||
```typescript
|
||||
// types/language.ts
|
||||
export type TLanguage = "en" | "fr" | "your-lang";
|
||||
```
|
||||
|
||||
2. **Add language configuration**
|
||||
Include the new language in the list of supported languages:
|
||||
|
||||
```typescript
|
||||
// constants/language.ts
|
||||
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Your Language", value: "your-lang" }
|
||||
];
|
||||
```
|
||||
|
||||
3. **Create translation files**
|
||||
1. Create a new folder for your language under locales (e.g., `locales/your-lang/`).
|
||||
|
||||
2. Add a `translations.json` file inside the folder.
|
||||
|
||||
3. Copy the structure from an existing translation file and translate all keys.
|
||||
|
||||
4. **Update import logic**
|
||||
Modify the language import logic to include your new language:
|
||||
|
||||
```typescript
|
||||
private importLanguageFile(language: TLanguage): Promise<any> {
|
||||
switch (language) {
|
||||
case "your-lang":
|
||||
return import("../locales/your-lang/translations.json");
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Quality checklist
|
||||
Before submitting your contribution, please ensure the following:
|
||||
|
||||
- All translation keys exist in every language file.
|
||||
- Nested structures match across all language files.
|
||||
- ICU message formats are correctly implemented.
|
||||
- All languages load without errors in the application.
|
||||
- Dynamic values and pluralization work as expected.
|
||||
- There are no missing or untranslated keys.
|
||||
|
||||
#### Pro tips
|
||||
- When in doubt, refer to the English translations for context.
|
||||
- Verify pluralization works with different numbers.
|
||||
- Ensure dynamic values (e.g., `{name}`) are correctly interpolated.
|
||||
- Double-check that nested key access paths are accurate.
|
||||
|
||||
Happy translating! 🌍✨
|
||||
|
||||
## Need help? Questions and suggestions
|
||||
|
||||
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
</a>
|
||||
</p>
|
||||
<h1 align="center"><b>Plane</b></h1>
|
||||
<p align="center"><b>Open-source project management that unlocks customer value</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.com/invite/A92xrEGCge">
|
||||
@@ -57,7 +58,7 @@ Prefer full control over your data and infrastructure? Install and run Plane on
|
||||
| Docker | [](https://developers.plane.so/self-hosting/methods/docker-compose) |
|
||||
| Kubernetes | [](https://developers.plane.so/self-hosting/methods/kubernetes) |
|
||||
|
||||
`Instance admins` can manage and customize settings using [God mode](https://developers.plane.so/self-hosting/govern/instance-admin).
|
||||
`Instance admins` can configure instance settings with [God mode](https://developers.plane.so/self-hosting/govern/instance-admin).
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
@@ -117,9 +118,9 @@ Setting up your local environment is simple and straightforward. Follow these st
|
||||
|
||||
That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉
|
||||
|
||||
## Built with
|
||||
[](https://nextjs.org/)<br/>
|
||||
[](https://www.djangoproject.com/)<br/>
|
||||
## ⚙️ Built with
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.djangoproject.com/)
|
||||
[](https://nodejs.org/en)
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
@@ -2,7 +2,4 @@ module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
FROM node:20-alpine as base
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 1: Build the project
|
||||
# *****************************************************************************
|
||||
FROM node:18-alpine AS builder
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
@@ -13,7 +15,7 @@ RUN turbo prune --scope=admin --docker
|
||||
# *****************************************************************************
|
||||
# STAGE 2: Install dependencies & build the project
|
||||
# *****************************************************************************
|
||||
FROM node:18-alpine AS installer
|
||||
FROM base AS installer
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
@@ -52,7 +54,7 @@ RUN yarn turbo run build --filter=admin
|
||||
# *****************************************************************************
|
||||
# STAGE 3: Copy the project and start it
|
||||
# *****************************************************************************
|
||||
FROM node:18-alpine AS runner
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=installer /app/admin/next.config.js .
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:18-alpine
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { InstanceService } from "@plane/services";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// services
|
||||
import { InstanceService } from "@/services/instance.service";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -123,7 +123,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
|
||||
No PII is collected.This anonymized data is used to understand how you use Plane and build new features
|
||||
in line with{" "}
|
||||
<a
|
||||
href="https://docs.plane.so/self-hosting/telemetry"
|
||||
href="https://developers.plane.so/self-hosting/telemetry"
|
||||
target="_blank"
|
||||
className="text-custom-primary-100 hover:underline"
|
||||
rel="noreferrer"
|
||||
|
||||
@@ -7,15 +7,15 @@ import { DefaultLayout } from "@/layouts/default-layout";
|
||||
export const metadata: Metadata = {
|
||||
title: "Plane | Simple, extensible, open-source project management tool.",
|
||||
description:
|
||||
"Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.",
|
||||
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
|
||||
openGraph: {
|
||||
title: "Plane | Simple, extensible, open-source project management tool.",
|
||||
description:
|
||||
"Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.",
|
||||
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
|
||||
url: "https://plane.so/",
|
||||
},
|
||||
keywords:
|
||||
"software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration",
|
||||
"software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration",
|
||||
twitter: {
|
||||
site: "@planepowers",
|
||||
},
|
||||
|
||||
@@ -2,18 +2,16 @@ import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// constants
|
||||
// plane imports
|
||||
import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
|
||||
// types
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import { IWorkspace } from "@plane/types";
|
||||
// components
|
||||
import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
// services
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
const instanceWorkspaceService = new InstanceWorkspaceService();
|
||||
|
||||
export const WorkspaceCreateForm = () => {
|
||||
// router
|
||||
@@ -40,8 +38,8 @@ export const WorkspaceCreateForm = () => {
|
||||
const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/");
|
||||
|
||||
const handleCreateWorkspace = async (formData: IWorkspace) => {
|
||||
await workspaceService
|
||||
.workspaceSlugCheck(formData.slug)
|
||||
await instanceWorkspaceService
|
||||
.slugCheck(formData.slug)
|
||||
.then(async (res) => {
|
||||
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
|
||||
setSlugError(false);
|
||||
|
||||
@@ -7,12 +7,11 @@ import { LogOut, UserCog2, Palette } from "lucide-react";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import {AuthService } from "@plane/services";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { getFileURL, cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme, useUser } from "@/hooks/store";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
// service initialization
|
||||
const authService = new AuthService();
|
||||
|
||||
@@ -6,12 +6,11 @@ import { useSearchParams } from "next/navigation";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Button, Checkbox, Input, Spinner } from "@plane/ui";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// components
|
||||
import { Banner, PasswordStrengthMeter } from "@/components/common";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
// service initialization
|
||||
const authService = new AuthService();
|
||||
@@ -337,7 +336,7 @@ export const InstanceSetupForm: FC = (props) => {
|
||||
</label>
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://docs.plane.so/telemetry"
|
||||
href="https://developers.plane.so/self-hosting/telemetry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-blue-500 hover:text-blue-600"
|
||||
|
||||
@@ -5,13 +5,12 @@ import { useSearchParams } from "next/navigation";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Button, Input, Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { Banner } from "@/components/common";
|
||||
// helpers
|
||||
import { authErrorHandler } from "@/lib/auth-helpers";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
// local components
|
||||
import { AuthBanner } from "../authentication";
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
// store
|
||||
// import { rootStore } from "@/lib/store-context";
|
||||
|
||||
export abstract class APIService {
|
||||
protected baseURL: string;
|
||||
private axiosInstance: AxiosInstance;
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL;
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors() {
|
||||
// this.axiosInstance.interceptors.response.use(
|
||||
// (response) => response,
|
||||
// (error) => {
|
||||
// const store = rootStore;
|
||||
// if (error.response && error.response.status === 401 && store.user.currentUser) store.user.reset();
|
||||
// return Promise.reject(error);
|
||||
// }
|
||||
// );
|
||||
}
|
||||
|
||||
get<ResponseType>(url: string, params = {}): Promise<AxiosResponse<ResponseType>> {
|
||||
return this.axiosInstance.get(url, { params });
|
||||
}
|
||||
|
||||
post<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
|
||||
return this.axiosInstance.post(url, data, config);
|
||||
}
|
||||
|
||||
put<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
|
||||
return this.axiosInstance.put(url, data, config);
|
||||
}
|
||||
|
||||
patch<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
|
||||
return this.axiosInstance.patch(url, data, config);
|
||||
}
|
||||
|
||||
delete<RequestType>(url: string, data?: RequestType, config = {}) {
|
||||
return this.axiosInstance.delete(url, { data, ...config });
|
||||
}
|
||||
|
||||
request<T>(config: AxiosRequestConfig = {}): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance(config);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
type TCsrfTokenResponse = {
|
||||
csrf_token: string;
|
||||
};
|
||||
|
||||
export class AuthService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async requestCSRFToken(): Promise<TCsrfTokenResponse> {
|
||||
return this.get<TCsrfTokenResponse>("/auth/get-csrf-token/")
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// plane internal packages
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import type {
|
||||
IFormattedInstanceConfiguration,
|
||||
IInstance,
|
||||
IInstanceAdmin,
|
||||
IInstanceConfiguration,
|
||||
IInstanceInfo,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class InstanceService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getInstanceInfo(): Promise<IInstanceInfo> {
|
||||
return this.get<IInstanceInfo>("/api/instances/")
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getInstanceAdmins(): Promise<IInstanceAdmin[]> {
|
||||
return this.get<IInstanceAdmin[]>("/api/instances/admins/")
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async updateInstanceInfo(data: Partial<IInstance>): Promise<IInstance> {
|
||||
return this.patch<Partial<IInstance>, IInstance>("/api/instances/", data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getInstanceConfigurations() {
|
||||
return this.get<IInstanceConfiguration[]>("/api/instances/configurations/")
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async updateInstanceConfigurations(
|
||||
data: Partial<IFormattedInstanceConfiguration>
|
||||
): Promise<IInstanceConfiguration[]> {
|
||||
return this.patch<Partial<IFormattedInstanceConfiguration>, IInstanceConfiguration[]>(
|
||||
"/api/instances/configurations/",
|
||||
data
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async sendTestEmail(receiverEmail: string): Promise<undefined> {
|
||||
return this.post<{ receiver_email: string }, undefined>("/api/instances/email-credentials-check/", {
|
||||
receiver_email: receiverEmail,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// plane internal packages
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import type { IUser } from "@plane/types";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
interface IUserSession extends IUser {
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export class UserService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async authCheck(): Promise<IUserSession> {
|
||||
return this.get<any>("/api/instances/admins/me/")
|
||||
.then((response) => ({ ...response?.data, isAuthenticated: true }))
|
||||
.catch(() => ({ isAuthenticated: false }));
|
||||
}
|
||||
|
||||
async currentUser(): Promise<IUser> {
|
||||
return this.get<IUser>("/api/instances/admins/me/")
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// plane internal packages
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class WorkspaceService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Fetches all workspaces
|
||||
* @returns Promise<TWorkspacePaginationInfo>
|
||||
*/
|
||||
async getWorkspaces(nextPageCursor?: string): Promise<TWorkspacePaginationInfo> {
|
||||
return this.get<TWorkspacePaginationInfo>("/api/instances/workspaces/", {
|
||||
cursor: nextPageCursor,
|
||||
})
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Checks if a slug is available
|
||||
* @param slug - string
|
||||
* @returns Promise<any>
|
||||
*/
|
||||
async workspaceSlugCheck(slug: string): Promise<any> {
|
||||
const params = new URLSearchParams({ slug });
|
||||
return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Creates a new workspace
|
||||
* @param data - IWorkspace
|
||||
* @returns Promise<IWorkspace>
|
||||
*/
|
||||
async createWorkspace(data: IWorkspace): Promise<IWorkspace> {
|
||||
return this.post<IWorkspace, IWorkspace>("/api/instances/workspaces/", data)
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import set from "lodash/set";
|
||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||
// plane internal packages
|
||||
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
|
||||
import {InstanceService} from "@plane/services";
|
||||
import {
|
||||
IInstance,
|
||||
IInstanceAdmin,
|
||||
@@ -10,8 +11,6 @@ import {
|
||||
IInstanceInfo,
|
||||
IInstanceConfig,
|
||||
} from "@plane/types";
|
||||
// services
|
||||
import { InstanceService } from "@/services/instance.service";
|
||||
// root store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
@@ -96,7 +95,7 @@ export class InstanceStore implements IInstanceStore {
|
||||
try {
|
||||
if (this.instance === undefined) this.isLoading = true;
|
||||
this.error = undefined;
|
||||
const instanceInfo = await this.instanceService.getInstanceInfo();
|
||||
const instanceInfo = await this.instanceService.info();
|
||||
// handling the new user popup toggle
|
||||
if (this.instance === undefined && !instanceInfo?.instance?.workspaces_exist)
|
||||
this.store.theme.toggleNewUserPopup();
|
||||
@@ -125,7 +124,7 @@ export class InstanceStore implements IInstanceStore {
|
||||
*/
|
||||
updateInstanceInfo = async (data: Partial<IInstance>) => {
|
||||
try {
|
||||
const instanceResponse = await this.instanceService.updateInstanceInfo(data);
|
||||
const instanceResponse = await this.instanceService.update(data);
|
||||
if (instanceResponse) {
|
||||
runInAction(() => {
|
||||
if (this.instance) set(this.instance, "instance", instanceResponse);
|
||||
@@ -144,7 +143,7 @@ export class InstanceStore implements IInstanceStore {
|
||||
*/
|
||||
fetchInstanceAdmins = async () => {
|
||||
try {
|
||||
const instanceAdmins = await this.instanceService.getInstanceAdmins();
|
||||
const instanceAdmins = await this.instanceService.admins();
|
||||
if (instanceAdmins) runInAction(() => (this.instanceAdmins = instanceAdmins));
|
||||
return instanceAdmins;
|
||||
} catch (error) {
|
||||
@@ -159,7 +158,7 @@ export class InstanceStore implements IInstanceStore {
|
||||
*/
|
||||
fetchInstanceConfigurations = async () => {
|
||||
try {
|
||||
const instanceConfigurations = await this.instanceService.getInstanceConfigurations();
|
||||
const instanceConfigurations = await this.instanceService.configurations();
|
||||
if (instanceConfigurations) runInAction(() => (this.instanceConfigurations = instanceConfigurations));
|
||||
return instanceConfigurations;
|
||||
} catch (error) {
|
||||
@@ -174,7 +173,7 @@ export class InstanceStore implements IInstanceStore {
|
||||
*/
|
||||
updateInstanceConfigurations = async (data: Partial<IFormattedInstanceConfiguration>) => {
|
||||
try {
|
||||
const response = await this.instanceService.updateInstanceConfigurations(data);
|
||||
const response = await this.instanceService.updateConfigurations(data);
|
||||
runInAction(() => {
|
||||
this.instanceConfigurations = this.instanceConfigurations?.map((config) => {
|
||||
const item = response.find((item) => item.key === config.key);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { action, observable, runInAction, makeObservable } from "mobx";
|
||||
// plane internal packages
|
||||
import { EUserStatus, TUserStatus } from "@plane/constants";
|
||||
import { AuthService, UserService } from "@plane/services";
|
||||
import { IUser } from "@plane/types";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
import { UserService } from "@/services/user.service";
|
||||
// root store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
@@ -58,7 +56,7 @@ export class UserStore implements IUserStore {
|
||||
fetchCurrentUser = async () => {
|
||||
try {
|
||||
if (this.currentUser === undefined) this.isLoading = true;
|
||||
const currentUser = await this.userService.currentUser();
|
||||
const currentUser = await this.userService.adminDetails();
|
||||
if (currentUser) {
|
||||
await this.store.instance.fetchInstanceAdmins();
|
||||
runInAction(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import set from "lodash/set";
|
||||
import { action, observable, runInAction, makeObservable, computed } from "mobx";
|
||||
// plane imports
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
|
||||
// services
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
// root store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
@@ -29,7 +29,7 @@ export class WorkspaceStore implements IWorkspaceStore {
|
||||
workspaces: Record<string, IWorkspace> = {};
|
||||
paginationInfo: TPaginationInfo | undefined = undefined;
|
||||
// services
|
||||
workspaceService;
|
||||
instanceWorkspaceService;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
@@ -48,7 +48,7 @@ export class WorkspaceStore implements IWorkspaceStore {
|
||||
// curd actions
|
||||
createWorkspace: action,
|
||||
});
|
||||
this.workspaceService = new WorkspaceService();
|
||||
this.instanceWorkspaceService = new InstanceWorkspaceService();
|
||||
}
|
||||
|
||||
// computed
|
||||
@@ -84,7 +84,7 @@ export class WorkspaceStore implements IWorkspaceStore {
|
||||
} else {
|
||||
this.loader = "init-loader";
|
||||
}
|
||||
const paginatedWorkspaceData = await this.workspaceService.getWorkspaces();
|
||||
const paginatedWorkspaceData = await this.instanceWorkspaceService.list();
|
||||
runInAction(() => {
|
||||
const { results, ...paginationInfo } = paginatedWorkspaceData;
|
||||
results.forEach((workspace: IWorkspace) => {
|
||||
@@ -109,7 +109,7 @@ export class WorkspaceStore implements IWorkspaceStore {
|
||||
if (!this.paginationInfo || this.paginationInfo.next_page_results === false) return [];
|
||||
try {
|
||||
this.loader = "pagination";
|
||||
const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(this.paginationInfo.next_cursor);
|
||||
const paginatedWorkspaceData = await this.instanceWorkspaceService.list(this.paginationInfo.next_cursor);
|
||||
runInAction(() => {
|
||||
const { results, ...paginationInfo } = paginatedWorkspaceData;
|
||||
results.forEach((workspace: IWorkspace) => {
|
||||
@@ -135,7 +135,7 @@ export class WorkspaceStore implements IWorkspaceStore {
|
||||
createWorkspace = async (data: IWorkspace): Promise<IWorkspace> => {
|
||||
try {
|
||||
this.loader = "mutation";
|
||||
const workspace = await this.workspaceService.createWorkspace(data);
|
||||
const workspace = await this.instanceWorkspaceService.create(data);
|
||||
runInAction(() => {
|
||||
set(this.workspaces, [workspace.id], workspace);
|
||||
});
|
||||
|
||||
@@ -18,13 +18,14 @@
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@sentry/nextjs": "^8.32.0",
|
||||
"@plane/services": "*",
|
||||
"@sentry/nextjs": "^8.54.0",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
"axios": "^1.7.4",
|
||||
"axios": "^1.7.9",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.356.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "^14.2.20",
|
||||
@@ -34,19 +35,18 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "7.51.5",
|
||||
"swr": "^2.2.4",
|
||||
"tailwindcss": "3.3.2",
|
||||
"uuid": "^9.0.1",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/tailwind-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/zxcvbn": "^4.4.4",
|
||||
"tailwind-config-custom": "*",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
|
||||
|
||||
module.exports = {
|
||||
presets: [sharedConfig],
|
||||
|
||||
@@ -15,3 +15,4 @@ from .state import StateLiteSerializer, StateSerializer
|
||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
|
||||
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
|
||||
from .intake import IntakeIssueSerializer
|
||||
from .estimate import EstimatePointSerializer
|
||||
@@ -72,6 +72,7 @@ class BaseSerializer(serializers.ModelSerializer):
|
||||
StateLiteSerializer,
|
||||
UserLiteSerializer,
|
||||
WorkspaceLiteSerializer,
|
||||
EstimatePointSerializer,
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@@ -88,6 +89,7 @@ class BaseSerializer(serializers.ModelSerializer):
|
||||
"owned_by": UserLiteSerializer,
|
||||
"members": UserLiteSerializer,
|
||||
"parent": IssueLiteSerializer,
|
||||
"estimate_point": EstimatePointSerializer,
|
||||
}
|
||||
# Check if field in expansion then expand the field
|
||||
if expand in expansion:
|
||||
|
||||
@@ -6,6 +6,7 @@ from .base import BaseSerializer
|
||||
from plane.db.models import Cycle, CycleIssue
|
||||
from plane.utils.timezone_converter import convert_to_utc
|
||||
|
||||
|
||||
class CycleSerializer(BaseSerializer):
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
cancelled_issues = serializers.IntegerField(read_only=True)
|
||||
@@ -30,11 +31,20 @@ class CycleSerializer(BaseSerializer):
|
||||
and data.get("end_date", None) is not None
|
||||
):
|
||||
project_id = self.initial_data.get("project_id") or self.instance.project_id
|
||||
is_start_date_end_date_equal = (
|
||||
True
|
||||
if str(data.get("start_date")) == str(data.get("end_date"))
|
||||
else False
|
||||
)
|
||||
data["start_date"] = convert_to_utc(
|
||||
str(data.get("start_date").date()), project_id, is_start_date=True
|
||||
date=str(data.get("start_date").date()),
|
||||
project_id=project_id,
|
||||
is_start_date=True,
|
||||
)
|
||||
data["end_date"] = convert_to_utc(
|
||||
str(data.get("end_date", None).date()), project_id
|
||||
date=str(data.get("end_date", None).date()),
|
||||
project_id=project_id,
|
||||
is_start_date_end_date_equal=is_start_date_end_date_equal,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
10
apiserver/plane/api/serializers/estimate.py
Normal file
10
apiserver/plane/api/serializers/estimate.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# Module imports
|
||||
from plane.db.models import EstimatePoint
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
class EstimatePointSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = EstimatePoint
|
||||
fields = ["id", "value"]
|
||||
read_only_fields = fields
|
||||
@@ -1,6 +1,7 @@
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from lxml import html
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
@@ -138,47 +139,56 @@ class IssueSerializer(BaseSerializer):
|
||||
updated_by_id = issue.updated_by_id
|
||||
|
||||
if assignees is not None and len(assignees):
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
# Then assign it to default assignee
|
||||
if default_assignee_id is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
else:
|
||||
# Then assign it to default assignee
|
||||
if default_assignee_id is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None and len(labels):
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return issue
|
||||
|
||||
@@ -194,37 +204,45 @@ class IssueSerializer(BaseSerializer):
|
||||
|
||||
if assignees is not None:
|
||||
IssueAssignee.objects.filter(issue=instance).delete()
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None:
|
||||
IssueLabel.objects.filter(issue=instance).delete()
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
# Time updation occues even when other related models are updated
|
||||
instance.updated_at = timezone.now()
|
||||
|
||||
@@ -71,4 +71,9 @@ urlpatterns = [
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="attachment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="issue-attachment",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import (
|
||||
Case,
|
||||
@@ -19,11 +20,11 @@ from django.db.models import (
|
||||
Subquery,
|
||||
)
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import (
|
||||
@@ -50,8 +51,10 @@ from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
CycleIssue,
|
||||
Workspace,
|
||||
)
|
||||
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
|
||||
|
||||
@@ -940,37 +943,162 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
model = FileAsset
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
serializer = IssueAttachmentSerializer(data=request.data)
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", False)
|
||||
size = request.data.get("size")
|
||||
external_id = request.data.get("external_id")
|
||||
external_source = request.data.get("external_source")
|
||||
|
||||
# Check if the request is valid
|
||||
if not name or not size:
|
||||
return Response(
|
||||
{"error": "Invalid request.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
||||
|
||||
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||
return Response(
|
||||
{"error": "Invalid file type.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and FileAsset.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue_id=issue_id,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
issue_id=issue_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
).exists()
|
||||
):
|
||||
issue_attachment = FileAsset.objects.filter(
|
||||
workspace__slug=slug,
|
||||
asset = FileAsset.objects.filter(
|
||||
project_id=project_id,
|
||||
external_id=request.data.get("external_id"),
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
issue_id=issue_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Issue attachment with the same external id and external source already exists",
|
||||
"id": str(issue_attachment.id),
|
||||
"error": "Issue with the same external id and external source already exists",
|
||||
"id": str(asset.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||
# Create a File Asset
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={"name": name, "type": type, "size": size_limit},
|
||||
asset=asset_key,
|
||||
size=size_limit,
|
||||
workspace_id=workspace.id,
|
||||
created_by=request.user,
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
external_id=external_id,
|
||||
external_source=external_source,
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
"upload_data": presigned_url,
|
||||
"asset_id": str(asset.id),
|
||||
"attachment": IssueAttachmentSerializer(asset).data,
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
issue_attachment.is_deleted = True
|
||||
issue_attachment.deleted_at = timezone.now()
|
||||
issue_attachment.save()
|
||||
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.deleted",
|
||||
requested_data=None,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
# Get the storage metadata
|
||||
if not issue_attachment.storage_metadata:
|
||||
get_asset_object_metadata.delay(str(issue_attachment.id))
|
||||
issue_attachment.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(self, request, slug, project_id, issue_id, pk=None):
|
||||
if pk:
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(
|
||||
id=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if not asset.is_uploaded:
|
||||
return Response(
|
||||
{"error": "The asset is not uploaded.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
storage = S3Storage(request=request)
|
||||
presigned_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
disposition="attachment",
|
||||
filename=asset.attributes.get("name"),
|
||||
)
|
||||
return HttpResponseRedirect(presigned_url)
|
||||
|
||||
# Get all the attachments
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
issue_id=issue_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
is_uploaded=True,
|
||||
)
|
||||
# Serialize the attachments
|
||||
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def patch(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = IssueAttachmentSerializer(issue_attachment)
|
||||
|
||||
# Send this activity only if the attachment is not uploaded before
|
||||
if not issue_attachment.is_uploaded:
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.created",
|
||||
requested_data=None,
|
||||
@@ -982,30 +1110,13 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
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)
|
||||
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(pk=pk)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.deleted",
|
||||
requested_data=None,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
# Update the attachment
|
||||
issue_attachment.is_uploaded = True
|
||||
issue_attachment.created_by = request.user
|
||||
|
||||
# Get the storage metadata
|
||||
if not issue_attachment.storage_metadata:
|
||||
get_asset_object_metadata.delay(str(issue_attachment.id))
|
||||
issue_attachment.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
issue_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -28,7 +28,7 @@ from plane.db.models import (
|
||||
Workspace,
|
||||
UserFavorite,
|
||||
)
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from .base import BaseAPIView
|
||||
|
||||
|
||||
@@ -288,16 +288,6 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
# Create the triage state in Backlog group
|
||||
State.objects.get_or_create(
|
||||
name="Triage",
|
||||
group="triage",
|
||||
description="Default state for managing all Intake Issues",
|
||||
project_id=pk,
|
||||
color="#ff7700",
|
||||
is_triage=True,
|
||||
)
|
||||
|
||||
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
|
||||
|
||||
model_activity.delay(
|
||||
@@ -336,6 +326,19 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
entity_type="project", entity_identifier=pk, project_id=pk
|
||||
).delete()
|
||||
project.delete()
|
||||
webhook_activity.delay(
|
||||
event="project",
|
||||
verb="deleted",
|
||||
field=None,
|
||||
old_value=None,
|
||||
new_value=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
current_site=request.META.get("HTTP_ORIGIN"),
|
||||
event_id=project.id,
|
||||
old_identifier=None,
|
||||
new_identifier=None,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ from .workspace import (
|
||||
WorkspaceMemberAdminSerializer,
|
||||
WorkspaceMemberMeSerializer,
|
||||
WorkspaceUserPropertiesSerializer,
|
||||
WorkspaceUserLinkSerializer,
|
||||
WorkspaceRecentVisitSerializer,
|
||||
WorkspaceHomePreferenceSerializer,
|
||||
StickySerializer,
|
||||
)
|
||||
from .project import (
|
||||
ProjectSerializer,
|
||||
@@ -68,6 +72,8 @@ from .issue import (
|
||||
IssueReactionLiteSerializer,
|
||||
IssueAttachmentLiteSerializer,
|
||||
IssueLinkLiteSerializer,
|
||||
IssueVersionDetailSerializer,
|
||||
IssueDescriptionVersionDetailSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
|
||||
@@ -20,12 +20,25 @@ class CycleWriteSerializer(BaseSerializer):
|
||||
data.get("start_date", None) is not None
|
||||
and data.get("end_date", None) is not None
|
||||
):
|
||||
project_id = self.initial_data.get("project_id") or self.instance.project_id
|
||||
project_id = (
|
||||
self.initial_data.get("project_id", None)
|
||||
or (self.instance and self.instance.project_id)
|
||||
or self.context.get("project_id", None)
|
||||
)
|
||||
is_start_date_end_date_equal = (
|
||||
True
|
||||
if str(data.get("start_date")) == str(data.get("end_date"))
|
||||
else False
|
||||
)
|
||||
data["start_date"] = convert_to_utc(
|
||||
str(data.get("start_date").date()), project_id, is_start_date=True
|
||||
date=str(data.get("start_date").date()),
|
||||
project_id=project_id,
|
||||
is_start_date=True,
|
||||
)
|
||||
data["end_date"] = convert_to_utc(
|
||||
str(data.get("end_date", None).date()), project_id
|
||||
date=str(data.get("end_date", None).date()),
|
||||
project_id=project_id,
|
||||
is_start_date_end_date_equal=is_start_date_end_date_equal,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import Dashboard, Widget
|
||||
from plane.db.models import DeprecatedDashboard, DeprecatedWidget
|
||||
|
||||
# Third party frameworks
|
||||
from rest_framework import serializers
|
||||
@@ -8,7 +8,7 @@ from rest_framework import serializers
|
||||
|
||||
class DashboardSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Dashboard
|
||||
model = DeprecatedDashboard
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
@@ -17,5 +17,5 @@ class WidgetSerializer(BaseSerializer):
|
||||
widget_filters = serializers.JSONField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Widget
|
||||
model = DeprecatedWidget
|
||||
fields = ["id", "key", "is_visible", "widget_filters"]
|
||||
|
||||
@@ -53,7 +53,6 @@ def get_entity_model_and_serializer(entity_type):
|
||||
}
|
||||
return entity_map.get(entity_type, (None, None))
|
||||
|
||||
|
||||
class UserFavoriteSerializer(serializers.ModelSerializer):
|
||||
entity_data = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from django.utils import timezone
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import serializers
|
||||
@@ -33,6 +34,8 @@ from plane.db.models import (
|
||||
IssueVote,
|
||||
IssueRelation,
|
||||
State,
|
||||
IssueVersion,
|
||||
IssueDescriptionVersion,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,47 +135,56 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
updated_by_id = issue.updated_by_id
|
||||
|
||||
if assignees is not None and len(assignees):
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
# Then assign it to default assignee
|
||||
if default_assignee_id is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
|
||||
if labels is not None and len(labels):
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
try:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None and len(labels):
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return issue
|
||||
|
||||
@@ -188,37 +200,45 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
|
||||
if assignees is not None:
|
||||
IssueAssignee.objects.filter(issue=instance).delete()
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None:
|
||||
IssueLabel.objects.filter(issue=instance).delete()
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
# Time updation occues even when other related models are updated
|
||||
instance.updated_at = timezone.now()
|
||||
@@ -281,10 +301,26 @@ class IssueRelationSerializer(BaseSerializer):
|
||||
)
|
||||
name = serializers.CharField(source="related_issue.name", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="related_issue.priority", read_only=True)
|
||||
assignee_ids = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = ["id", "project_id", "sequence_id", "relation_type", "name"]
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
|
||||
@@ -296,10 +332,26 @@ class RelatedIssueSerializer(BaseSerializer):
|
||||
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
|
||||
name = serializers.CharField(source="issue.name", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
state_id = serializers.UUIDField(source="issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="issue.priority", read_only=True)
|
||||
assignee_ids = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = ["id", "project_id", "sequence_id", "relation_type", "name"]
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
|
||||
@@ -470,6 +522,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||
"asset",
|
||||
"attributes",
|
||||
# "issue_id",
|
||||
"created_by",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
"asset_url",
|
||||
@@ -667,3 +720,64 @@ class IssueSubscriberSerializer(BaseSerializer):
|
||||
model = IssueSubscriber
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "issue"]
|
||||
|
||||
|
||||
class IssueVersionDetailSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueVersion
|
||||
fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"parent",
|
||||
"state",
|
||||
"estimate_point",
|
||||
"name",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"assignees",
|
||||
"sequence_id",
|
||||
"labels",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"archived_at",
|
||||
"is_draft",
|
||||
"external_source",
|
||||
"external_id",
|
||||
"type",
|
||||
"cycle",
|
||||
"modules",
|
||||
"meta",
|
||||
"name",
|
||||
"last_saved_at",
|
||||
"owned_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = ["workspace", "project", "issue"]
|
||||
|
||||
|
||||
class IssueDescriptionVersionDetailSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueDescriptionVersion
|
||||
fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"description_binary",
|
||||
"description_html",
|
||||
"description_stripped",
|
||||
"description_json",
|
||||
"last_saved_at",
|
||||
"owned_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = ["workspace", "project", "issue"]
|
||||
|
||||
@@ -54,6 +54,8 @@ class PageSerializer(BaseSerializer):
|
||||
labels = validated_data.pop("labels", None)
|
||||
project_id = self.context["project_id"]
|
||||
owned_by_id = self.context["owned_by_id"]
|
||||
description = self.context["description"]
|
||||
description_binary = self.context["description_binary"]
|
||||
description_html = self.context["description_html"]
|
||||
|
||||
# Get the workspace id from the project
|
||||
@@ -62,6 +64,8 @@ class PageSerializer(BaseSerializer):
|
||||
# Create the page
|
||||
page = Page.objects.create(
|
||||
**validated_data,
|
||||
description=description,
|
||||
description_binary=description_binary,
|
||||
description_html=description_html,
|
||||
owned_by_id=owned_by_id,
|
||||
workspace_id=project.workspace_id,
|
||||
|
||||
@@ -90,17 +90,7 @@ class ProjectLiteSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ProjectListSerializer(DynamicBaseSerializer):
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
archived_issues = serializers.IntegerField(read_only=True)
|
||||
archived_sub_issues = serializers.IntegerField(read_only=True)
|
||||
draft_issues = serializers.IntegerField(read_only=True)
|
||||
draft_sub_issues = serializers.IntegerField(read_only=True)
|
||||
sub_issues = serializers.IntegerField(read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_cycles = serializers.IntegerField(read_only=True)
|
||||
total_modules = serializers.IntegerField(read_only=True)
|
||||
is_member = serializers.BooleanField(read_only=True)
|
||||
sort_order = serializers.FloatField(read_only=True)
|
||||
member_role = serializers.IntegerField(read_only=True)
|
||||
anchor = serializers.CharField(read_only=True)
|
||||
@@ -113,14 +103,9 @@ class ProjectListSerializer(DynamicBaseSerializer):
|
||||
if project_members is not None:
|
||||
# Filter members by the project ID
|
||||
return [
|
||||
{
|
||||
"id": member.id,
|
||||
"member_id": member.member_id,
|
||||
"member__display_name": member.member.display_name,
|
||||
"member__avatar": member.member.avatar,
|
||||
"member__avatar_url": member.member.avatar_url,
|
||||
}
|
||||
member.member_id
|
||||
for member in project_members
|
||||
if member.is_active and not member.member.is_bot
|
||||
]
|
||||
return []
|
||||
|
||||
@@ -134,10 +119,6 @@ class ProjectDetailSerializer(BaseSerializer):
|
||||
default_assignee = UserLiteSerializer(read_only=True)
|
||||
project_lead = UserLiteSerializer(read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_cycles = serializers.IntegerField(read_only=True)
|
||||
total_modules = serializers.IntegerField(read_only=True)
|
||||
is_member = serializers.BooleanField(read_only=True)
|
||||
sort_order = serializers.FloatField(read_only=True)
|
||||
member_role = serializers.IntegerField(read_only=True)
|
||||
anchor = serializers.CharField(read_only=True)
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .user import UserLiteSerializer, UserAdminLiteSerializer
|
||||
|
||||
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
WorkspaceMemberInvite,
|
||||
WorkspaceTheme,
|
||||
WorkspaceUserProperties,
|
||||
WorkspaceUserLink,
|
||||
UserRecentVisit,
|
||||
Issue,
|
||||
Page,
|
||||
Project,
|
||||
ProjectMember,
|
||||
WorkspaceHomePreference,
|
||||
Sticky,
|
||||
WorkspaceUserPreference,
|
||||
)
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||
owner = UserLiteSerializer(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
logo_url = serializers.CharField(read_only=True)
|
||||
role = serializers.IntegerField(read_only=True)
|
||||
|
||||
def validate_slug(self, value):
|
||||
# Check if the slug is restricted
|
||||
@@ -44,7 +59,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||
class WorkspaceLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Workspace
|
||||
fields = ["name", "slug", "id"]
|
||||
fields = ["name", "slug", "id", "logo_url"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -75,9 +90,11 @@ class WorkspaceMemberAdminSerializer(DynamicBaseSerializer):
|
||||
|
||||
|
||||
class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||
workspace = WorkspaceLiteSerializer(read_only=True)
|
||||
invite_link = serializers.SerializerMethodField()
|
||||
|
||||
def get_invite_link(self, obj):
|
||||
return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}"
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceMemberInvite
|
||||
@@ -91,6 +108,7 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
"responded_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"invite_link",
|
||||
]
|
||||
|
||||
|
||||
@@ -106,3 +124,183 @@ class WorkspaceUserPropertiesSerializer(BaseSerializer):
|
||||
model = WorkspaceUserProperties
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "user"]
|
||||
|
||||
|
||||
class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = WorkspaceUserLink
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "owner"]
|
||||
|
||||
def to_internal_value(self, data):
|
||||
url = data.get("url", "")
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
data["url"] = "http://" + url
|
||||
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate_url(self, value):
|
||||
url_validator = URLValidator()
|
||||
try:
|
||||
url_validator(value)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError({"error": "Invalid URL format."})
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def create(self, validated_data):
|
||||
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
|
||||
|
||||
url = validated_data.get("url")
|
||||
|
||||
workspace_user_link = WorkspaceUserLink.objects.filter(
|
||||
url=url,
|
||||
workspace_id=validated_data.get("workspace_id"),
|
||||
owner_id=validated_data.get("owner_id")
|
||||
)
|
||||
|
||||
if workspace_user_link.exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this workspace and owner"}
|
||||
)
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
|
||||
|
||||
url = validated_data.get("url")
|
||||
|
||||
workspace_user_link = WorkspaceUserLink.objects.filter(
|
||||
url=url,
|
||||
workspace_id=instance.workspace_id,
|
||||
owner=instance.owner
|
||||
)
|
||||
|
||||
if workspace_user_link.exclude(pk=instance.id).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this workspace and owner"}
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_identifier = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"state",
|
||||
"priority",
|
||||
"assignees",
|
||||
"type",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"project_identifier",
|
||||
]
|
||||
|
||||
def get_project_identifier(self, obj):
|
||||
project = obj.project
|
||||
|
||||
return project.identifier if project else None
|
||||
|
||||
|
||||
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_members = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ["id", "name", "logo_props", "project_members", "identifier"]
|
||||
|
||||
def get_project_members(self, obj):
|
||||
members = ProjectMember.objects.filter(
|
||||
project_id=obj.id, member__is_bot=False, is_active=True
|
||||
).values_list("member", flat=True)
|
||||
|
||||
return members
|
||||
|
||||
|
||||
class PageRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_id = serializers.SerializerMethodField()
|
||||
project_identifier = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Page
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"logo_props",
|
||||
"project_id",
|
||||
"owned_by",
|
||||
"project_identifier",
|
||||
]
|
||||
|
||||
def get_project_id(self, obj):
|
||||
return (
|
||||
obj.project_id
|
||||
if hasattr(obj, "project_id")
|
||||
else obj.projects.values_list("id", flat=True).first()
|
||||
)
|
||||
|
||||
def get_project_identifier(self, obj):
|
||||
project = obj.projects.first()
|
||||
|
||||
return project.identifier if project else None
|
||||
|
||||
|
||||
def get_entity_model_and_serializer(entity_type):
|
||||
entity_map = {
|
||||
"issue": (Issue, IssueRecentVisitSerializer),
|
||||
"page": (Page, PageRecentVisitSerializer),
|
||||
"project": (Project, ProjectRecentVisitSerializer),
|
||||
}
|
||||
return entity_map.get(entity_type, (None, None))
|
||||
|
||||
|
||||
class WorkspaceRecentVisitSerializer(BaseSerializer):
|
||||
entity_data = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserRecentVisit
|
||||
fields = ["id", "entity_name", "entity_identifier", "entity_data", "visited_at"]
|
||||
read_only_fields = ["workspace", "owner", "created_by", "updated_by"]
|
||||
|
||||
def get_entity_data(self, obj):
|
||||
entity_name = obj.entity_name
|
||||
entity_identifier = obj.entity_identifier
|
||||
|
||||
entity_model, entity_serializer = get_entity_model_and_serializer(entity_name)
|
||||
|
||||
if entity_model and entity_serializer:
|
||||
try:
|
||||
entity = entity_model.objects.get(pk=entity_identifier)
|
||||
|
||||
return entity_serializer(entity).data
|
||||
except entity_model.DoesNotExist:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class WorkspaceHomePreferenceSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = WorkspaceHomePreference
|
||||
fields = ["key", "is_enabled", "sort_order"]
|
||||
read_only_fields = ["workspace", "created_by", "updated_by"]
|
||||
|
||||
|
||||
class StickySerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Sticky
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "owner"]
|
||||
extra_kwargs = {"name": {"required": False}}
|
||||
|
||||
|
||||
class WorkspaceUserPreferenceSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = WorkspaceUserPreference
|
||||
fields = ["key", "is_pinned", "sort_order"]
|
||||
read_only_fields = ["workspace", "created_by", "updated_by"]
|
||||
|
||||
@@ -7,6 +7,7 @@ from plane.app.views import (
|
||||
SavedAnalyticEndpoint,
|
||||
ExportAnalyticsEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
ProjectStatsEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,4 +44,9 @@ urlpatterns = [
|
||||
DefaultAnalyticsEndpoint.as_view(),
|
||||
name="default-analytics",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/project-stats/",
|
||||
ProjectStatsEndpoint.as_view(),
|
||||
name="project-analytics",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -24,6 +24,10 @@ from plane.app.views import (
|
||||
IssueDetailEndpoint,
|
||||
IssueAttachmentV2Endpoint,
|
||||
IssueBulkUpdateDateEndpoint,
|
||||
IssueVersionEndpoint,
|
||||
IssueDescriptionVersionEndpoint,
|
||||
IssueMetaEndpoint,
|
||||
IssueDetailIdentifierEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -256,4 +260,34 @@ urlpatterns = [
|
||||
IssueBulkUpdateDateEndpoint.as_view(),
|
||||
name="project-issue-dates",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/",
|
||||
IssueVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/<uuid:pk>/",
|
||||
IssueVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/",
|
||||
IssueDescriptionVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/<uuid:pk>/",
|
||||
IssueDescriptionVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/meta/",
|
||||
IssueMetaEndpoint.as_view(),
|
||||
name="issue-meta",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/work-items/<str:project_identifier>-<str:issue_identifier>/",
|
||||
IssueDetailIdentifierEndpoint.as_view(),
|
||||
name="issue-detail-identifier",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ from plane.app.views import (
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageVersionEndpoint,
|
||||
PageDuplicateEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,4 +79,9 @@ urlpatterns = [
|
||||
PageVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
|
||||
PageDuplicateEndpoint.as_view(),
|
||||
name="page-duplicate",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -23,6 +23,11 @@ urlpatterns = [
|
||||
ProjectViewSet.as_view({"get": "list", "post": "create"}),
|
||||
name="project",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/details/",
|
||||
ProjectViewSet.as_view({"get": "list_detail"}),
|
||||
name="project",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:pk>/",
|
||||
ProjectViewSet.as_view(
|
||||
|
||||
@@ -27,6 +27,11 @@ from plane.app.views import (
|
||||
WorkspaceFavoriteEndpoint,
|
||||
WorkspaceFavoriteGroupEndpoint,
|
||||
WorkspaceDraftIssueViewSet,
|
||||
QuickLinkViewSet,
|
||||
UserRecentVisitViewSet,
|
||||
WorkspaceHomePreferenceViewSet,
|
||||
WorkspaceStickyViewSet,
|
||||
WorkspaceUserPreferenceViewSet,
|
||||
)
|
||||
|
||||
|
||||
@@ -213,4 +218,56 @@ urlpatterns = [
|
||||
WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}),
|
||||
name="workspace-drafts-issues",
|
||||
),
|
||||
# quick link
|
||||
path(
|
||||
"workspaces/<str:slug>/quick-links/",
|
||||
QuickLinkViewSet.as_view({"get": "list", "post": "create"}),
|
||||
name="workspace-quick-links",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/quick-links/<uuid:pk>/",
|
||||
QuickLinkViewSet.as_view(
|
||||
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
|
||||
),
|
||||
name="workspace-quick-links",
|
||||
),
|
||||
# Widgets
|
||||
path(
|
||||
"workspaces/<str:slug>/home-preferences/",
|
||||
WorkspaceHomePreferenceViewSet.as_view(),
|
||||
name="workspace-home-preference",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/home-preferences/<str:key>/",
|
||||
WorkspaceHomePreferenceViewSet.as_view(),
|
||||
name="workspace-home-preference",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/recent-visits/",
|
||||
UserRecentVisitViewSet.as_view({"get": "list"}),
|
||||
name="workspace-recent-visits",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/stickies/",
|
||||
WorkspaceStickyViewSet.as_view({"get": "list", "post": "create"}),
|
||||
name="workspace-sticky",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/stickies/<uuid:pk>/",
|
||||
WorkspaceStickyViewSet.as_view(
|
||||
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
|
||||
),
|
||||
name="workspace-sticky",
|
||||
),
|
||||
# User Preference
|
||||
path(
|
||||
"workspaces/<str:slug>/sidebar-preferences/",
|
||||
WorkspaceUserPreferenceViewSet.as_view(),
|
||||
name="workspace-user-preference",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/sidebar-preferences/<str:key>/",
|
||||
WorkspaceUserPreferenceViewSet.as_view(),
|
||||
name="workspace-user-preference",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -41,10 +41,14 @@ from .workspace.base import (
|
||||
|
||||
from .workspace.draft import WorkspaceDraftIssueViewSet
|
||||
|
||||
from .workspace.home import WorkspaceHomePreferenceViewSet
|
||||
|
||||
from .workspace.favorite import (
|
||||
WorkspaceFavoriteEndpoint,
|
||||
WorkspaceFavoriteGroupEndpoint,
|
||||
)
|
||||
from .workspace.recent_visit import UserRecentVisitViewSet
|
||||
from .workspace.user_preference import WorkspaceUserPreferenceViewSet
|
||||
|
||||
from .workspace.member import (
|
||||
WorkSpaceMemberViewSet,
|
||||
@@ -72,6 +76,8 @@ from .workspace.user import (
|
||||
from .workspace.estimate import WorkspaceEstimatesEndpoint
|
||||
from .workspace.module import WorkspaceModulesEndpoint
|
||||
from .workspace.cycle import WorkspaceCyclesEndpoint
|
||||
from .workspace.quick_link import QuickLinkViewSet
|
||||
from .workspace.sticky import WorkspaceStickyViewSet
|
||||
|
||||
from .state.base import StateViewSet
|
||||
from .view.base import (
|
||||
@@ -110,6 +116,8 @@ from .issue.base import (
|
||||
IssuePaginatedViewSet,
|
||||
IssueDetailEndpoint,
|
||||
IssueBulkUpdateDateEndpoint,
|
||||
IssueMetaEndpoint,
|
||||
IssueDetailIdentifierEndpoint,
|
||||
)
|
||||
|
||||
from .issue.activity import IssueActivityEndpoint
|
||||
@@ -136,6 +144,8 @@ from .issue.sub_issue import SubIssuesEndpoint
|
||||
|
||||
from .issue.subscriber import IssueSubscriberViewSet
|
||||
|
||||
from .issue.version import IssueVersionEndpoint, IssueDescriptionVersionEndpoint
|
||||
|
||||
from .module.base import (
|
||||
ModuleViewSet,
|
||||
ModuleLinkViewSet,
|
||||
@@ -155,6 +165,7 @@ from .page.base import (
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageDuplicateEndpoint,
|
||||
)
|
||||
from .page.version import PageVersionEndpoint
|
||||
|
||||
@@ -181,6 +192,7 @@ from .analytic.base import (
|
||||
SavedAnalyticEndpoint,
|
||||
ExportAnalyticsEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
ProjectStatsEndpoint,
|
||||
)
|
||||
|
||||
from .notification.base import (
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.db.models import Count, F, Sum, Q
|
||||
from django.db.models.functions import ExtractMonth
|
||||
from django.utils import timezone
|
||||
from django.db.models.functions import Concat
|
||||
from django.db.models import Case, When, Value
|
||||
from django.db.models import Case, When, Value, OuterRef, Func
|
||||
from django.db import models
|
||||
|
||||
# Third party imports
|
||||
@@ -15,7 +15,16 @@ from plane.app.permissions import WorkSpaceAdminPermission
|
||||
from plane.app.serializers import AnalyticViewSerializer
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.analytic_plot_export import analytic_export_task
|
||||
from plane.db.models import AnalyticView, Issue, Workspace
|
||||
from plane.db.models import (
|
||||
AnalyticView,
|
||||
Issue,
|
||||
Workspace,
|
||||
Project,
|
||||
ProjectMember,
|
||||
Cycle,
|
||||
Module,
|
||||
)
|
||||
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
@@ -441,3 +450,74 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class ProjectStatsEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def get(self, request, slug):
|
||||
fields = request.GET.get("fields", "").split(",")
|
||||
project_ids = request.GET.get("project_ids", "")
|
||||
|
||||
valid_fields = {
|
||||
"total_issues",
|
||||
"completed_issues",
|
||||
"total_members",
|
||||
"total_cycles",
|
||||
"total_modules",
|
||||
}
|
||||
requested_fields = set(filter(None, fields)) & valid_fields
|
||||
|
||||
if not requested_fields:
|
||||
requested_fields = valid_fields
|
||||
|
||||
projects = Project.objects.filter(workspace__slug=slug)
|
||||
if project_ids:
|
||||
projects = projects.filter(id__in=project_ids.split(","))
|
||||
|
||||
annotations = {}
|
||||
if "total_issues" in requested_fields:
|
||||
annotations["total_issues"] = (
|
||||
Issue.issue_objects.filter(project_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
if "completed_issues" in requested_fields:
|
||||
annotations["completed_issues"] = (
|
||||
Issue.issue_objects.filter(
|
||||
project_id=OuterRef("pk"), state__group="completed"
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
if "total_cycles" in requested_fields:
|
||||
annotations["total_cycles"] = (
|
||||
Cycle.objects.filter(project_id=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
if "total_modules" in requested_fields:
|
||||
annotations["total_modules"] = (
|
||||
Module.objects.filter(project_id=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
if "total_members" in requested_fields:
|
||||
annotations["total_members"] = (
|
||||
ProjectMember.objects.filter(
|
||||
project_id=OuterRef("id"), member__is_bot=False, is_active=True
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
projects = projects.annotate(**annotations).values("id", *requested_fields)
|
||||
return Response(projects, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -679,15 +680,30 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
|
||||
[self.save_project_cover(asset, project_id) for asset in assets]
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION:
|
||||
assets.update(issue_id=entity_id)
|
||||
# For some cases, the bulk api is called after the issue is deleted creating
|
||||
# an integrity error
|
||||
try:
|
||||
assets.update(issue_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
|
||||
assets.update(comment_id=entity_id)
|
||||
# For some cases, the bulk api is called after the comment is deleted
|
||||
# creating an integrity error
|
||||
try:
|
||||
assets.update(comment_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
|
||||
assets.update(page_id=entity_id)
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION:
|
||||
assets.update(draft_issue_id=entity_id)
|
||||
# For some cases, the bulk api is called after the draft issue is deleted
|
||||
# creating an integrity error
|
||||
try:
|
||||
assets.update(draft_issue_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -47,6 +47,7 @@ from plane.db.models import (
|
||||
User,
|
||||
Project,
|
||||
ProjectMember,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
@@ -54,11 +55,7 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
# Module imports
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.utils.timezone_converter import (
|
||||
convert_utc_to_project_timezone,
|
||||
convert_to_utc,
|
||||
user_timezone_converter,
|
||||
)
|
||||
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
|
||||
|
||||
|
||||
class CycleViewSet(BaseViewSet):
|
||||
@@ -136,6 +133,18 @@ class CycleViewSet(BaseViewSet):
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group__in=["cancelled"],
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
@@ -143,10 +152,7 @@ class CycleViewSet(BaseViewSet):
|
||||
& Q(end_date__gte=current_time_in_utc),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(
|
||||
start_date__gt=current_time_in_utc,
|
||||
then=Value("UPCOMING"),
|
||||
),
|
||||
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
|
||||
When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")),
|
||||
When(
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
@@ -221,6 +227,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"completed_issues",
|
||||
"cancelled_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"version",
|
||||
@@ -252,6 +259,7 @@ class CycleViewSet(BaseViewSet):
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
@@ -259,7 +267,9 @@ class CycleViewSet(BaseViewSet):
|
||||
"created_by",
|
||||
)
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
|
||||
data = user_timezone_converter(
|
||||
data, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
@@ -271,7 +281,9 @@ class CycleViewSet(BaseViewSet):
|
||||
request.data.get("start_date", None) is not None
|
||||
and request.data.get("end_date", None) is not None
|
||||
):
|
||||
serializer = CycleWriteSerializer(data=request.data)
|
||||
serializer = CycleWriteSerializer(
|
||||
data=request.data, context={"project_id": project_id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, owned_by=request.user)
|
||||
cycle = (
|
||||
@@ -306,6 +318,11 @@ class CycleViewSet(BaseViewSet):
|
||||
.first()
|
||||
)
|
||||
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
cycle = user_timezone_converter(
|
||||
cycle, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="cycle",
|
||||
@@ -358,7 +375,9 @@ class CycleViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True)
|
||||
serializer = CycleWriteSerializer(
|
||||
cycle, data=request.data, partial=True, context={"project_id": project_id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
cycle = queryset.values(
|
||||
@@ -388,6 +407,11 @@ class CycleViewSet(BaseViewSet):
|
||||
"created_by",
|
||||
).first()
|
||||
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
cycle = user_timezone_converter(
|
||||
cycle, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="cycle",
|
||||
@@ -457,7 +481,9 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
queryset = queryset.first()
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
|
||||
data = user_timezone_converter(
|
||||
data, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
|
||||
recent_visited_task.delay(
|
||||
slug=slug,
|
||||
@@ -518,6 +544,13 @@ class CycleViewSet(BaseViewSet):
|
||||
entity_identifier=pk,
|
||||
project_id=project_id,
|
||||
).delete()
|
||||
# Delete the cycle from recent visits
|
||||
UserRecentVisit.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=pk,
|
||||
entity_name="cycle",
|
||||
).delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -533,8 +566,17 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
start_date = convert_to_utc(str(start_date), project_id, is_start_date=True)
|
||||
end_date = convert_to_utc(str(end_date), project_id)
|
||||
is_start_date_end_date_equal = (
|
||||
True if str(start_date) == str(end_date) else False
|
||||
)
|
||||
start_date = convert_to_utc(
|
||||
date=str(start_date), project_id=project_id, is_start_date=True
|
||||
)
|
||||
end_date = convert_to_utc(
|
||||
date=str(end_date),
|
||||
project_id=project_id,
|
||||
is_start_date_end_date_equal=is_start_date_end_date_equal,
|
||||
)
|
||||
|
||||
# Check if any cycle intersects in the given interval
|
||||
cycles = Cycle.objects.filter(
|
||||
|
||||
@@ -32,15 +32,15 @@ from plane.app.serializers import (
|
||||
WidgetSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Dashboard,
|
||||
DashboardWidget,
|
||||
DeprecatedDashboard,
|
||||
DeprecatedDashboardWidget,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
IssueRelation,
|
||||
Project,
|
||||
Widget,
|
||||
DeprecatedWidget,
|
||||
WorkspaceMember,
|
||||
CycleIssue,
|
||||
)
|
||||
@@ -53,10 +53,10 @@ from .. import BaseAPIView
|
||||
def dashboard_overview_stats(self, request, slug):
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
(Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)),
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
@@ -133,10 +133,13 @@ def dashboard_overview_stats(self, request, slug):
|
||||
|
||||
completed_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[request.user])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
state__group="completed",
|
||||
)
|
||||
.filter(
|
||||
@@ -176,10 +179,13 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
# get all the assigned issues
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[request.user])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
@@ -687,7 +693,7 @@ class DashboardEndpoint(BaseAPIView):
|
||||
if not dashboard_id:
|
||||
dashboard_type = request.GET.get("dashboard_type", None)
|
||||
if dashboard_type == "home":
|
||||
dashboard, created = Dashboard.objects.get_or_create(
|
||||
dashboard, created = DeprecatedDashboard.objects.get_or_create(
|
||||
type_identifier=dashboard_type,
|
||||
owned_by=request.user,
|
||||
is_default=True,
|
||||
@@ -707,24 +713,24 @@ class DashboardEndpoint(BaseAPIView):
|
||||
|
||||
updated_dashboard_widgets = []
|
||||
for widget_key in widgets_to_fetch:
|
||||
widget = Widget.objects.filter(key=widget_key).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
widget = DeprecatedWidget.objects.filter(
|
||||
key=widget_key
|
||||
).values_list("id", flat=True)
|
||||
if widget:
|
||||
updated_dashboard_widgets.append(
|
||||
DashboardWidget(
|
||||
DeprecatedDashboardWidget(
|
||||
widget_id=widget, dashboard_id=dashboard.id
|
||||
)
|
||||
)
|
||||
|
||||
DashboardWidget.objects.bulk_create(
|
||||
DeprecatedDashboardWidget.objects.bulk_create(
|
||||
updated_dashboard_widgets, batch_size=100
|
||||
)
|
||||
|
||||
widgets = (
|
||||
Widget.objects.annotate(
|
||||
DeprecatedWidget.objects.annotate(
|
||||
is_visible=Exists(
|
||||
DashboardWidget.objects.filter(
|
||||
DeprecatedDashboardWidget.objects.filter(
|
||||
widget_id=OuterRef("pk"),
|
||||
dashboard_id=dashboard.id,
|
||||
is_visible=True,
|
||||
@@ -733,7 +739,7 @@ class DashboardEndpoint(BaseAPIView):
|
||||
)
|
||||
.annotate(
|
||||
dashboard_filters=Subquery(
|
||||
DashboardWidget.objects.filter(
|
||||
DeprecatedDashboardWidget.objects.filter(
|
||||
widget_id=OuterRef("pk"),
|
||||
dashboard_id=dashboard.id,
|
||||
filters__isnull=False,
|
||||
@@ -792,7 +798,7 @@ class DashboardEndpoint(BaseAPIView):
|
||||
|
||||
class WidgetsEndpoint(BaseAPIView):
|
||||
def patch(self, request, dashboard_id, widget_id):
|
||||
dashboard_widget = DashboardWidget.objects.filter(
|
||||
dashboard_widget = DeprecatedDashboardWidget.objects.filter(
|
||||
widget_id=widget_id, dashboard_id=dashboard_id
|
||||
).first()
|
||||
dashboard_widget.is_visible = request.data.get(
|
||||
|
||||
222
apiserver/plane/app/views/external/base.py
vendored
222
apiserver/plane/app/views/external/base.py
vendored
@@ -1,71 +1,169 @@
|
||||
# Python imports
|
||||
import requests
|
||||
# Python import
|
||||
import os
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
# Third party import
|
||||
import litellm
|
||||
import requests
|
||||
|
||||
# Third party imports
|
||||
from openai import OpenAI
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Django imports
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseAPIView
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import Workspace, Project
|
||||
from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
||||
# Module import
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.app.serializers import (ProjectLiteSerializer,
|
||||
WorkspaceLiteSerializer)
|
||||
from plane.db.models import Project, Workspace
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
from ..base import BaseAPIView
|
||||
|
||||
|
||||
class LLMProvider:
|
||||
"""Base class for LLM provider configurations"""
|
||||
name: str = ""
|
||||
models: List[str] = []
|
||||
default_model: str = ""
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, str | List[str]]:
|
||||
return {
|
||||
"name": cls.name,
|
||||
"models": cls.models,
|
||||
"default_model": cls.default_model,
|
||||
}
|
||||
|
||||
class OpenAIProvider(LLMProvider):
|
||||
name = "OpenAI"
|
||||
models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"]
|
||||
default_model = "gpt-4o-mini"
|
||||
|
||||
class AnthropicProvider(LLMProvider):
|
||||
name = "Anthropic"
|
||||
models = [
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-2.1",
|
||||
"claude-2",
|
||||
"claude-instant-1.2",
|
||||
"claude-instant-1"
|
||||
]
|
||||
default_model = "claude-3-sonnet-20240229"
|
||||
|
||||
class GeminiProvider(LLMProvider):
|
||||
name = "Gemini"
|
||||
models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"]
|
||||
default_model = "gemini-pro"
|
||||
|
||||
SUPPORTED_PROVIDERS = {
|
||||
"openai": OpenAIProvider,
|
||||
"anthropic": AnthropicProvider,
|
||||
"gemini": GeminiProvider,
|
||||
}
|
||||
|
||||
def get_llm_config() -> Tuple[str | None, str | None, str | None]:
|
||||
"""
|
||||
Helper to get LLM configuration values, returns:
|
||||
- api_key, model, provider
|
||||
"""
|
||||
api_key, provider_key, model = get_configuration_value([
|
||||
{
|
||||
"key": "LLM_API_KEY",
|
||||
"default": os.environ.get("LLM_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "LLM_PROVIDER",
|
||||
"default": os.environ.get("LLM_PROVIDER", "openai"),
|
||||
},
|
||||
{
|
||||
"key": "LLM_MODEL",
|
||||
"default": os.environ.get("LLM_MODEL", None),
|
||||
},
|
||||
])
|
||||
|
||||
provider = SUPPORTED_PROVIDERS.get(provider_key.lower())
|
||||
if not provider:
|
||||
log_exception(ValueError(f"Unsupported provider: {provider_key}"))
|
||||
return None, None, None
|
||||
|
||||
if not api_key:
|
||||
log_exception(ValueError(f"Missing API key for provider: {provider.name}"))
|
||||
return None, None, None
|
||||
|
||||
# If no model specified, use provider's default
|
||||
if not model:
|
||||
model = provider.default_model
|
||||
|
||||
# Validate model is supported by provider
|
||||
if model not in provider.models:
|
||||
log_exception(ValueError(
|
||||
f"Model {model} not supported by {provider.name}. "
|
||||
f"Supported models: {', '.join(provider.models)}"
|
||||
))
|
||||
return None, None, None
|
||||
|
||||
return api_key, model, provider_key
|
||||
|
||||
|
||||
def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]:
|
||||
"""Helper to get LLM completion response"""
|
||||
final_text = task + "\n" + prompt
|
||||
try:
|
||||
# For Gemini, prepend provider name to model
|
||||
if provider.lower() == "gemini":
|
||||
model = f"gemini/{model}"
|
||||
|
||||
response = litellm.completion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": final_text}],
|
||||
api_key=api_key,
|
||||
)
|
||||
text = response.choices[0].message.content.strip()
|
||||
return text, None
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
error_type = e.__class__.__name__
|
||||
if error_type == "AuthenticationError":
|
||||
return None, f"Invalid API key for {provider}"
|
||||
elif error_type == "RateLimitError":
|
||||
return None, f"Rate limit exceeded for {provider}"
|
||||
else:
|
||||
return None, f"Error occurred while generating response from {provider}"
|
||||
|
||||
class GPTIntegrationEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id):
|
||||
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "OPENAI_API_KEY",
|
||||
"default": os.environ.get("OPENAI_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
|
||||
},
|
||||
]
|
||||
)
|
||||
api_key, model, provider = get_llm_config()
|
||||
|
||||
# Get the configuration value
|
||||
# Check the keys
|
||||
if not OPENAI_API_KEY or not GPT_ENGINE:
|
||||
if not api_key or not model or not provider:
|
||||
return Response(
|
||||
{"error": "OpenAI API key and engine is required"},
|
||||
{"error": "LLM provider API key and model are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
prompt = request.data.get("prompt", False)
|
||||
task = request.data.get("task", False)
|
||||
|
||||
if not task:
|
||||
return Response(
|
||||
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
final_text = task + "\n" + prompt
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}]
|
||||
)
|
||||
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
|
||||
if not text and error:
|
||||
return Response(
|
||||
{"error": "An internal error has occurred."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
text = response.choices[0].message.content.strip()
|
||||
text_html = text.replace("\n", "<br/>")
|
||||
return Response(
|
||||
{
|
||||
"response": text,
|
||||
"response_html": text_html,
|
||||
"response_html": text.replace("\n", "<br/>"),
|
||||
"project_detail": ProjectLiteSerializer(project).data,
|
||||
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
|
||||
},
|
||||
@@ -76,47 +174,33 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "OPENAI_API_KEY",
|
||||
"default": os.environ.get("OPENAI_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# Get the configuration value
|
||||
# Check the keys
|
||||
if not OPENAI_API_KEY or not GPT_ENGINE:
|
||||
api_key, model, provider = get_llm_config()
|
||||
|
||||
if not api_key or not model or not provider:
|
||||
return Response(
|
||||
{"error": "OpenAI API key and engine is required"},
|
||||
{"error": "LLM provider API key and model are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
prompt = request.data.get("prompt", False)
|
||||
task = request.data.get("task", False)
|
||||
|
||||
if not task:
|
||||
return Response(
|
||||
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
final_text = task + "\n" + prompt
|
||||
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
|
||||
if not text and error:
|
||||
return Response(
|
||||
{"error": "An internal error has occurred."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}]
|
||||
)
|
||||
|
||||
text = response.choices[0].message.content.strip()
|
||||
text_html = text.replace("\n", "<br/>")
|
||||
return Response(
|
||||
{"response": text, "response_html": text_html}, status=status.HTTP_200_OK
|
||||
{
|
||||
"response": text,
|
||||
"response_html": text.replace("\n", "<br/>"),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -120,10 +120,12 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -44,6 +44,7 @@ from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -671,6 +672,13 @@ class IssueViewSet(BaseViewSet):
|
||||
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
|
||||
issue.delete()
|
||||
# delete the issue from recent visits
|
||||
UserRecentVisit.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=pk,
|
||||
entity_name="issue",
|
||||
).delete(soft=False)
|
||||
issue_activity.delay(
|
||||
type="issue.activity.deleted",
|
||||
requested_data=json.dumps({"issue_id": str(pk)}),
|
||||
@@ -1088,3 +1096,178 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
|
||||
return Response(
|
||||
{"message": "Issues updated successfully"}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class IssueMetaEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
issue = Issue.issue_objects.only("sequence_id", "project__identifier").get(
|
||||
id=issue_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"sequence_id": issue.sequence_id,
|
||||
"project_identifier": issue.project.identifier,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class IssueDetailIdentifierEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, slug, project_identifier, issue_identifier):
|
||||
|
||||
# Fetch the project
|
||||
project = Project.objects.get(
|
||||
identifier__iexact=project_identifier,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
# Check if the user is a member of the project
|
||||
if not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project.id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).exists():
|
||||
return Response(
|
||||
{"error": "You are not allowed to view this issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Fetch the issue
|
||||
issue = (
|
||||
Issue.issue_objects.filter(project_id=project.id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
|
||||
:1
|
||||
]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.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")
|
||||
)
|
||||
.filter(sequence_id=issue_identifier)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("issue", "actor"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related("created_by"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project.id,
|
||||
issue__sequence_id=issue_identifier,
|
||||
subscriber=request.user,
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
# Check if the issue exists
|
||||
if not issue:
|
||||
return Response(
|
||||
{"error": "The required object does not exist."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
"""
|
||||
if the role is guest and guest_view_all_features is false and owned by is not
|
||||
the requesting user then dont show the issue
|
||||
"""
|
||||
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project.id,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and not project.guest_view_all_features
|
||||
and not issue.created_by == request.user
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to view this issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
recent_visited_task.delay(
|
||||
slug=slug,
|
||||
entity_name="issue",
|
||||
entity_identifier=str(issue.id),
|
||||
user_id=str(request.user.id),
|
||||
project_id=str(project.id),
|
||||
)
|
||||
|
||||
# Serialize the issue
|
||||
serializer = IssueDetailSerializer(issue, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from django.utils import timezone
|
||||
from django.db.models import Exists
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -164,24 +165,32 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def create(self, request, slug, project_id, comment_id):
|
||||
serializer = CommentReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id, actor_id=request.user.id, comment_id=comment_id
|
||||
try:
|
||||
serializer = CommentReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
actor_id=request.user.id,
|
||||
comment_id=comment_id,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="comment_reaction.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=None,
|
||||
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)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "Reaction already exists for the user"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="comment_reaction.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=None,
|
||||
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)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def destroy(self, request, slug, project_id, comment_id, reaction_code):
|
||||
|
||||
@@ -35,7 +35,9 @@ class LabelViewSet(BaseViewSet):
|
||||
.order_by("sort_order")
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/labels/", url_params=True, user=False, multiple=True
|
||||
)
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
@@ -53,6 +55,20 @@ class LabelViewSet(BaseViewSet):
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Check if the label name is unique within the project
|
||||
if (
|
||||
"name" in request.data
|
||||
and Label.objects.filter(
|
||||
project_id=kwargs["project_id"], name=request.data["name"]
|
||||
)
|
||||
.exclude(pk=kwargs["pk"])
|
||||
.exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Label with the same name already exists in the project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# call the parent method to perform the update
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@@ -72,7 +88,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||
Label(
|
||||
name=label.get("name", "Migrated"),
|
||||
description=label.get("description", "Migrated Issue"),
|
||||
color=f"#{random.randint(0, 0xFFFFFF+1):06X}",
|
||||
color=f"#{random.randint(0, 0xFFFFFF + 1):06X}",
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
|
||||
@@ -268,27 +268,19 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
def remove_relation(self, request, slug, project_id, issue_id):
|
||||
relation_type = request.data.get("relation_type", None)
|
||||
related_issue = request.data.get("related_issue", None)
|
||||
|
||||
if relation_type in ["blocking", "start_after", "finish_after"]:
|
||||
issue_relation = IssueRelation.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=related_issue,
|
||||
related_issue_id=issue_id,
|
||||
)
|
||||
else:
|
||||
issue_relation = IssueRelation.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
related_issue_id=related_issue,
|
||||
)
|
||||
current_instance = json.dumps(
|
||||
IssueRelationSerializer(issue_relation).data, cls=DjangoJSONEncoder
|
||||
issue_relations = IssueRelation.objects.filter(
|
||||
workspace__slug=slug,
|
||||
).filter(
|
||||
Q(issue_id=related_issue, related_issue_id=issue_id)
|
||||
| Q(issue_id=issue_id, related_issue_id=related_issue)
|
||||
)
|
||||
issue_relation.delete()
|
||||
issue_relations = issue_relations.first()
|
||||
current_instance = json.dumps(
|
||||
IssueRelationSerializer(issue_relations).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
issue_relations.delete()
|
||||
issue_activity.delay(
|
||||
type="issue_relation.activity.deleted",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
|
||||
118
apiserver/plane/app/views/issue/version.py
Normal file
118
apiserver/plane/app/views/issue/version.py
Normal file
@@ -0,0 +1,118 @@
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import IssueVersion, IssueDescriptionVersion
|
||||
from ..base import BaseAPIView
|
||||
from plane.app.serializers import (
|
||||
IssueVersionDetailSerializer,
|
||||
IssueDescriptionVersionDetailSerializer,
|
||||
)
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.global_paginator import paginate
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
|
||||
|
||||
class IssueVersionEndpoint(BaseAPIView):
|
||||
def process_paginated_result(self, fields, results, timezone):
|
||||
paginated_data = results.values(*fields)
|
||||
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
paginated_data = user_timezone_converter(
|
||||
paginated_data, datetime_fields, timezone
|
||||
)
|
||||
|
||||
return paginated_data
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, issue_id, pk=None):
|
||||
if pk:
|
||||
issue_version = IssueVersion.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
|
||||
)
|
||||
|
||||
serializer = IssueVersionDetailSerializer(issue_version)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
cursor = request.GET.get("cursor", None)
|
||||
|
||||
required_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"last_saved_at",
|
||||
"owned_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
|
||||
issue_versions_queryset = IssueVersion.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id
|
||||
)
|
||||
|
||||
paginated_data = paginate(
|
||||
base_queryset=issue_versions_queryset,
|
||||
queryset=issue_versions_queryset,
|
||||
cursor=cursor,
|
||||
on_result=lambda results: self.process_paginated_result(
|
||||
required_fields, results, request.user.user_timezone
|
||||
),
|
||||
)
|
||||
|
||||
return Response(paginated_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueDescriptionVersionEndpoint(BaseAPIView):
|
||||
def process_paginated_result(self, fields, results, timezone):
|
||||
paginated_data = results.values(*fields)
|
||||
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
paginated_data = user_timezone_converter(
|
||||
paginated_data, datetime_fields, timezone
|
||||
)
|
||||
|
||||
return paginated_data
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, issue_id, pk=None):
|
||||
if pk:
|
||||
issue_description_version = IssueDescriptionVersion.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
|
||||
)
|
||||
|
||||
serializer = IssueDescriptionVersionDetailSerializer(
|
||||
issue_description_version
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
cursor = request.GET.get("cursor", None)
|
||||
|
||||
required_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"last_saved_at",
|
||||
"owned_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
|
||||
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id
|
||||
)
|
||||
paginated_data = paginate(
|
||||
base_queryset=issue_description_versions_queryset,
|
||||
queryset=issue_description_versions_queryset,
|
||||
cursor=cursor,
|
||||
on_result=lambda results: self.process_paginated_result(
|
||||
required_fields, results, request.user.user_timezone
|
||||
),
|
||||
)
|
||||
return Response(paginated_data, status=status.HTTP_200_OK)
|
||||
@@ -54,6 +54,7 @@ from plane.db.models import (
|
||||
ModuleLink,
|
||||
ModuleUserProperties,
|
||||
Project,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
@@ -808,6 +809,13 @@ class ModuleViewSet(BaseViewSet):
|
||||
entity_identifier=pk,
|
||||
project_id=project_id,
|
||||
).delete()
|
||||
# delete the module from recent visits
|
||||
UserRecentVisit.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=pk,
|
||||
entity_name="module",
|
||||
).delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
@@ -33,13 +33,14 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
ProjectPage,
|
||||
Project,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.error_codes import ERROR_CODES
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.page_transaction_task import page_transaction
|
||||
from plane.bgtasks.page_version_task import page_version
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
|
||||
from plane.bgtasks.copy_s3_object import copy_s3_objects
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
# Your SQL query
|
||||
@@ -121,6 +122,8 @@ class PageViewSet(BaseViewSet):
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"owned_by_id": request.user.id,
|
||||
"description": request.data.get("description", {}),
|
||||
"description_binary": request.data.get("description_binary", None),
|
||||
"description_html": request.data.get("description_html", "<p></p>"),
|
||||
},
|
||||
)
|
||||
@@ -385,6 +388,13 @@ class PageViewSet(BaseViewSet):
|
||||
entity_identifier=pk,
|
||||
entity_type="page",
|
||||
).delete()
|
||||
# Delete the page from recent visit
|
||||
UserRecentVisit.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=pk,
|
||||
entity_name="page",
|
||||
).delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -553,3 +563,61 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
return Response({"message": "Updated successfully"})
|
||||
else:
|
||||
return Response({"error": "No binary data provided"})
|
||||
|
||||
|
||||
class PageDuplicateEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
).first()
|
||||
|
||||
# get all the project ids where page is present
|
||||
project_ids = ProjectPage.objects.filter(page_id=page_id).values_list(
|
||||
"project_id", flat=True
|
||||
)
|
||||
|
||||
page.pk = None
|
||||
page.name = f"{page.name} (Copy)"
|
||||
page.description_binary = None
|
||||
page.owned_by = request.user
|
||||
page.created_by = request.user
|
||||
page.updated_by = request.user
|
||||
page.save()
|
||||
|
||||
for project_id in project_ids:
|
||||
ProjectPage.objects.create(
|
||||
workspace_id=page.workspace_id,
|
||||
project_id=project_id,
|
||||
page_id=page.id,
|
||||
created_by_id=page.created_by_id,
|
||||
updated_by_id=page.updated_by_id,
|
||||
)
|
||||
|
||||
page_transaction.delay(
|
||||
{"description_html": page.description_html}, None, page.id
|
||||
)
|
||||
|
||||
# Copy the s3 objects uploaded in the page
|
||||
copy_s3_objects.delay(
|
||||
entity_name="PAGE",
|
||||
entity_identifier=page.id,
|
||||
project_id=project_id,
|
||||
slug=slug,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
page = (
|
||||
Page.objects.filter(pk=page.id)
|
||||
.annotate(
|
||||
project_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"projects__id", distinct=True, filter=~Q(projects__id=True)
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
serializer = PageDetailSerializer(page)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
@@ -25,12 +25,9 @@ from plane.app.serializers import (
|
||||
from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE
|
||||
from plane.db.models import (
|
||||
UserFavorite,
|
||||
Cycle,
|
||||
Intake,
|
||||
DeployBoard,
|
||||
IssueUserProperty,
|
||||
Issue,
|
||||
Module,
|
||||
Project,
|
||||
ProjectIdentifier,
|
||||
ProjectMember,
|
||||
@@ -39,7 +36,7 @@ from plane.db.models import (
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.utils.cache import cache_response
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
@@ -73,36 +70,6 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_member=Exists(
|
||||
ProjectMember.objects.filter(
|
||||
member=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_members=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("id"), member__is_bot=False, is_active=True
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
total_modules=Module.objects.filter(project_id=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
member_role=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("pk"),
|
||||
@@ -133,7 +100,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
def list(self, request, slug):
|
||||
def list_detail(self, request, slug):
|
||||
fields = [field for field in request.GET.get("fields", "").split(",") if field]
|
||||
projects = self.get_queryset().order_by("sort_order", "name")
|
||||
if WorkspaceMember.objects.filter(
|
||||
@@ -170,6 +137,73 @@ class ProjectViewSet(BaseViewSet):
|
||||
).data
|
||||
return Response(projects, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
def list(self, request, slug):
|
||||
sort_order = ProjectMember.objects.filter(
|
||||
member=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
is_active=True,
|
||||
).values("sort_order")
|
||||
|
||||
projects = (
|
||||
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related(
|
||||
"workspace", "workspace__owner", "default_assignee", "project_lead"
|
||||
)
|
||||
.annotate(
|
||||
member_role=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("pk"),
|
||||
member_id=self.request.user.id,
|
||||
is_active=True,
|
||||
).values("role")
|
||||
)
|
||||
.annotate(inbox_view=F("intake_view"))
|
||||
.annotate(sort_order=Subquery(sort_order))
|
||||
.distinct()
|
||||
).values(
|
||||
"id",
|
||||
"name",
|
||||
"identifier",
|
||||
"sort_order",
|
||||
"logo_props",
|
||||
"member_role",
|
||||
"archived_at",
|
||||
"workspace",
|
||||
"cycle_view",
|
||||
"issue_views_view",
|
||||
"module_view",
|
||||
"page_view",
|
||||
"inbox_view",
|
||||
"project_lead",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=5
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
project_projectmember__is_active=True,
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=15
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
Q(
|
||||
project_projectmember__member=self.request.user,
|
||||
project_projectmember__is_active=True,
|
||||
)
|
||||
| Q(network=2)
|
||||
)
|
||||
return Response(projects, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
@@ -182,58 +216,6 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
.filter(archived_at__isnull=True)
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("pk")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("pk"), parent__isnull=False
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
archived_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"), archived_at__isnull=False
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
archived_sub_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
archived_at__isnull=False,
|
||||
parent__isnull=False,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
draft_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"), is_draft=True
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
draft_sub_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
is_draft=True,
|
||||
parent__isnull=False,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
).first()
|
||||
|
||||
if project is None:
|
||||
@@ -416,16 +398,6 @@ class ProjectViewSet(BaseViewSet):
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
# Create the triage state in Backlog group
|
||||
State.objects.get_or_create(
|
||||
name="Triage",
|
||||
group="triage",
|
||||
description="Default state for managing all Intake Issues",
|
||||
project_id=pk,
|
||||
color="#ff7700",
|
||||
is_triage=True,
|
||||
)
|
||||
|
||||
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
|
||||
|
||||
model_activity.delay(
|
||||
@@ -472,7 +444,19 @@ class ProjectViewSet(BaseViewSet):
|
||||
):
|
||||
project = Project.objects.get(pk=pk)
|
||||
project.delete()
|
||||
|
||||
webhook_activity.delay(
|
||||
event="project",
|
||||
verb="deleted",
|
||||
field=None,
|
||||
old_value=None,
|
||||
new_value=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
current_site=request.META.get("HTTP_ORIGIN"),
|
||||
event_id=project.id,
|
||||
old_identifier=None,
|
||||
new_identifier=None,
|
||||
)
|
||||
# Delete the project members
|
||||
DeployBoard.objects.filter(project_id=pk, workspace__slug=slug).delete()
|
||||
|
||||
|
||||
@@ -277,28 +277,14 @@ class SearchEndpoint(BaseAPIView):
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
base_filters = Q(
|
||||
q,
|
||||
is_active=True,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
project_id=project_id,
|
||||
role__gt=10,
|
||||
)
|
||||
if issue_id:
|
||||
issue_created_by = (
|
||||
Issue.objects.filter(id=issue_id)
|
||||
.values_list("created_by_id", flat=True)
|
||||
.first()
|
||||
)
|
||||
# Add condition to include `issue_created_by` in the query
|
||||
filters = Q(member_id=issue_created_by) | base_filters
|
||||
else:
|
||||
filters = base_filters
|
||||
|
||||
# Query to fetch users
|
||||
users = (
|
||||
ProjectMember.objects.filter(filters)
|
||||
ProjectMember.objects.filter(
|
||||
q,
|
||||
is_active=True,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(
|
||||
member__avatar_url=Case(
|
||||
When(
|
||||
@@ -318,14 +304,35 @@ class SearchEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.values(
|
||||
"member__avatar_url",
|
||||
"member__display_name",
|
||||
"member__id",
|
||||
)[:count]
|
||||
)
|
||||
|
||||
response_data["user_mention"] = list(users)
|
||||
if issue_id:
|
||||
issue_created_by = (
|
||||
Issue.objects.filter(id=issue_id)
|
||||
.values_list("created_by_id", flat=True)
|
||||
.first()
|
||||
)
|
||||
users = (
|
||||
users.filter(Q(role__gt=10) | Q(member_id=issue_created_by))
|
||||
.distinct()
|
||||
.values(
|
||||
"member__avatar_url",
|
||||
"member__display_name",
|
||||
"member__id",
|
||||
)
|
||||
)
|
||||
else:
|
||||
users = (
|
||||
users.filter(Q(role__gt=10))
|
||||
.distinct()
|
||||
.values(
|
||||
"member__avatar_url",
|
||||
"member__display_name",
|
||||
"member__id",
|
||||
)
|
||||
)
|
||||
|
||||
response_data["user_mention"] = list(users[:count])
|
||||
|
||||
elif query_type == "project":
|
||||
fields = ["name", "identifier"]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Python imports
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
|
||||
@@ -9,7 +7,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import Issue, ProjectMember
|
||||
from plane.db.models import Issue, ProjectMember, IssueRelation
|
||||
from plane.utils.issue_search import search_issues
|
||||
|
||||
|
||||
@@ -47,17 +45,18 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
)
|
||||
if issue_relation == "true" and issue_id:
|
||||
issue = Issue.issue_objects.filter(pk=issue_id).first()
|
||||
related_issue_ids = IssueRelation.objects.filter(
|
||||
Q(related_issue=issue) | Q(issue=issue)
|
||||
).values_list(
|
||||
"issue_id", "related_issue_id"
|
||||
).distinct()
|
||||
|
||||
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
|
||||
|
||||
if issue:
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id),
|
||||
~(
|
||||
Q(issue_related__issue=issue)
|
||||
& Q(issue_related__deleted_at__isnull=True)
|
||||
),
|
||||
~(
|
||||
Q(issue_relation__related_issue=issue)
|
||||
& Q(issue_relation__deleted_at__isnull=True)
|
||||
),
|
||||
~Q(pk__in=related_issue_ids),
|
||||
)
|
||||
if sub_issue == "true" and issue_id:
|
||||
issue = Issue.issue_objects.filter(pk=issue_id).first()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Python imports
|
||||
from itertools import groupby
|
||||
|
||||
# Django imports
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
@@ -37,11 +40,36 @@ class StateViewSet(BaseViewSet):
|
||||
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
serializer = StateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
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)
|
||||
try:
|
||||
serializer = StateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The state name is already taken"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
state = State.objects.get(
|
||||
pk=pk, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
serializer = StateSerializer(state, 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)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The state name is already taken"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
|
||||
@@ -24,6 +24,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
Project,
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -495,6 +496,13 @@ class IssueViewViewSet(BaseViewSet):
|
||||
entity_identifier=pk,
|
||||
entity_type="view",
|
||||
).delete()
|
||||
# Delete the page from recent visit
|
||||
UserRecentVisit.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=pk,
|
||||
entity_name="view",
|
||||
).delete(soft=False)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Only admin or owner can delete the view"},
|
||||
|
||||
@@ -120,7 +120,7 @@ class WebhookLogsEndpoint(BaseAPIView):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def get(self, request, slug, webhook_id):
|
||||
webhook_logs = WebhookLog.objects.filter(
|
||||
workspace__slug=slug, webhook_id=webhook_id
|
||||
workspace__slug=slug, webhook=webhook_id
|
||||
)
|
||||
serializer = WebhookLogSerializer(webhook_logs, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -7,9 +7,11 @@ from datetime import date
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
|
||||
|
||||
from django.db.models.fields import DateField
|
||||
from django.db.models.functions import Cast, ExtractDay, ExtractWeek
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
@@ -62,12 +64,6 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.issue_objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
return (
|
||||
self.filter_queryset(super().get_queryset().select_related("owner"))
|
||||
.order_by("name")
|
||||
@@ -76,8 +72,6 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
workspace_member__is_active=True,
|
||||
)
|
||||
.annotate(total_members=member_count)
|
||||
.annotate(total_issues=issue_count)
|
||||
.select_related("owner")
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
@@ -123,7 +117,14 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
role=20,
|
||||
company_role=request.data.get("company_role", ""),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
# Get total members and role
|
||||
total_members=WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count()
|
||||
data = serializer.data
|
||||
data["total_members"] = total_members
|
||||
data["role"] = 20
|
||||
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
return Response(
|
||||
[serializer.errors[error][0] for error in serializer.errors],
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -166,11 +167,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.issue_objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
role = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
|
||||
.values("role")
|
||||
)
|
||||
|
||||
workspace = (
|
||||
@@ -182,19 +181,19 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
),
|
||||
)
|
||||
)
|
||||
.select_related("owner")
|
||||
.annotate(total_members=member_count)
|
||||
.annotate(total_issues=issue_count)
|
||||
.annotate(role=role, total_members=member_count)
|
||||
.filter(
|
||||
workspace_member__member=request.user, workspace_member__is_active=True
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
workspaces = WorkSpaceSerializer(
|
||||
self.filter_queryset(workspace),
|
||||
fields=fields if fields else None,
|
||||
many=True,
|
||||
).data
|
||||
|
||||
return Response(workspaces, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Django modules
|
||||
from django.db.models import Q
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseAPIView
|
||||
@@ -31,16 +32,21 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = UserFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
user_id=request.user.id,
|
||||
workspace=workspace,
|
||||
project_id=request.data.get("project_id", None),
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = UserFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
user_id=request.user.id,
|
||||
workspace=workspace,
|
||||
project_id=request.data.get("project_id", None),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "Favorite already exists"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def patch(self, request, slug, favorite_id):
|
||||
|
||||
85
apiserver/plane/app/views/workspace/home.py
Normal file
85
apiserver/plane/app/views/workspace/home.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# Module imports
|
||||
from ..base import BaseAPIView
|
||||
from plane.db.models.workspace import WorkspaceHomePreference
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import Workspace
|
||||
from plane.app.serializers.workspace import WorkspaceHomePreferenceSerializer
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class WorkspaceHomePreferenceViewSet(BaseAPIView):
|
||||
model = WorkspaceHomePreference
|
||||
|
||||
def get_serializer_class(self):
|
||||
return WorkspaceHomePreferenceSerializer
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def get(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
get_preference = WorkspaceHomePreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
)
|
||||
|
||||
create_preference_keys = []
|
||||
|
||||
keys = [
|
||||
key
|
||||
for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices
|
||||
if key not in ["quick_tutorial", "new_at_plane"]
|
||||
]
|
||||
|
||||
sort_order_counter = 1
|
||||
|
||||
for preference in keys:
|
||||
if preference not in get_preference.values_list("key", flat=True):
|
||||
create_preference_keys.append(preference)
|
||||
|
||||
sort_order = 1000 - sort_order_counter
|
||||
|
||||
preference = WorkspaceHomePreference.objects.bulk_create(
|
||||
[
|
||||
WorkspaceHomePreference(
|
||||
key=key,
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
for key in create_preference_keys
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
sort_order_counter += 1
|
||||
|
||||
preference = WorkspaceHomePreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
)
|
||||
|
||||
return Response(
|
||||
preference.values("key", "is_enabled", "config", "sort_order"),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def patch(self, request, slug, key):
|
||||
preference = WorkspaceHomePreference.objects.filter(
|
||||
key=key, workspace__slug=slug, user=request.user
|
||||
).first()
|
||||
|
||||
if preference:
|
||||
serializer = WorkspaceHomePreferenceSerializer(
|
||||
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)
|
||||
|
||||
return Response(
|
||||
{"detail": "Preference not found"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
@@ -251,8 +251,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(email=self.request.user.email)
|
||||
.select_related("workspace", "workspace__owner", "created_by")
|
||||
.annotate(total_members=Count("workspace__workspace_member"))
|
||||
.select_related("workspace")
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/", user=False)
|
||||
|
||||
74
apiserver/plane/app/views/workspace/quick_link.py
Normal file
74
apiserver/plane/app/views/workspace/quick_link.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import WorkspaceUserLink, Workspace
|
||||
from plane.app.serializers import WorkspaceUserLinkSerializer
|
||||
from ..base import BaseViewSet
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
|
||||
class QuickLinkViewSet(BaseViewSet):
|
||||
model = WorkspaceUserLink
|
||||
|
||||
def get_serializer_class(self):
|
||||
return WorkspaceUserLinkSerializer
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def create(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = WorkspaceUserLinkSerializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def partial_update(self, request, slug, pk):
|
||||
quick_link = WorkspaceUserLink.objects.filter(
|
||||
pk=pk, workspace__slug=slug, owner=request.user
|
||||
).first()
|
||||
|
||||
if quick_link:
|
||||
serializer = WorkspaceUserLinkSerializer(
|
||||
quick_link, 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)
|
||||
return Response(
|
||||
{"detail": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def retrieve(self, request, slug, pk):
|
||||
try:
|
||||
quick_link = WorkspaceUserLink.objects.get(
|
||||
pk=pk, workspace__slug=slug, owner=request.user
|
||||
)
|
||||
serializer = WorkspaceUserLinkSerializer(quick_link)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except WorkspaceUserLink.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def destroy(self, request, slug, pk):
|
||||
quick_link = WorkspaceUserLink.objects.get(
|
||||
pk=pk, workspace__slug=slug, owner=request.user
|
||||
)
|
||||
quick_link.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def list(self, request, slug):
|
||||
quick_links = WorkspaceUserLink.objects.filter(
|
||||
workspace__slug=slug, owner=request.user
|
||||
)
|
||||
|
||||
serializer = WorkspaceUserLinkSerializer(quick_links, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
35
apiserver/plane/app/views/workspace/recent_visit.py
Normal file
35
apiserver/plane/app/views/workspace/recent_visit.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.db.models import UserRecentVisit
|
||||
from plane.app.serializers import WorkspaceRecentVisitSerializer
|
||||
|
||||
# Modules imports
|
||||
from ..base import BaseViewSet
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
|
||||
class UserRecentVisitViewSet(BaseViewSet):
|
||||
model = UserRecentVisit
|
||||
|
||||
def get_serializer_class(self):
|
||||
return WorkspaceRecentVisitSerializer
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def list(self, request, slug):
|
||||
user_recent_visits = UserRecentVisit.objects.filter(
|
||||
workspace__slug=slug, user=request.user
|
||||
)
|
||||
|
||||
entity_name = request.query_params.get("entity_name")
|
||||
|
||||
if entity_name:
|
||||
user_recent_visits = user_recent_visits.filter(entity_name=entity_name)
|
||||
|
||||
user_recent_visits = user_recent_visits.filter(
|
||||
entity_name__in=["issue", "page", "project"]
|
||||
)
|
||||
|
||||
serializer = WorkspaceRecentVisitSerializer(user_recent_visits[:20], many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
59
apiserver/plane/app/views/workspace/sticky.py
Normal file
59
apiserver/plane/app/views/workspace/sticky.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseViewSet
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.db.models import Sticky, Workspace
|
||||
from plane.app.serializers import StickySerializer
|
||||
|
||||
|
||||
class WorkspaceStickyViewSet(BaseViewSet):
|
||||
serializer_class = StickySerializer
|
||||
model = Sticky
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(owner_id=self.request.user.id)
|
||||
.select_related("workspace", "owner")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
def create(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = StickySerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
def list(self, request, slug):
|
||||
query = request.query_params.get("query", False)
|
||||
stickies = self.get_queryset().order_by("-sort_order")
|
||||
if query:
|
||||
stickies = stickies.filter(description_stripped__icontains=query)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(stickies),
|
||||
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
|
||||
default_per_page=20,
|
||||
)
|
||||
|
||||
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
@@ -375,8 +375,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
state_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -391,8 +394,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
priority_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -426,8 +432,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
assigned_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -438,8 +447,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
pending_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -449,8 +461,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
completed_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
state__group="completed",
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
|
||||
85
apiserver/plane/app/views/workspace/user_preference.py
Normal file
85
apiserver/plane/app/views/workspace/user_preference.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# Module imports
|
||||
from ..base import BaseAPIView
|
||||
from plane.db.models.workspace import WorkspaceUserPreference
|
||||
from plane.app.serializers.workspace import WorkspaceUserPreferenceSerializer
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import Workspace
|
||||
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
model = WorkspaceUserPreference
|
||||
|
||||
def get_serializer_class(self):
|
||||
return WorkspaceUserPreferenceSerializer
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def get(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
get_preference = WorkspaceUserPreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
)
|
||||
|
||||
create_preference_keys = []
|
||||
|
||||
keys = [
|
||||
key
|
||||
for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices
|
||||
]
|
||||
|
||||
for preference in keys:
|
||||
if preference not in get_preference.values_list("key", flat=True):
|
||||
create_preference_keys.append(preference)
|
||||
|
||||
preference = WorkspaceUserPreference.objects.bulk_create(
|
||||
[
|
||||
WorkspaceUserPreference(
|
||||
key=key, user=request.user, workspace=workspace, sort_order=(65535 + (i*10000))
|
||||
)
|
||||
for i, key in enumerate(create_preference_keys)
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
preferences = WorkspaceUserPreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
).order_by("sort_order").values("key", "is_pinned", "sort_order")
|
||||
|
||||
|
||||
user_preferences = {}
|
||||
|
||||
for preference in preferences:
|
||||
user_preferences[(str(preference["key"]))] = {
|
||||
"is_pinned": preference["is_pinned"],
|
||||
"sort_order": preference["sort_order"],
|
||||
}
|
||||
return Response(
|
||||
user_preferences,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def patch(self, request, slug, key):
|
||||
preference = WorkspaceUserPreference.objects.filter(
|
||||
key=key, workspace__slug=slug, user=request.user
|
||||
).first()
|
||||
|
||||
if preference:
|
||||
serializer = WorkspaceUserPreferenceSerializer(
|
||||
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)
|
||||
|
||||
return Response(
|
||||
{"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
@@ -53,7 +53,6 @@ urlpatterns = [
|
||||
path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"),
|
||||
path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"),
|
||||
path("magic-sign-up/", MagicSignUpEndpoint.as_view(), name="magic-sign-up"),
|
||||
path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"),
|
||||
path(
|
||||
"spaces/magic-generate/",
|
||||
MagicGenerateSpaceEndpoint.as_view(),
|
||||
|
||||
150
apiserver/plane/bgtasks/copy_s3_object.py
Normal file
150
apiserver/plane/bgtasks/copy_s3_object.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
import base64
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset, Page, Issue
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.storage import S3Storage
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
def get_entity_id_field(entity_type, entity_id):
|
||||
entity_mapping = {
|
||||
FileAsset.EntityTypeContext.WORKSPACE_LOGO: {"workspace_id": entity_id},
|
||||
FileAsset.EntityTypeContext.PROJECT_COVER: {"project_id": entity_id},
|
||||
FileAsset.EntityTypeContext.USER_AVATAR: {"user_id": entity_id},
|
||||
FileAsset.EntityTypeContext.USER_COVER: {"user_id": entity_id},
|
||||
FileAsset.EntityTypeContext.ISSUE_ATTACHMENT: {"issue_id": entity_id},
|
||||
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: {"issue_id": entity_id},
|
||||
FileAsset.EntityTypeContext.PAGE_DESCRIPTION: {"page_id": entity_id},
|
||||
FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: {"comment_id": entity_id},
|
||||
FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: {
|
||||
"draft_issue_id": entity_id
|
||||
},
|
||||
}
|
||||
return entity_mapping.get(entity_type, {})
|
||||
|
||||
|
||||
def extract_asset_ids(html, tag):
|
||||
try:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
return [tag.get("src") for tag in soup.find_all(tag) if tag.get("src")]
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return []
|
||||
|
||||
|
||||
def replace_asset_ids(html, tag, duplicated_assets):
|
||||
try:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for mention_tag in soup.find_all(tag):
|
||||
for asset in duplicated_assets:
|
||||
if mention_tag.get("src") == asset["old_asset_id"]:
|
||||
mention_tag["src"] = asset["new_asset_id"]
|
||||
return str(soup)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return html
|
||||
|
||||
|
||||
def update_description(entity, duplicated_assets, tag):
|
||||
updated_html = replace_asset_ids(entity.description_html, tag, duplicated_assets)
|
||||
entity.description_html = updated_html
|
||||
entity.save()
|
||||
return updated_html
|
||||
|
||||
|
||||
# Get the description binary and description from the live server
|
||||
def sync_with_external_service(entity_name, description_html):
|
||||
try:
|
||||
data = {
|
||||
"description_html": description_html,
|
||||
"variant": "rich" if entity_name == "PAGE" else "document",
|
||||
}
|
||||
response = requests.post(
|
||||
f"{settings.LIVE_BASE_URL}/convert-document/",
|
||||
json=data,
|
||||
headers=None,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log_exception(e)
|
||||
return {}
|
||||
|
||||
|
||||
@shared_task
|
||||
def copy_s3_objects(entity_name, entity_identifier, project_id, slug, user_id):
|
||||
"""
|
||||
Step 1: Extract asset ids from the description_html of the entity
|
||||
Step 2: Duplicate the assets
|
||||
Step 3: Update the description_html of the entity with the new asset ids (change the src of img tag)
|
||||
Step 4: Request the live server to generate the description_binary and description for the entity
|
||||
|
||||
"""
|
||||
try:
|
||||
model_class = {"PAGE": Page, "ISSUE": Issue}.get(entity_name)
|
||||
if not model_class:
|
||||
raise ValueError(f"Unsupported entity_name: {entity_name}")
|
||||
|
||||
entity = model_class.objects.get(id=entity_identifier)
|
||||
asset_ids = extract_asset_ids(entity.description_html, "image-component")
|
||||
|
||||
duplicated_assets = []
|
||||
workspace = entity.workspace
|
||||
storage = S3Storage()
|
||||
original_assets = FileAsset.objects.filter(
|
||||
workspace=workspace, project_id=project_id, id__in=asset_ids
|
||||
)
|
||||
|
||||
for original_asset in original_assets:
|
||||
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
|
||||
duplicated_asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": original_asset.attributes.get("name"),
|
||||
"type": original_asset.attributes.get("type"),
|
||||
"size": original_asset.attributes.get("size"),
|
||||
},
|
||||
asset=destination_key,
|
||||
size=original_asset.size,
|
||||
workspace=workspace,
|
||||
created_by_id=user_id,
|
||||
entity_type=original_asset.entity_type,
|
||||
project_id=project_id,
|
||||
storage_metadata=original_asset.storage_metadata,
|
||||
**get_entity_id_field(original_asset.entity_type, entity_identifier),
|
||||
)
|
||||
storage.copy_object(original_asset.asset, destination_key)
|
||||
duplicated_assets.append(
|
||||
{
|
||||
"new_asset_id": str(duplicated_asset.id),
|
||||
"old_asset_id": str(original_asset.id),
|
||||
}
|
||||
)
|
||||
|
||||
if duplicated_assets:
|
||||
FileAsset.objects.filter(
|
||||
pk__in=[item["new_asset_id"] for item in duplicated_assets]
|
||||
).update(is_uploaded=True)
|
||||
updated_html = update_description(
|
||||
entity, duplicated_assets, "image-component"
|
||||
)
|
||||
external_data = sync_with_external_service(entity_name, updated_html)
|
||||
|
||||
if external_data:
|
||||
entity.description = external_data.get("description")
|
||||
entity.description_binary = base64.b64decode(
|
||||
external_data.get("description_binary")
|
||||
)
|
||||
entity.save()
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return []
|
||||
@@ -82,7 +82,10 @@ def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
|
||||
)
|
||||
else:
|
||||
# Handle other relationships
|
||||
related_queryset = getattr(instance, related_name).all()
|
||||
related_queryset = getattr(instance, related_name)(
|
||||
manager="objects"
|
||||
).all()
|
||||
|
||||
for related_obj in related_queryset:
|
||||
if hasattr(related_obj, "deleted_at"):
|
||||
if not related_obj.deleted_at:
|
||||
|
||||
@@ -738,8 +738,10 @@ def delete_comment_activity(
|
||||
issue_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_comment_id=requested_data.get("comment_id", None),
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
@@ -788,14 +790,15 @@ def create_cycle_issue_activity(
|
||||
issue_id=updated_record.get("issue_id"),
|
||||
actor_id=actor_id,
|
||||
verb="updated",
|
||||
old_value=old_cycle.name,
|
||||
new_value=new_cycle.name,
|
||||
old_value=old_cycle.name if old_cycle else "",
|
||||
new_value=new_cycle.name if new_cycle else "",
|
||||
field="cycles",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
|
||||
old_identifier=old_cycle.id,
|
||||
new_identifier=new_cycle.id,
|
||||
comment=f"""updated cycle from {old_cycle.name if old_cycle else ""}
|
||||
to {new_cycle.name if new_cycle else ""}""",
|
||||
old_identifier=old_cycle.id if old_cycle else None,
|
||||
new_identifier=new_cycle.id if new_cycle else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
@@ -891,11 +894,11 @@ def create_module_issue_activity(
|
||||
actor_id=actor_id,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=module.name,
|
||||
new_value=module.name if module else "",
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"added module {module.name}",
|
||||
comment=f"added module {module.name if module else ''}",
|
||||
new_identifier=requested_data.get("module_id"),
|
||||
epoch=epoch,
|
||||
)
|
||||
@@ -1411,7 +1414,7 @@ def delete_issue_relation_activity(
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f'deleted {requested_data.get("relation_type")} relation',
|
||||
comment=f"deleted {requested_data.get('relation_type')} relation",
|
||||
old_identifier=requested_data.get("related_issue"),
|
||||
epoch=epoch,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
from django.utils import timezone
|
||||
from django.db import DatabaseError
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -22,8 +23,12 @@ def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slu
|
||||
).first()
|
||||
|
||||
if recent_visited:
|
||||
recent_visited.visited_at = timezone.now()
|
||||
recent_visited.save(update_fields=["visited_at"])
|
||||
# Check if the database is available
|
||||
try:
|
||||
recent_visited.visited_at = timezone.now()
|
||||
recent_visited.save(update_fields=["visited_at"])
|
||||
except DatabaseError:
|
||||
pass
|
||||
else:
|
||||
recent_visited_count = UserRecentVisit.objects.filter(
|
||||
user_id=user_id, workspace_id=workspace.id
|
||||
|
||||
@@ -136,7 +136,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
|
||||
# Log the webhook request
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook_id=str(webhook.id),
|
||||
webhook=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
@@ -153,7 +153,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
|
||||
# Log the failed webhook request
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook_id=str(webhook.id),
|
||||
webhook=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
@@ -304,7 +304,7 @@ def webhook_send_task(
|
||||
# Log the webhook request
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook_id=str(webhook.id),
|
||||
webhook=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
@@ -319,7 +319,7 @@ def webhook_send_task(
|
||||
# Log the failed webhook request
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook_id=str(webhook.id),
|
||||
webhook=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
@@ -387,7 +387,11 @@ def webhook_activity(
|
||||
webhook=webhook.id,
|
||||
slug=slug,
|
||||
event=event,
|
||||
event_data=get_model_data(event=event, event_id=event_id),
|
||||
event_data=(
|
||||
{"id": event_id}
|
||||
if verb == "deleted"
|
||||
else get_model_data(event=event, event_id=event_id)
|
||||
),
|
||||
action=verb,
|
||||
current_site=current_site,
|
||||
activity={
|
||||
|
||||
@@ -16,9 +16,9 @@ from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
def workspace_invitation(email, workspace_id, token, current_site, inviter):
|
||||
try:
|
||||
user = User.objects.get(email=invitor)
|
||||
user = User.objects.get(email=inviter)
|
||||
|
||||
workspace = Workspace.objects.get(pk=workspace_id)
|
||||
workspace_member_invite = WorkspaceMemberInvite.objects.get(
|
||||
@@ -26,7 +26,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
)
|
||||
|
||||
# Relative link
|
||||
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}"
|
||||
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501
|
||||
|
||||
# The complete url including the domain
|
||||
abs_url = str(current_site) + relative_link
|
||||
@@ -42,7 +42,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
) = get_email_configuration()
|
||||
|
||||
# Subject of the email
|
||||
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane"
|
||||
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" # noqa: E501
|
||||
|
||||
context = {
|
||||
"email": email,
|
||||
@@ -78,11 +78,9 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane").info("Email sent succesfully")
|
||||
|
||||
logging.getLogger("plane").info("Email sent successfully")
|
||||
return
|
||||
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
|
||||
log_exception(e)
|
||||
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist):
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Generated by Django 4.2.17 on 2025-01-02 07:47
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0088_sticky_sort_order_workspaceuserlink"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WorkspaceHomePreference",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Deleted At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("key", models.CharField(max_length=255)),
|
||||
("is_enabled", models.BooleanField(default=True)),
|
||||
("config", models.JSONField(default=dict)),
|
||||
(
|
||||
"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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"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="workspace_user_home_preferences",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_user_home_preferences",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Workspace Home Preference",
|
||||
"verbose_name_plural": "Workspace Home Preferences",
|
||||
"db_table": "workspace_home_preferences",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="workspacehomepreference",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("workspace", "user", "key"),
|
||||
name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="workspacehomepreference",
|
||||
unique_together={("workspace", "user", "key", "deleted_at")},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="page",
|
||||
name="name",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="sticky",
|
||||
name="name",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacehomepreference',
|
||||
name='sort_order',
|
||||
field=models.PositiveIntegerField(default=65535),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
# Generated by Django 4.2.17 on 2025-01-09 14:43
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0089_workspacehomepreference_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='Dashboard',
|
||||
new_name='DeprecatedDashboard',
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name='DashboardWidget',
|
||||
new_name='DeprecatedDashboardWidget',
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name='Widget',
|
||||
new_name='DeprecatedWidget',
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='deprecateddashboard',
|
||||
options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedDashboard', 'verbose_name_plural': 'DeprecatedDashboards'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='deprecateddashboardwidget',
|
||||
options={'ordering': ('-created_at',), 'verbose_name': 'Deprecated Dashboard Widget', 'verbose_name_plural': 'Deprecated Dashboard Widgets'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='deprecatedwidget',
|
||||
options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedWidget', 'verbose_name_plural': 'DeprecatedWidgets'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workspacehomepreference',
|
||||
name='sort_order',
|
||||
field=models.FloatField(default=65535),
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='deprecateddashboard',
|
||||
table='deprecated_dashboards',
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='deprecateddashboardwidget',
|
||||
table='deprecated_dashboard_widgets',
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='deprecatedwidget',
|
||||
table='deprecated_widgets',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WorkspaceUserPreference',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('key', models.CharField(max_length=255)),
|
||||
('is_pinned', models.BooleanField(default=False)),
|
||||
('sort_order', models.FloatField(default=65535)),
|
||||
('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')),
|
||||
('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='workspace_user_preferences', to=settings.AUTH_USER_MODEL)),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_user_preferences', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Workspace User Preference',
|
||||
'verbose_name_plural': 'Workspace User Preferences',
|
||||
'db_table': 'workspace_user_preferences',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='workspaceuserpreference',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('workspace', 'user', 'key'), name='workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='workspaceuserpreference',
|
||||
unique_together={('workspace', 'user', 'key', 'deleted_at')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.17 on 2025-01-30 16:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0090_rename_dashboard_deprecateddashboard_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issuecomment',
|
||||
name='edited_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='is_smooth_cursor_enabled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userrecentvisit',
|
||||
name='entity_name',
|
||||
field=models.CharField(max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhooklog',
|
||||
name='webhook',
|
||||
field=models.UUIDField(),
|
||||
)
|
||||
]
|
||||
@@ -3,7 +3,7 @@ from .api import APIActivityLog, APIToken
|
||||
from .asset import FileAsset
|
||||
from .base import BaseModel
|
||||
from .cycle import Cycle, CycleIssue, CycleUserProperties
|
||||
from .dashboard import Dashboard, DashboardWidget, Widget
|
||||
from .dashboard import DeprecatedDashboard, DeprecatedDashboardWidget, DeprecatedWidget
|
||||
from .deploy_board import DeployBoard
|
||||
from .draft import (
|
||||
DraftIssue,
|
||||
@@ -69,6 +69,8 @@ from .workspace import (
|
||||
WorkspaceTheme,
|
||||
WorkspaceUserProperties,
|
||||
WorkspaceUserLink,
|
||||
WorkspaceHomePreference,
|
||||
WorkspaceUserPreference,
|
||||
)
|
||||
|
||||
from .favorite import UserFavorite
|
||||
|
||||
@@ -8,7 +8,7 @@ from ..mixins import TimeAuditModel
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Dashboard(BaseModel):
|
||||
class DeprecatedDashboard(BaseModel):
|
||||
DASHBOARD_CHOICES = (
|
||||
("workspace", "Workspace"),
|
||||
("project", "Project"),
|
||||
@@ -36,13 +36,13 @@ class Dashboard(BaseModel):
|
||||
return f"{self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Dashboard"
|
||||
verbose_name_plural = "Dashboards"
|
||||
db_table = "dashboards"
|
||||
verbose_name = "DeprecatedDashboard"
|
||||
verbose_name_plural = "DeprecatedDashboards"
|
||||
db_table = "deprecated_dashboards"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class Widget(TimeAuditModel):
|
||||
class DeprecatedWidget(TimeAuditModel):
|
||||
id = models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
||||
)
|
||||
@@ -55,18 +55,18 @@ class Widget(TimeAuditModel):
|
||||
return f"{self.key}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Widget"
|
||||
verbose_name_plural = "Widgets"
|
||||
db_table = "widgets"
|
||||
verbose_name = "DeprecatedWidget"
|
||||
verbose_name_plural = "DeprecatedWidgets"
|
||||
db_table = "deprecated_widgets"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class DashboardWidget(BaseModel):
|
||||
class DeprecatedDashboardWidget(BaseModel):
|
||||
widget = models.ForeignKey(
|
||||
Widget, on_delete=models.CASCADE, related_name="dashboard_widgets"
|
||||
DeprecatedWidget, on_delete=models.CASCADE, related_name="dashboard_widgets"
|
||||
)
|
||||
dashboard = models.ForeignKey(
|
||||
Dashboard, on_delete=models.CASCADE, related_name="dashboard_widgets"
|
||||
DeprecatedDashboard, on_delete=models.CASCADE, related_name="dashboard_widgets"
|
||||
)
|
||||
is_visible = models.BooleanField(default=True)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
@@ -86,7 +86,7 @@ class DashboardWidget(BaseModel):
|
||||
name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Dashboard Widget"
|
||||
verbose_name_plural = "Dashboard Widgets"
|
||||
db_table = "dashboard_widgets"
|
||||
verbose_name = "Deprecated Dashboard Widget"
|
||||
verbose_name_plural = "Deprecated Dashboard Widgets"
|
||||
db_table = "deprecated_dashboard_widgets"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
@@ -467,6 +467,7 @@ class IssueComment(ProjectBaseModel):
|
||||
)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
edited_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.comment_stripped = (
|
||||
|
||||
@@ -20,7 +20,7 @@ class Page(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="pages"
|
||||
)
|
||||
name = models.CharField(max_length=255, blank=True)
|
||||
name = models.TextField(blank=True)
|
||||
description = models.JSONField(default=dict, blank=True)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
|
||||
@@ -17,7 +17,7 @@ class EntityNameEnum(models.TextChoices):
|
||||
|
||||
class UserRecentVisit(WorkspaceBaseModel):
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
entity_name = models.CharField(max_length=30, choices=EntityNameEnum.choices)
|
||||
entity_name = models.CharField(max_length=30)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
|
||||
@@ -5,9 +5,12 @@ from django.db import models
|
||||
# Module imports
|
||||
from .base import BaseModel
|
||||
|
||||
# Third party imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
class Sticky(BaseModel):
|
||||
name = models.TextField()
|
||||
name = models.TextField(null=True, blank=True)
|
||||
|
||||
description = models.JSONField(blank=True, default=dict)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
@@ -33,6 +36,12 @@ class Sticky(BaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
if self._state.adding:
|
||||
# Get the maximum sequence value from the database
|
||||
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(
|
||||
|
||||
@@ -186,6 +186,8 @@ class Profile(TimeAuditModel):
|
||||
billing_address = models.JSONField(null=True)
|
||||
has_billing_address = models.BooleanField(default=False)
|
||||
company_name = models.CharField(max_length=255, blank=True)
|
||||
|
||||
is_smooth_cursor_enabled = models.BooleanField(default=False)
|
||||
# mobile
|
||||
is_mobile_onboarded = models.BooleanField(default=False)
|
||||
mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding)
|
||||
|
||||
@@ -66,7 +66,7 @@ class WebhookLog(BaseModel):
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs"
|
||||
)
|
||||
# Associated webhook
|
||||
webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="logs")
|
||||
webhook = models.UUIDField()
|
||||
|
||||
# Basic request details
|
||||
event_type = models.CharField(max_length=255, blank=True, null=True)
|
||||
@@ -89,4 +89,4 @@ class WebhookLog(BaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event_type} {str(self.webhook.url)}"
|
||||
return f"{self.event_type} {str(self.webhook)}"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Python imports
|
||||
from django.db.models.functions import Ln
|
||||
import pytz
|
||||
|
||||
# Django imports
|
||||
@@ -341,4 +342,87 @@ class WorkspaceUserLink(WorkspaceBaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.id} {self.url}"
|
||||
return f"{self.workspace.id} {self.url}"
|
||||
|
||||
|
||||
class WorkspaceHomePreference(BaseModel):
|
||||
"""Preference for the home page of a workspace for a user"""
|
||||
|
||||
class HomeWidgetKeys(models.TextChoices):
|
||||
QUICK_LINKS = "quick_links", "Quick Links"
|
||||
RECENTS = "recents", "Recents"
|
||||
MY_STICKIES = "my_stickies", "My Stickies"
|
||||
NEW_AT_PLANE = "new_at_plane", "New at Plane"
|
||||
QUICK_TUTORIAL = "quick_tutorial", "Quick Tutorial"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_user_home_preferences",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_user_home_preferences",
|
||||
)
|
||||
key = models.CharField(max_length=255)
|
||||
is_enabled = models.BooleanField(default=True)
|
||||
config = models.JSONField(default=dict)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "key", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "user", "key"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Workspace Home Preference"
|
||||
verbose_name_plural = "Workspace Home Preferences"
|
||||
db_table = "workspace_home_preferences"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.name} {self.user.email} {self.key}"
|
||||
|
||||
|
||||
class WorkspaceUserPreference(BaseModel):
|
||||
"""Preference for the workspace for a user"""
|
||||
|
||||
class UserPreferenceKeys(models.TextChoices):
|
||||
VIEWS = "views", "Views"
|
||||
ACTIVE_CYCLES = "active_cycles", "Active Cycles"
|
||||
ANALYTICS = "analytics", "Analytics"
|
||||
DRAFTS = "drafts", "Drafts"
|
||||
YOUR_WORK = "your_work", "Your Work"
|
||||
ARCHIVES = "archives", "Archives"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_user_preferences",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_user_preferences",
|
||||
)
|
||||
key = models.CharField(max_length=255)
|
||||
is_pinned = models.BooleanField(default=False)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "key", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "user", "key"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Workspace User Preference"
|
||||
verbose_name_plural = "Workspace User Preferences"
|
||||
db_table = "workspace_user_preferences"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
@@ -290,11 +290,12 @@ class InstanceAdminSignInEndpoint(View):
|
||||
# Fetch the user
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
# is_active
|
||||
if not user.is_active:
|
||||
# Error out if the user is not present
|
||||
if not user:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
|
||||
error_message="ADMIN_USER_DEACTIVATED",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
|
||||
error_message="ADMIN_USER_DOES_NOT_EXIST",
|
||||
payload={"email": email},
|
||||
)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_admin=True),
|
||||
@@ -302,12 +303,11 @@ class InstanceAdminSignInEndpoint(View):
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# Error out if the user is not present
|
||||
if not user:
|
||||
# is_active
|
||||
if not user.is_active:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
|
||||
error_message="ADMIN_USER_DOES_NOT_EXIST",
|
||||
payload={"email": email},
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
|
||||
error_message="ADMIN_USER_DEACTIVATED",
|
||||
)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_admin=True),
|
||||
|
||||
@@ -132,20 +132,33 @@ class Command(BaseCommand):
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "OPENAI_API_KEY",
|
||||
"value": os.environ.get("OPENAI_API_KEY"),
|
||||
"category": "OPENAI",
|
||||
"key": "LLM_API_KEY",
|
||||
"value": os.environ.get("LLM_API_KEY"),
|
||||
"category": "AI",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"key": "LLM_PROVIDER",
|
||||
"value": os.environ.get("LLM_PROVIDER", "openai"),
|
||||
"category": "AI",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "LLM_MODEL",
|
||||
"value": os.environ.get("LLM_MODEL", "gpt-4o-mini"),
|
||||
"category": "AI",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
# Deprecated, use LLM_MODEL
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "UNSPLASH_ACCESS_KEY",
|
||||
"value": os.environ.get("UNSPLASH_ACESS_KEY", ""),
|
||||
"value": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||
"category": "UNSPLASH",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
|
||||
@@ -336,6 +336,8 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
|
||||
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
|
||||
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
|
||||
APP_BASE_URL = os.environ.get("APP_BASE_URL")
|
||||
LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL")
|
||||
|
||||
|
||||
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))
|
||||
|
||||
@@ -361,6 +363,18 @@ ATTACHMENT_MIME_TYPES = [
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"text/plain",
|
||||
"application/rtf",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/vnd.oasis.opendocument.presentation",
|
||||
"application/vnd.oasis.opendocument.graphics",
|
||||
# Microsoft Visio
|
||||
"application/vnd.visio",
|
||||
# Netpbm format
|
||||
"image/x-portable-graymap",
|
||||
"image/x-portable-bitmap",
|
||||
"image/x-portable-pixmap",
|
||||
# Open Office Bae
|
||||
"application/vnd.oasis.opendocument.database",
|
||||
# Audio
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
|
||||
@@ -151,3 +151,17 @@ class S3Storage(S3Boto3Storage):
|
||||
"ETag": response.get("ETag"),
|
||||
"Metadata": response.get("Metadata", {}),
|
||||
}
|
||||
|
||||
def copy_object(self, object_name, new_object_name):
|
||||
"""Copy an S3 object to a new location"""
|
||||
try:
|
||||
response = self.s3_client.copy_object(
|
||||
Bucket=self.aws_storage_bucket_name,
|
||||
CopySource={"Bucket": self.aws_storage_bucket_name, "Key": object_name},
|
||||
Key=new_object_name,
|
||||
)
|
||||
except ClientError as e:
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
return response
|
||||
|
||||
@@ -130,15 +130,6 @@ class IntakeIssuePublicViewSet(BaseViewSet):
|
||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Create or get state
|
||||
state, _ = State.objects.get_or_create(
|
||||
name="Triage",
|
||||
group="backlog",
|
||||
description="Default state for managing all Intake Issues",
|
||||
project_id=project_deploy_board.project_id,
|
||||
color="#ff7700",
|
||||
)
|
||||
|
||||
# create an issue
|
||||
issue = Issue.objects.create(
|
||||
name=request.data.get("issue", {}).get("name"),
|
||||
@@ -148,7 +139,6 @@ class IntakeIssuePublicViewSet(BaseViewSet):
|
||||
),
|
||||
priority=request.data.get("issue", {}).get("priority", "low"),
|
||||
project_id=project_deploy_board.project_id,
|
||||
state=state,
|
||||
)
|
||||
|
||||
# Create an Issue Activity
|
||||
|
||||
@@ -14,9 +14,9 @@ class ProjectMetaDataEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, anchor):
|
||||
try:
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="project"
|
||||
).first()
|
||||
)
|
||||
except DeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user