Compare commits

..
Author SHA1 Message Date
NarayanBavisetti 0864354d69 feat: created new cycle analytics 2024-09-17 14:41:07 +05:30
sriram veeraghanta 69b4f155fc fix: yjs dependencies revert 2024-09-16 21:06:40 +05:30
sriram veeraghanta 8f492e4c6c fix: disable turbo telemetry on live service 2024-09-16 20:58:35 +05:30
8533eba07d [WEB-2450] dev: custom image extension (#5585)
* fix: svg not supported in image uploads

* fix: svg image file error message fixed

* feat: add custom image node for uploads

* fix: combine two extensions

* fix: added new image extension to backend

* fix: type errors

* style: image drop node

* style: image resize handler

* fix: removed unused stuff

* fix: types of updateAttributes

* fix: image insertion at pos and loading effect added

* fix: resize image real time sync

* fix: drag drop menu

* feat: custom image component editor

* fix: reverted back styles

* fix: reverted back document info changes

* fix: css image css

* style: image selected and hover states

* refactor: custom image extension folder structure

* style: read-only image

* chore: remove file handler

* fix: fixed multi time file opener

* fix: editor readonly content set properly

* fix: old images not rendered as new ones

* fix: drop upload fixed

* chore: remove console logs

* fix: src of image node as dependency

* fix: helper library build fix

* fix: improved reflow/layout and fixed resizing

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2024-09-16 19:36:20 +05:30
Anmol Singh BhatiaandGitHub edf0ab8175 fix: build error (#5617) 2024-09-16 19:22:47 +05:30
45da70cf6a [WEB-2460] fix: role permission validation (#5615)
* fix: workspace menu quick action

* fix: guest role upgrade flow validation

* fix: create issue validation

* fix: create issue validation

* fix: cmd k permission validation

* fix: subscription validation

* fix: create label permission validation

* fix: build error

* chore: guest can comment in their created issues

* chore: changed the queryset

* chore: code refactor

* chore: code refactor

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-16 18:56:28 +05:30
2e816656e5 [WEB-2112 | WEB-2113] dev: billing and change-log improvements. (#5614)
* chore: minor improvements in billing and changelogs.

* fix: lint errors.

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-09-16 18:36:17 +05:30
Aaryan KhandelwalandGitHub 6826ce0465 [WEB-1116] chore: remove yjs packages from the editor (#5603)
* chore: remove yjs packages from the editor

* chore: updated yarn lock file
2024-09-16 18:28:09 +05:30
sriram veeraghanta c4b5c737f3 fix: adding types in package 2024-09-16 17:54:23 +05:30
sriram veeraghanta 89a1c0b534 fix: build errors 2024-09-16 17:48:10 +05:30
74507559b8 [WEB-2456] Chore: workspace member list additional info (#5604)
* chore: added last login medium

* chore: added email and authentication columns in member settings

* fix: revoked lock file changes

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-16 14:28:41 +05:30
Bavisetti NarayanandGitHub 3ce84f78f1 chore: roles demotion (#5612) 2024-09-16 14:25:27 +05:30
Anmol Singh BhatiaandGitHub 5ba1eeaf4c [WEB-2443] fix: join project flicker (#5602)
* fix: join project flicker

* fix: leave project project mutation and code refactor
2024-09-16 14:16:23 +05:30
Anmol Singh BhatiaandGitHub c14d20c2e0 fix: workspace settings access validation updated (#5606) 2024-09-16 14:03:06 +05:30
sriram veeraghanta f155a13929 fix: adding new session cookie name 2024-09-13 16:59:47 +05:30
Anmol Singh BhatiaandGitHub 485caaf2ec [WEB-2443] fix: project member validation (#5601)
* fix: project member validation

* fix: project member validation
2024-09-13 16:28:03 +05:30
Ketan SharmaandGitHub b44dd28ac0 [WEB-2445] fix: date picker and member picker dropdown z-index for list, kanban and spreadsheet views (#5597)
* changes for list and kanban

* passing values for list and kanban

* spreadsheet changes

* fix use different props for different stylings

* fix z index
2024-09-13 12:03:00 +05:30
sriram veeraghanta 1b0e31027e fix: lint fixes and typescript version fixes 2024-09-12 20:39:31 +05:30
Anmol Singh BhatiaandGitHub 1efb067274 fix: build error (#5598) 2024-09-12 20:22:50 +05:30
Prateek ShouryaandGitHub b2533b94ce [WEB-2444] improvement: performance improvement for useOutsideClickDetector and usePeekOverviewOutsideClickDetector. (#5595)
* [WEB-2444] improvement: performace improvement for `useOutsideClickDetector` and `usePeekOverviewOutsideClickDetector`.

* Move outside click detector to plane helpers package.

* chore: remove plane helpers yarn.lock
2024-09-12 20:10:04 +05:30
Anmol Singh BhatiaandGitHub 441385fc95 [WEB-2443] fix: role validation and code refactor (#5596)
* chore: delete cycle toast message updated

* fix: view page empty state

* fix: project settings automation

* fix: intake delete action

* fix: project label validation

* fix: project label validation

* fix: project state permission updated

* chore: code refactor
2024-09-12 20:08:13 +05:30
sriram veeraghantaandGitHub 5f1939cdeb fix: workflow sync fixes (#5594) 2024-09-12 17:22:41 +05:30
Anmol Singh BhatiaandGitHub 9d694ab006 fix: not authorized flicker (#5593) 2024-09-12 16:26:57 +05:30
Anmol Singh BhatiaandGitHub 48e97477ed fix: issue properties dropdown (#5592) 2024-09-12 16:02:56 +05:30
33dd5fe8cc [WEB-2443] fix: project intake edit permission (#5588)
* fix: project intake edit permission

* chore: inbox issue validation changes

* fix: intake edit permission updated

* fix: project invite modal

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-12 14:44:21 +05:30
Anmol Singh BhatiaandGitHub aed2f2dd47 fix: page permission validation (#5589) 2024-09-12 14:39:38 +05:30
Ketan SharmaandGitHub eb84f165f4 [WEB-2282] fix: date picker and member picker dropdown z-index for list, kanban and spreadsheet views (#5555)
* changes for list and kanban

* passing values for list and kanban

* spreadsheet changes
2024-09-12 14:35:45 +05:30
MihirandGitHub 572644f7f9 Updated alignment inside kanban header (#5559) 2024-09-12 14:34:24 +05:30
Aaryan KhandelwalandGitHub ddbd9dfdc8 chore: add toast alerts post access change of a page (#5569) 2024-09-12 14:32:54 +05:30
MihirandGitHub 09578c9a7d Updates theme options to include custom theme option (#5574) 2024-09-12 14:32:14 +05:30
MihirandGitHub e5ddfd322d [WEB-2393] chore: removal of .svg from supported image formats (#5582)
* Updated supported image formats

* Updated image accepting functions
2024-09-12 14:25:06 +05:30
Anmol Singh BhatiaandGitHub 87d6544b72 fix: project favorite permission validation (#5587) 2024-09-12 14:09:19 +05:30
fdcd9a376c [WEB-2357] fix: update and redefine user roles across the platform (#5466)
* chore: removed viewer role

* chore: indentation

* chore: remove viewer role

* chore: handled user permissions in store

* chore: updated the migration file

* chore: updated user permissions store

* chore: removed the owner key

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* fix: build error

* chore: updated user permissions store and handled the permissions fetch in workspace and project wrappers

* chore: package user enum updated

* chore: user permission updated

* chore: user permission updated

* chore: resolved build errors

* chore: resolved build error

* chore: resolved build errors

* chore: computedFn deep map issue resolved

* chore: added back migration

* chore: added new field in project table

* chore: removed member store in users

* chore: private project for admins

* chore: workspace notification access validation updated

* fix: workspace member edit option

* fix: project intake permission validation updated

* chore: workspace export settings permission updated

* chore: guest_view_all_issues added

* chore: guest_view_all_issues added

* chore: key changed for guest access

* chore: added validation for individual issues

* chore: changed the dashboard issues count

* chore: added new yarn file

* chore: modified yarn file

* chore: project page permission updated

* chore: project page permission updated

* chore: member setting ux updated

* chore: build error

* fix: yarn lock

* fix: build error

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-09-11 17:10:15 +05:30
Bavisetti NarayanandGitHub 7013a36629 [WEB-2430] fix: issue exports for project (#5579)
* fix: issue exports for project

* chore: code cleanup
2024-09-11 13:18:59 +05:30
Anmol Singh BhatiaandGitHub bb49d27a84 fix: join project permission mutation (#5580) 2024-09-11 12:37:31 +05:30
Prateek ShouryaandGitHub 00b76300f5 [WEB-2421] chore: issue display properties and issue identifier improvements. (#5577)
* [WEB-2421] chore: issue display properties and issue identifier improvements.

* chore: remove yarn.lock changes.
2024-09-10 21:49:57 +05:30
sriram veeraghanta 71f3c5c12a fix: typescript upgrade build errors 2024-09-10 21:31:32 +05:30
sriram veeraghanta 99ab274216 fix: upgrading the python runtime version 2024-09-10 20:44:38 +05:30
sriram veeraghanta 04b10cabc8 fix: tailwind warning fixes 2024-09-10 17:57:06 +05:30
sriram veeraghanta 545717cc51 fix: security update for express pacakge 2024-09-10 17:36:32 +05:30
sriram veeraghanta 1ca0a15792 fix: upgrading tubro version 2024-09-10 17:31:10 +05:30
sriram veeraghanta c5971f03aa Merge branch 'preview' of github.com:makeplane/plane into preview 2024-09-10 17:29:34 +05:30
sriram veeraghanta 902403a54d chore: linting warning resolved 2024-09-10 17:29:19 +05:30
Akshat JainandGitHub 1d6ebb7c41 add the SERVICE_FOLDER value to install.sh script dynamically (#5553) 2024-09-10 17:29:16 +05:30
Rounak ShresthaandGitHub 106914e14e fix: Local Setup on Windows (#5539) 2024-09-10 17:28:18 +05:30
Aaryan KhandelwalandGitHub 8acb60baef [WEB-1116] fix: current version not displaying the latest content (#5573)
* fix: current version sync

* chore: update read only editor ref type
2024-09-10 16:13:20 +05:30
Manish GuptaandGitHub 1da97d5814 skipped stable tag for prerelrease, modified docker tag for branch name with special characters (#5570) 2024-09-10 15:10:10 +05:30
5fb2dd0b6e fix(webhook): allow private ip to be used as payload url (#5535)
Co-authored-by: gmajkic <gmajkic@veepee.com>
2024-09-10 14:57:30 +05:30
Akshita GoyalandGitHub ff6c3ce1a0 fix: settings page scrollbar (#5572) 2024-09-10 14:44:32 +05:30
Anmol Singh BhatiaandGitHub ec51e9d8ce fix-header-theme (#5564) 2024-09-10 14:42:24 +05:30
Aaryan KhandelwalandGitHub cc07992e47 [WEB-2424] fix: add optional chaining for parent node (#5571)
* fix: add optional chaining for parent node

* chore: revert yarn lock changes
2024-09-10 14:41:48 +05:30
M. PalanikannanandGitHub 069f8b950e fix: svg not supported in image uploads in the editor (#5558)
* fix: svg not supported in image uploads

* fix: svg image file error message fixed
2024-09-10 14:27:27 +05:30
Akshita GoyalandGitHub 5eb868e07d [WEB 2418] Fix minor UI inconsistencies (#5568)
* fix: project features modal padding

* fix: minor ui inconsistencies

* fix: lint issue
2024-09-10 14:24:07 +05:30
Aaryan KhandelwalandGitHub 7c77fc1680 fix: task list not getting synced (#5566) 2024-09-09 21:35:31 +05:30
Anmol Singh BhatiaandGitHub 99a7867a5e [WEB-2228] fix: dashboard peek overview issue stats #5442 (#5560)
* fix: dashboard issue stats

* chore: code refactor
2024-09-09 20:37:46 +05:30
Ketan SharmaandGitHub c44bf861e0 [WEB-2415] fix:remove input type to fix image upload (#5563)
* remove input type to fix things

* made the same changes in all locations
2024-09-09 20:12:15 +05:30
M. PalanikannanandGitHub 4d38a10f8b fix: character count to work properly on editor rerenders and read only mode (#5554)
* fix: character count to work properly on editor rerenders and read only mode

* fix: desctructing properly at the start
2024-09-09 19:59:07 +05:30
Akshita GoyalandGitHub 7c3fc690e9 fix: project features modal padding (#5562) 2024-09-09 19:22:47 +05:30
Prateek ShouryaandGitHub 8cf1c2d136 [WEB-2413] chore: admin application restructuring. (#5557) 2024-09-09 17:43:56 +05:30
Ketan SharmaandGitHub fe280b2beb [WEB-2106] fix: add date and state change functionalities to list and grid view (#5533)
* added functionality to list and grid

* fixed logic for archived module

* fixed logic for list view

* improved logic and fixed linting issues

* improved variable names
2024-09-09 16:50:56 +05:30
Ketan SharmaandGitHub ad5c6ee4f5 [WEB-2201] fix: clear email button on login screen (#5546)
* fixed the logic

* made required css changes

* replicated same for space component

* fixed variable name

* replicated for space

* better variable name

* improved the css

* replicated for space
2024-09-09 14:58:06 +05:30
MihirandGitHub ba0d1ba518 Update sidebar (#5549)
Removed else statement which was expanding it whenever windowSize changed or webapp was hard refreshed.
2024-09-09 14:57:05 +05:30
M. PalanikannanandGitHub 70ea1459cd fix: async loading of the redis extension (#5537)
* fix: async loading of the redis extension

* fix: initialize redis connection and hocuspocusserver only during server start

* fix: removed console logs

* fix: remove async

* fix: error handling and shutting down gracefully in unhandled errors

* feat: added compression library

* fix: added helmet for security headers
2024-09-07 14:24:20 +05:30
Aaryan KhandelwalandGitHub 8154a190d2 [WEB-1116] fix: editor info badges occupying multiple lines (#5548) 2024-09-07 09:01:01 +05:30
29fd1186ee [WEB-2129] fix: module creation and updation toast error (#5550)
* chore: added error message for module name

* used the backend message

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-07 08:58:28 +05:30
68b412badf [WEB-1933] refactor: link create/update for issues and modules (#5543)
* chore: added module and issue link validation

* refactor: issues and modules link moda;

* chore: changed the url validation logic

* chore: code cleanup

* refactor: modules link logic

* chore: removed the validator function

* fix: url validation regex

* chore: removed unwanted imports

* chore: reverted the external api changes

* refactor: link modals

* refactor: reset modal logic

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-06 22:52:29 +05:30
Akshita GoyalandGitHub c95aa6a0f7 [WEB-2273] Fix: page alignments (#5541)
* chore: headers + common containers

* fix: filters code splitting

* fix: home header

* fix: header changes

* chore: page alignments fixed

* fix: uncommented filters

* fix: used enums

* fix: cards + filters

* fix: enum changes

* fix: reverted package changes

* fix: reverted package changes

* fix: Card + tags seperated + naming fixed

* fix: card + tags seperated + naming fixed

* fix: mobile headers fixed partially

* fix: build errors + minor css

* fix: checkbox spacing

* fix: review changes

* fix: lint errors

* fix: minor review changes

* fix: header-alignments

* fix: tabs

* fix: settings page

* fix: subgroup page

* fix: mobile headers

* fix: settings mobile header made observable

* fix: lint error + edge case handling
2024-09-06 18:38:53 +05:30
rahulrameshaandGitHub 751cd6c862 [WEB-2365] fix: Minor UI in-consistencies cause by tooltip changes (#5545)
* Fix minor in-consistencies caused by tooltip on hover changes

* fix linting
2024-09-06 18:37:57 +05:30
Prateek ShouryaandGitHub 1032bc75d7 [WEB-2332] chore: layout structure improvement. (#5538)
* [WEB-2332] chore: layout structure improvement.

* chore: improve layout.
2024-09-06 16:46:42 +05:30
Ketan SharmaandGitHub 9415a5ba00 made required changes in css (#5542) 2024-09-06 16:22:59 +05:30
Akshat JainandGitHub d24a4e18a2 add: API_BASE_URL env to selfhost envs (#5523)
* add: API_BASE_URL env to selfhost envs

* Update variables.env
2024-09-06 16:22:16 +05:30
Anmol Singh BhatiaandGitHub 52f78a86af [PWA-26] chore: pwa input focus improvement (#5507)
* chore: pwa dropdown input focus improvement

* chore: tab indices helper function updated and code refactor

* chore: modal tab index refactoring

* fix: PWA filters input autofocus

* chore: intake tab index updated and code refactor

* chore: code refactor
2024-09-06 16:21:14 +05:30
Anmol Singh BhatiaandGitHub c84c37805c [PWA-22] chore: pwa issue redirection (#5544)
* chore: issue peek overview redirection hook added

* chore: handleIssuePeekOverview function updated
2024-09-06 15:36:06 +05:30
Anmol Singh BhatiaandGitHub c2758caf95 chore: pwa issue detail improvement (#5540) 2024-09-06 15:23:48 +05:30
484 changed files with 8004 additions and 5472 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+48 -13
View File
@@ -2,6 +2,12 @@ name: Branch Build
on:
workflow_dispatch:
inputs:
arm64:
description: "Build for ARM64 architecture"
required: false
default: false
type: boolean
push:
branches:
- master
@@ -11,6 +17,8 @@ on:
env:
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
ARM64_BUILD: ${{ github.event.inputs.arm64 }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
jobs:
branch_build_setup:
@@ -28,12 +36,13 @@ jobs:
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_web: ${{ steps.changed_files.outputs.web_any_changed }}
build_live: ${{ steps.changed_files.outputs.live_any_changed }}
flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }}
steps:
- id: set_env_variables
name: Set Environment Variables
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ env.ARM64_BUILD }}" == "true" ] || ([ "${{ github.event_name }}" == "release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ]); then
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
@@ -45,6 +54,8 @@ jobs:
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
fi
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
flat_branch_name=$(echo ${{ env.TARGET_BRANCH }} | sed 's/[^a-zA-Z0-9\._]/-/g')
echo "FLAT_BRANCH_NAME=${flat_branch_name}" >> $GITHUB_OUTPUT
- id: checkout_files
name: Checkout Files
@@ -90,10 +101,11 @@ jobs:
branch_build_push_web:
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Web Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -103,7 +115,10 @@ jobs:
- name: Set Frontend Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-frontend:stable,makeplane/plane-frontend:${{ github.event.release.tag_name }}
TAG=makeplane/plane-frontend:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-frontend:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-frontend:latest
else
@@ -142,10 +157,11 @@ jobs:
branch_build_push_admin:
if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Admin Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }}
ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -155,7 +171,10 @@ jobs:
- name: Set Admin Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-admin:stable,makeplane/plane-admin:${{ github.event.release.tag_name }}
TAG=makeplane/plane-admin:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-admin:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-admin:latest
else
@@ -194,10 +213,11 @@ jobs:
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Space Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -207,7 +227,10 @@ jobs:
- name: Set Space Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-space:stable,makeplane/plane-space:${{ github.event.release.tag_name }}
TAG=makeplane/plane-space:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-space:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-space:latest
else
@@ -246,10 +269,11 @@ jobs:
branch_build_push_apiserver:
if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push API Server Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -259,7 +283,10 @@ jobs:
- name: Set Backend Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-backend:stable,makeplane/plane-backend:${{ github.event.release.tag_name }}
TAG=makeplane/plane-backend:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-backend:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-backend:latest
else
@@ -298,10 +325,11 @@ jobs:
branch_build_push_live:
if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
LIVE_TAG: makeplane/plane-live:${{ needs.branch_build_setup.outputs.gh_branch_name }}
LIVE_TAG: makeplane/plane-live:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -311,7 +339,10 @@ jobs:
- name: Set Live Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-live:stable,makeplane/plane-live:${{ github.event.release.tag_name }}
TAG=makeplane/plane-live:${{ github.event.release.tag_name }}
if [ "${{ github.event.release.prerelease }}" != "true" ]; then
TAG=${TAG},makeplane/plane-live:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-live:latest
else
@@ -350,10 +381,11 @@ jobs:
branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Proxy Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -363,7 +395,10 @@ jobs:
- name: Set Proxy Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-proxy:stable,makeplane/plane-proxy:${{ github.event.release.tag_name }}
TAG=makeplane/plane-proxy:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-proxy:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-proxy:latest
else
+2 -18
View File
@@ -8,7 +8,6 @@ on:
env:
CURRENT_BRANCH: ${{ github.ref_name }}
SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
REVIEWER: ${{ vars.SYNC_PR_REVIEWER }}
@@ -16,22 +15,7 @@ env:
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
jobs:
Check_Branch:
runs-on: ubuntu-latest
outputs:
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
steps:
- name: Check if current branch matches the secret
id: check-branch
run: |
if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then
echo "MATCH=true" >> $GITHUB_OUTPUT
else
echo "MATCH=false" >> $GITHUB_OUTPUT
fi
Create_PR:
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
needs: [Check_Branch]
runs-on: ubuntu-latest
permissions:
pull-requests: write
@@ -59,11 +43,11 @@ jobs:
- name: Create PR to Target Branch
run: |
# get all pull requests and check if there is already a PR
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number')
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number')
if [ -n "$PR_EXISTS" ]; then
echo "Pull Request already exists: $PR_EXISTS"
else
echo "Creating new pull request"
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: community changes" --body "")
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "sync: community changes" --body "")
echo "Pull Request created: $PR_URL"
fi
+2 -1
View File
@@ -35,8 +35,9 @@ jobs:
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
RUN_ID="${{ github.run_id }}"
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
TARGET_BRANCH="sync/${RUN_ID}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
@@ -10,8 +10,9 @@ import {
// components
import { AuthenticationMethodCard } from "@/components/authentication";
// helpers
import { UpgradeButton } from "@/components/common/upgrade-button";
import { getBaseAuthenticationModes } from "@/helpers/authentication.helper";
// plane admin components
import { UpgradeButton } from "@/plane-admin/components/common";
// images
import OIDCLogo from "@/public/logos/oidc-logo.svg";
import SAMLLogo from "@/public/logos/saml-logo.svg";
@@ -27,24 +28,24 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
updateConfig,
resolvedTheme,
}) => [
...getBaseAuthenticationModes({ disabled, updateConfig, resolvedTheme }),
{
key: "oidc",
name: "OIDC",
description: "Authenticate your users via the OpenID Connect protocol.",
icon: <Image src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
config: <UpgradeButton />,
unavailable: true,
},
{
key: "saml",
name: "SAML",
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
icon: <Image src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
config: <UpgradeButton />,
unavailable: true,
},
];
...getBaseAuthenticationModes({ disabled, updateConfig, resolvedTheme }),
{
key: "oidc",
name: "OIDC",
description: "Authenticate your users via the OpenID Connect protocol.",
icon: <Image src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
config: <UpgradeButton />,
unavailable: true,
},
{
key: "saml",
name: "SAML",
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
icon: <Image src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
config: <UpgradeButton />,
unavailable: true,
},
];
export const AuthenticationModes: React.FC<TAuthenticationModeProps> = observer((props) => {
const { disabled, updateConfig } = props;
+1
View File
@@ -0,0 +1 @@
export * from "./upgrade-button";
+19
View File
@@ -0,0 +1,19 @@
import { enableStaticRendering } from "mobx-react";
// stores
import { CoreRootStore } from "@/store/root.store";
enableStaticRendering(typeof window === "undefined");
export class RootStore extends CoreRootStore {
constructor() {
super();
}
hydrate(initialData: any) {
super.hydrate(initialData);
}
resetOnSignOut() {
super.resetOnSignOut();
}
}
+5 -4
View File
@@ -2,11 +2,12 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// hooks
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
import { useTheme } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// components
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
// hooks
import { useTheme } from "@/hooks/store";
export interface IInstanceSidebar {}
-1
View File
@@ -8,4 +8,3 @@ export * from "./empty-state";
export * from "./logo-spinner";
export * from "./page-header";
export * from "./code-block";
export * from "./upgrade-button";
@@ -1,21 +0,0 @@
"use client";
import React, { useEffect } from "react";
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
});
};
export default useOutsideClickDetector;
+1
View File
@@ -18,6 +18,7 @@ export const AdminLayout: FC<TAdminLayout> = observer((props) => {
const { children } = props;
// router
const router = useRouter();
// store hooks
const { isUserLoggedIn } = useUser();
useEffect(() => {
+2 -2
View File
@@ -1,8 +1,8 @@
"use client";
import { ReactNode, createContext } from "react";
// store
import { RootStore } from "@/store/root.store";
// plane admin store
import { RootStore } from "@/plane-admin/store/root.store";
let rootStore = new RootStore();
+2 -2
View File
@@ -13,7 +13,7 @@ import { EInstanceStatus, TInstanceStatus } from "@/helpers/instance.helper";
// services
import { InstanceService } from "@/services/instance.service";
// root store
import { RootStore } from "@/store/root.store";
import { CoreRootStore } from "@/store/root.store";
export interface IInstanceStore {
// issues
@@ -46,7 +46,7 @@ export class InstanceStore implements IInstanceStore {
// service
instanceService;
constructor(private store: RootStore) {
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observable
isLoading: observable.ref,
+1 -1
View File
@@ -6,7 +6,7 @@ import { IUserStore, UserStore } from "./user.store";
enableStaticRendering(typeof window === "undefined");
export class RootStore {
export abstract class CoreRootStore {
theme: IThemeStore;
instance: IInstanceStore;
user: IUserStore;
+2 -2
View File
@@ -1,6 +1,6 @@
import { action, observable, makeObservable } from "mobx";
// root store
import { RootStore } from "@/store/root.store";
import { CoreRootStore } from "@/store/root.store";
type TTheme = "dark" | "light";
export interface IThemeStore {
@@ -21,7 +21,7 @@ export class ThemeStore implements IThemeStore {
isSidebarCollapsed: boolean | undefined = undefined;
theme: string | undefined = undefined;
constructor(private store: RootStore) {
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observables
isNewUserPopup: observable.ref,
+2 -2
View File
@@ -6,7 +6,7 @@ import { EUserStatus, TUserStatus } from "@/helpers/user.helper";
import { AuthService } from "@/services/auth.service";
import { UserService } from "@/services/user.service";
// root store
import { RootStore } from "@/store/root.store";
import { CoreRootStore } from "@/store/root.store";
export interface IUserStore {
// observables
@@ -31,7 +31,7 @@ export class UserStore implements IUserStore {
userService;
authService;
constructor(private store: RootStore) {
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observables
isLoading: observable.ref,
+1
View File
@@ -0,0 +1 @@
export * from "ce/components/common";
+1
View File
@@ -0,0 +1 @@
export * from "ce/store/root.store";
+1 -1
View File
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+3 -2
View File
@@ -12,9 +12,10 @@
},
"dependencies": {
"@headlessui/react": "^1.7.19",
"@plane/constants": "*",
"@plane/helpers": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/constants": "*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -45,6 +46,6 @@
"eslint-config-custom": "*",
"tailwind-config-custom": "*",
"tsconfig": "*",
"typescript": "^5.4.2"
"typescript": "5.4.5"
}
}
+4 -4
View File
@@ -1,6 +1,3 @@
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
# Django imports
from django.utils import timezone
from lxml import html
@@ -30,6 +27,9 @@ from .module import ModuleLiteSerializer, ModuleSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
class IssueSerializer(BaseSerializer):
assignees = serializers.ListField(
@@ -315,7 +315,7 @@ class IssueLinkSerializer(BaseSerializer):
"created_at",
"updated_at",
]
def validate_url(self, value):
# Check URL format
validate_url = URLValidator()
+23
View File
@@ -71,6 +71,16 @@ class ModuleSerializer(BaseSerializer):
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if Module.objects.filter(
name=module_name, project_id=project_id
).exists():
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
module = Module.objects.create(**validated_data, project_id=project_id)
if members is not None:
ModuleMember.objects.bulk_create(
@@ -93,6 +103,19 @@ class ModuleSerializer(BaseSerializer):
def update(self, instance, validated_data):
members = validated_data.pop("members", None)
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if (
Module.objects.filter(
name=module_name, project=instance.project
)
.exclude(id=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
+4 -5
View File
@@ -210,7 +210,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
)
# Only project members admins and created_by users can access this endpoint
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
if project_member.role <= 5 and str(inbox_issue.created_by_id) != str(
request.user.id
):
return Response(
@@ -244,9 +244,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug,
project_id=project_id,
)
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
# viewers and guests since only viewers and guests
# Only allow guests to edit name and description
if project_member.role <= 5:
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get(
@@ -286,7 +285,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
)
# Only project admins and members can edit inbox issue attributes
if project_member.role > 10:
if project_member.role > 5:
serializer = InboxIssueSerializer(
inbox_issue, data=request.data, partial=True
)
+2 -2
View File
@@ -133,7 +133,7 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
workspace_member = WorkspaceMember.objects.create(
workspace=workspace,
member=user,
role=request.data.get("role", 10),
role=request.data.get("role", 5),
)
workspace_member.save()
@@ -142,7 +142,7 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
project_member = ProjectMember.objects.create(
project=project,
member=user,
role=request.data.get("role", 10),
role=request.data.get("role", 5),
)
project_member.save()
-1
View File
@@ -8,7 +8,6 @@ from enum import Enum
class ROLE(Enum):
ADMIN = 20
MEMBER = 15
VIEWER = 10
GUEST = 5
@@ -7,7 +7,6 @@ from plane.db.models import ProjectMember, WorkspaceMember
# Permission Mappings
Admin = 20
Member = 15
Viewer = 10
Guest = 5
+7 -8
View File
@@ -6,9 +6,8 @@ from plane.db.models import WorkspaceMember
# Permission Mappings
Owner = 20
Admin = 15
Member = 10
Admin = 20
Member = 15
Guest = 5
@@ -31,7 +30,7 @@ class WorkSpaceBasePermission(BasePermission):
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role__in=[Owner, Admin],
role__in=[Admin, Member],
is_active=True,
).exists()
@@ -40,7 +39,7 @@ class WorkSpaceBasePermission(BasePermission):
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role=Owner,
role=Admin,
is_active=True,
).exists()
@@ -53,7 +52,7 @@ class WorkspaceOwnerPermission(BasePermission):
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role=Owner,
role=Admin,
).exists()
@@ -65,7 +64,7 @@ class WorkSpaceAdminPermission(BasePermission):
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role__in=[Owner, Admin],
role__in=[Admin, Member],
is_active=True,
).exists()
@@ -86,7 +85,7 @@ class WorkspaceEntityPermission(BasePermission):
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role__in=[Owner, Admin],
role__in=[Admin, Member],
is_active=True,
).exists()
@@ -44,6 +44,7 @@ from .cycle import (
CycleIssueSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
CycleAnalyticsSerializer,
)
from .asset import FileAssetSerializer
from .issue import (
+8
View File
@@ -7,6 +7,7 @@ from .issue import IssueStateSerializer
from plane.db.models import (
Cycle,
CycleIssue,
CycleAnalytics,
CycleUserProperties,
)
@@ -93,6 +94,7 @@ class CycleIssueSerializer(BaseSerializer):
"cycle",
]
class CycleUserPropertiesSerializer(BaseSerializer):
class Meta:
model = CycleUserProperties
@@ -102,3 +104,9 @@ class CycleUserPropertiesSerializer(BaseSerializer):
"project",
"cycle" "user",
]
class CycleAnalyticsSerializer(BaseSerializer):
class Meta:
model = CycleAnalytics
fields = "__all__"
+22 -12
View File
@@ -437,17 +437,21 @@ class IssueLinkSerializer(BaseSerializer):
"issue",
]
def validate_url(self, value):
# Check URL format
validate_url = URLValidator()
try:
validate_url(value)
except ValidationError:
raise serializers.ValidationError("Invalid URL format.")
def to_internal_value(self, data):
# Modify the URL before validation by appending http:// if missing
url = data.get("url", "")
if url and not url.startswith(("http://", "https://")):
data["url"] = "http://" + url
# Check URL scheme
if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.")
return super().to_internal_value(data)
def validate_url(self, value):
# Use Django's built-in URLValidator for validation
url_validator = URLValidator()
try:
url_validator(value)
except ValidationError:
raise serializers.ValidationError({"error": "Invalid URL format."})
return value
@@ -533,7 +537,7 @@ class IssueReactionSerializer(BaseSerializer):
"project",
"issue",
"actor",
"deleted_at"
"deleted_at",
]
@@ -552,7 +556,13 @@ class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
fields = "__all__"
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
read_only_fields = [
"workspace",
"project",
"comment",
"actor",
"deleted_at",
]
class IssueVoteSerializer(BaseSerializer):
+69 -3
View File
@@ -5,6 +5,10 @@ from rest_framework import serializers
from .base import BaseSerializer, DynamicBaseSerializer
from .project import ProjectLiteSerializer
# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from plane.db.models import (
User,
Module,
@@ -64,6 +68,16 @@ class ModuleWriteSerializer(BaseSerializer):
members = validated_data.pop("member_ids", None)
project = self.context["project"]
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if Module.objects.filter(
name=module_name, project=project
).exists():
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
module = Module.objects.create(**validated_data, project=project)
if members is not None:
ModuleMember.objects.bulk_create(
@@ -86,6 +100,19 @@ class ModuleWriteSerializer(BaseSerializer):
def update(self, instance, validated_data):
members = validated_data.pop("member_ids", None)
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if (
Module.objects.filter(
name=module_name, project=instance.project
)
.exclude(id=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
@@ -155,16 +182,48 @@ class ModuleLinkSerializer(BaseSerializer):
"module",
]
# Validation if url already exists
def to_internal_value(self, data):
# Modify the URL before validation by appending http:// if missing
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):
# Use Django's built-in URLValidator for validation
url_validator = URLValidator()
try:
url_validator(value)
except ValidationError:
raise serializers.ValidationError({"error": "Invalid URL format."})
return value
def create(self, validated_data):
validated_data["url"] = self.validate_url(validated_data.get("url"))
if ModuleLink.objects.filter(
url=validated_data.get("url"),
module_id=validated_data.get("module_id"),
).exists():
raise serializers.ValidationError({"error": "URL already exists."})
return super().create(validated_data)
def update(self, instance, validated_data):
validated_data["url"] = self.validate_url(validated_data.get("url"))
if (
ModuleLink.objects.filter(
url=validated_data.get("url"),
module_id=instance.module_id,
)
.exclude(pk=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
return ModuleLink.objects.create(**validated_data)
return super().update(instance, validated_data)
class ModuleSerializer(DynamicBaseSerializer):
@@ -229,7 +288,14 @@ class ModuleDetailSerializer(ModuleSerializer):
cancelled_estimate_points = serializers.FloatField(read_only=True)
class Meta(ModuleSerializer.Meta):
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"]
fields = ModuleSerializer.Meta.fields + [
"link_module",
"sub_issues",
"backlog_estimate_points",
"unstarted_estimate_points",
"started_estimate_points",
"cancelled_estimate_points",
]
class ModuleUserPropertiesSerializer(BaseSerializer):
+1
View File
@@ -176,6 +176,7 @@ class UserAdminLiteSerializer(BaseSerializer):
"is_bot",
"display_name",
"email",
"last_login_medium",
]
read_only_fields = [
"id",
+2 -2
View File
@@ -40,7 +40,7 @@ class WebhookSerializer(DynamicBaseSerializer):
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
if ip.is_loopback:
raise serializers.ValidationError(
{"url": "URL resolves to a blocked IP address."}
)
@@ -92,7 +92,7 @@ class WebhookSerializer(DynamicBaseSerializer):
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
if ip.is_loopback:
raise serializers.ValidationError(
{"url": "URL resolves to a blocked IP address."}
)
+6
View File
@@ -9,6 +9,7 @@ from plane.app.views import (
CycleProgressEndpoint,
CycleAnalyticsEndpoint,
TransferCycleIssueEndpoint,
CycleIssueStateAnalyticsEndpoint,
CycleUserPropertiesEndpoint,
CycleArchiveUnarchiveEndpoint,
)
@@ -118,4 +119,9 @@ urlpatterns = [
CycleAnalyticsEndpoint.as_view(),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-progress/",
CycleIssueStateAnalyticsEndpoint.as_view(),
name="project-cycle-progress",
),
]
+2 -3
View File
@@ -100,10 +100,9 @@ from .cycle.base import (
TransferCycleIssueEndpoint,
CycleAnalyticsEndpoint,
CycleProgressEndpoint,
CycleIssueStateAnalyticsEndpoint,
)
from .cycle.issue import (
CycleIssueViewSet,
)
from .cycle.issue import CycleIssueViewSet
from .cycle.archive import (
CycleArchiveUnarchiveEndpoint,
)
+16 -6
View File
@@ -21,7 +21,11 @@ from plane.app.permissions import allow_permission, ROLE
class AnalyticsEndpoint(BaseAPIView):
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
[
ROLE.ADMIN,
ROLE.MEMBER,
],
level="WORKSPACE",
)
def get(self, request, slug):
x_axis = request.GET.get("x_axis", False)
@@ -203,7 +207,11 @@ class AnalyticViewViewset(BaseViewSet):
class SavedAnalyticEndpoint(BaseAPIView):
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
[
ROLE.ADMIN,
ROLE.MEMBER,
],
level="WORKSPACE",
)
def get(self, request, slug, analytic_id):
analytic_view = AnalyticView.objects.get(
@@ -236,7 +244,11 @@ class SavedAnalyticEndpoint(BaseAPIView):
class ExportAnalyticsEndpoint(BaseAPIView):
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
[
ROLE.ADMIN,
ROLE.MEMBER,
],
level="WORKSPACE",
)
def post(self, request, slug):
x_axis = request.data.get("x_axis", False)
@@ -302,9 +314,7 @@ class ExportAnalyticsEndpoint(BaseAPIView):
class DefaultAnalyticsEndpoint(BaseAPIView):
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], level="WORKSPACE"
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug):
filters = issue_filters(request.GET, "GET")
base_issues = Issue.issue_objects.filter(
+6 -1
View File
@@ -288,7 +288,12 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
]
)
def get(self, request, slug, project_id, pk=None):
if pk is None:
queryset = (
+61 -11
View File
@@ -31,6 +31,7 @@ from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import (
CycleSerializer,
CycleUserPropertiesSerializer,
CycleAnalyticsSerializer,
CycleWriteSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
@@ -44,6 +45,8 @@ from plane.db.models import (
User,
Project,
ProjectMember,
CycleAnalytics,
CycleIssueStateProgress,
)
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.recent_visited_task import recent_visited_task
@@ -150,7 +153,7 @@ class CycleViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True)
cycle_view = request.GET.get("cycle_view", "all")
@@ -370,7 +373,12 @@ class CycleViewSet(BaseViewSet):
return Response(cycle, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
]
)
def retrieve(self, request, slug, project_id, pk):
queryset = (
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
@@ -497,7 +505,6 @@ class CycleViewSet(BaseViewSet):
class CycleDateCheckEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
start_date = request.data.get("start_date", False)
@@ -566,7 +573,6 @@ class CycleFavoriteViewSet(BaseViewSet):
class TransferCycleIssueEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False)
@@ -955,6 +961,37 @@ class TransferCycleIssueEndpoint(BaseAPIView):
updated_cycles, ["cycle_id"], batch_size=100
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
CycleIssueStateProgress.objects.bulk_create(
[
CycleIssueStateProgress(
cycle_id=new_cycle_id,
state_id=cycle_issue.issue.state_id,
issue_id=cycle_issue.issue_id,
state_group=cycle_issue.issue.state.group,
type="ADDED",
estimate_id=cycle_issue.issue.estimate_point_id,
estimate_value=(
cycle_issue.issue.estimate_point.value
if estimate_type
else None
),
project_id=project_id,
workspace_id=cycle_issue.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
)
for cycle_issue in cycle_issues
],
batch_size=10,
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
@@ -977,8 +1014,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
class CycleUserPropertiesEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def patch(self, request, slug, project_id, cycle_id):
cycle_properties = CycleUserProperties.objects.get(
user=request.user,
@@ -1001,7 +1037,7 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
serializer = CycleUserPropertiesSerializer(cycle_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
cycle_properties, _ = CycleUserProperties.objects.get_or_create(
user=request.user,
@@ -1014,10 +1050,8 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
class CycleProgressEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
aggregate_estimates = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
@@ -1151,7 +1185,7 @@ class CycleProgressEndpoint(BaseAPIView):
class CycleAnalyticsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
analytic_type = request.GET.get("type", "issues")
cycle = (
@@ -1368,3 +1402,19 @@ class CycleAnalyticsEndpoint(BaseAPIView):
},
status=status.HTTP_200_OK,
)
class CycleIssueStateAnalyticsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
cycle_state_progress = CycleAnalytics.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
)
return Response(
CycleAnalyticsSerializer(cycle_state_progress, many=True).data,
status=status.HTTP_200_OK,
)
+70 -1
View File
@@ -24,6 +24,8 @@ from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
CycleIssueStateProgress,
Project,
)
from plane.utils.grouper import (
issue_group_values,
@@ -80,7 +82,12 @@ class CycleIssueViewSet(BaseViewSet):
)
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
]
)
def list(self, request, slug, project_id, cycle_id):
order_by_param = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
@@ -263,6 +270,10 @@ class CycleIssueViewSet(BaseViewSet):
]
new_issues = list(set(issues) - set(existing_issues))
# Fetch issue details
issue_objects = Issue.objects.filter(id__in=new_issues)
issue_dict = {issue.id: issue for issue in issue_objects}
# New issues to create
created_records = CycleIssue.objects.bulk_create(
[
@@ -279,6 +290,42 @@ class CycleIssueViewSet(BaseViewSet):
batch_size=10,
)
# estimate_type = Project.objects.filter(
# workspace__slug=slug,
# pk=project_id,
# estimate__isnull=False,
# estimate__type="points",
# ).exists()
# for issue_id in new_issues:
# print(issue_id, "issue id")
# print(issue_dict[issue_id].state_id, "state_id")
# CycleIssueStateProgress.objects.bulk_create(
# [
# CycleIssueStateProgress(
# cycle_id=cycle_id,
# state_id=str(issue_dict[issue_id].state_id),
# issue_id=issue_id,
# state_group=issue_dict[issue_id].state.group,
# type="ADDED",
# estimate_id=issue_dict[issue_id].estimate_id,
# estimate_value=(
# issue_dict[issue_id].estimate_point.value
# if estimate_type
# else None
# ),
# project_id=project_id,
# workspace_id=cycle.workspace_id,
# created_by_id=request.user.id,
# updated_by_id=request.user.id,
# )
# print(issue_id, "issue id")
# for issue_id in new_issues
# ],
# batch_size=10,
# )
# Updated Issues
updated_records = []
update_cycle_issue_activity = []
@@ -331,6 +378,28 @@ class CycleIssueViewSet(BaseViewSet):
project_id=project_id,
cycle_id=cycle_id,
)
issue = Issue.objects.get(pk=issue_id)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
CycleIssueStateProgress.objects.create(
cycle_id=cycle_id,
state_id=issue.state_id,
issue_id=issue_id,
state_group=issue.state.group,
type="REMOVED",
estimate_id=issue.estimate_id,
estimate_value=(
issue.estimate_point.value if estimate_type else None
),
project_id=project_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
)
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
+60 -13
View File
@@ -52,23 +52,28 @@ from .. import BaseAPIView
def dashboard_overview_stats(self, request, slug):
extra_filters = {}
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
extra_filters = {"created_by": request.user}
assigned_issues = (
Issue.issue_objects.filter(
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
).filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(**extra_filters)
.count()
)
@@ -80,8 +85,22 @@ def dashboard_overview_stats(self, request, slug):
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
).filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(**extra_filters)
.count()
)
@@ -91,8 +110,22 @@ def dashboard_overview_stats(self, request, slug):
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
created_by_id=request.user.id,
).filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(**extra_filters)
.count()
)
@@ -103,8 +136,22 @@ def dashboard_overview_stats(self, request, slug):
project__project_projectmember__member=request.user,
assignees__in=[request.user],
state__group="completed",
).filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(**extra_filters)
.count()
)
+6 -1
View File
@@ -28,7 +28,12 @@ def generate_random_name(length=10):
class ProjectEstimatePointEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
]
)
def get(self, request, slug, project_id):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
if project.estimate_id is not None:
+37 -14
View File
@@ -165,11 +165,12 @@ class InboxIssueViewSet(BaseViewSet):
)
).distinct()
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
inbox_id = Inbox.objects.get(
workspace__slug=slug, project_id=project_id
)
project = Project.objects.get(pk=project_id)
filters = issue_filters(request.GET, "GET", "issue__")
inbox_issue = (
InboxIssue.objects.filter(
@@ -199,13 +200,16 @@ class InboxIssueViewSet(BaseViewSet):
if inbox_status:
inbox_issue = inbox_issue.filter(status__in=inbox_status)
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists():
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
):
inbox_issue = inbox_issue.filter(created_by=request.user)
return self.paginate(
request=request,
@@ -338,7 +342,7 @@ class InboxIssueViewSet(BaseViewSet):
is_active=True,
)
# Only project members admins and created_by users can access this endpoint
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
if project_member.role <= 5 and str(inbox_issue.created_by_id) != str(
request.user.id
):
return Response(
@@ -371,9 +375,8 @@ class InboxIssueViewSet(BaseViewSet):
workspace__slug=slug,
project_id=project_id,
)
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
# viewers and guests since only viewers and guests
# Only allow guests to edit name and description
if project_member.role <= 5:
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get(
@@ -415,7 +418,7 @@ class InboxIssueViewSet(BaseViewSet):
)
# Only project admins and members can edit inbox issue attributes
if project_member.role > 10:
if project_member.role > 5:
serializer = InboxIssueSerializer(
inbox_issue, data=request.data, partial=True
)
@@ -515,7 +518,11 @@ class InboxIssueViewSet(BaseViewSet):
return Response(serializer, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER],
allowed_roles=[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
],
creator=True,
model=Issue,
)
@@ -523,6 +530,7 @@ class InboxIssueViewSet(BaseViewSet):
inbox_id = Inbox.objects.get(
workspace__slug=slug, project_id=project_id
)
project = Project.objects.get(pk=project_id)
inbox_issue = (
InboxIssue.objects.select_related("issue")
.prefetch_related(
@@ -549,6 +557,21 @@ class InboxIssueViewSet(BaseViewSet):
)
.get(inbox_id=inbox_id.id, issue_id=pk, project_id=project_id)
)
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 inbox_issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
issue = InboxIssueDetailSerializer(inbox_issue).data
return Response(
issue,
+13 -3
View File
@@ -19,7 +19,11 @@ from plane.app.serializers import (
IssueActivitySerializer,
IssueCommentSerializer,
)
from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE
from plane.app.permissions import (
ProjectEntityPermission,
allow_permission,
ROLE,
)
from plane.db.models import (
IssueActivity,
IssueComment,
@@ -33,7 +37,13 @@ class IssueActivityEndpoint(BaseAPIView):
]
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def get(self, request, slug, project_id, issue_id):
filters = {}
if request.GET.get("created_at__gt", None) is not None:
@@ -42,7 +52,7 @@ class IssueActivityEndpoint(BaseAPIView):
issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id)
.filter(
~Q(field__in=["comment", "vote", "reaction"]),
~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
+12 -2
View File
@@ -97,7 +97,12 @@ class IssueArchiveViewSet(BaseViewSet):
)
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
]
)
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true")
@@ -213,7 +218,12 @@ class IssueArchiveViewSet(BaseViewSet):
),
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
]
)
def retrieve(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
@@ -64,7 +64,13 @@ class IssueAttachmentEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def get(self, request, slug, project_id, issue_id):
issue_attachments = IssueAttachment.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
+81 -14
View File
@@ -42,6 +42,7 @@ from plane.db.models import (
IssueSubscriber,
Project,
ProjectMember,
CycleIssueStateProgress,
)
from plane.utils.grouper import (
issue_group_values,
@@ -62,7 +63,7 @@ from plane.bgtasks.webhook_task import model_activity
class IssueListEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
issue_ids = request.GET.get("issues", False)
@@ -232,8 +233,9 @@ class IssueViewSet(BaseViewSet):
).distinct()
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
@@ -264,13 +266,16 @@ class IssueViewSet(BaseViewSet):
entity_identifier=project_id,
user_id=request.user.id,
)
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists():
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
):
issue_queryset = issue_queryset.filter(created_by=request.user)
if group_by:
@@ -440,9 +445,17 @@ class IssueViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], creator=True, model=Issue
allowed_roles=[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
],
creator=True,
model=Issue,
)
def retrieve(self, request, slug, project_id, pk=None):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
issue = (
self.get_queryset()
.filter(pk=pk)
@@ -511,6 +524,29 @@ class IssueViewSet(BaseViewSet):
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",
@@ -522,7 +558,9 @@ class IssueViewSet(BaseViewSet):
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], creator=True, model=Issue
)
def partial_update(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
@@ -566,6 +604,29 @@ class IssueViewSet(BaseViewSet):
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if issue.cycle_id:
CycleIssueStateProgress.objects.create(
cycle_id=issue.cycle_id,
state_id=issue.state_id,
issue_id=issue.id,
state_group=issue.state.group,
type="UPDATED",
estimate_id=issue.estimate_point_id,
estimate_value=(
issue.estimate_point.value if estimate_type else None
),
project_id=project_id,
workspace_id=issue.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
serializer = IssueCreateSerializer(
@@ -618,7 +679,7 @@ class IssueViewSet(BaseViewSet):
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def patch(self, request, slug, project_id):
issue_property = IssueUserProperty.objects.get(
user=request.user,
@@ -638,7 +699,13 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
serializer = IssueUserPropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def get(self, request, slug, project_id):
issue_property, _ = IssueUserProperty.objects.get_or_create(
user=request.user, project_id=project_id
@@ -719,7 +786,7 @@ class IssuePaginatedViewSet(BaseViewSet):
return paginated_data
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
cursor = request.GET.get("cursor", None)
is_description_required = request.GET.get("description", False)
+42 -6
View File
@@ -16,11 +16,13 @@ from plane.app.serializers import (
IssueCommentSerializer,
CommentReactionSerializer,
)
from plane.app.permissions import ProjectLitePermission, allow_permission, ROLE
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import (
IssueComment,
ProjectMember,
CommentReaction,
Project,
Issue,
)
from plane.bgtasks.issue_activities_task import issue_activity
@@ -63,8 +65,31 @@ class IssueCommentViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def create(self, request, slug, project_id, issue_id):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(pk=issue_id)
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 comment on the issue"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
@@ -89,7 +114,7 @@ class IssueCommentViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
allowed_roles=[ROLE.ADMIN],
creator=True,
model=IssueComment,
)
@@ -156,9 +181,6 @@ class IssueCommentViewSet(BaseViewSet):
class CommentReactionViewSet(BaseViewSet):
serializer_class = CommentReactionSerializer
model = CommentReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
@@ -176,6 +198,13 @@ class CommentReactionViewSet(BaseViewSet):
.distinct()
)
@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():
@@ -198,6 +227,13 @@ class CommentReactionViewSet(BaseViewSet):
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):
comment_reaction = CommentReaction.objects.get(
workspace__slug=slug,
+3 -3
View File
@@ -43,7 +43,7 @@ class LabelViewSet(BaseViewSet):
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
try:
serializer = LabelSerializer(data=request.data)
@@ -66,14 +66,14 @@ class LabelViewSet(BaseViewSet):
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@allow_permission([ROLE.ADMIN])
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@allow_permission([ROLE.ADMIN])
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
+3 -4
View File
@@ -12,7 +12,7 @@ from rest_framework import status
# Module imports
from .. import BaseViewSet
from plane.app.serializers import IssueReactionSerializer
from plane.app.permissions import ProjectLitePermission
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import IssueReaction
from plane.bgtasks.issue_activities_task import issue_activity
@@ -20,9 +20,6 @@ from plane.bgtasks.issue_activities_task import issue_activity
class IssueReactionViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
@@ -40,6 +37,7 @@ class IssueReactionViewSet(BaseViewSet):
.distinct()
)
@allow_permission(ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST)
def create(self, request, slug, project_id, issue_id):
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
@@ -62,6 +60,7 @@ class IssueReactionViewSet(BaseViewSet):
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, issue_id, reaction_code):
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
+15 -5
View File
@@ -317,7 +317,12 @@ class ModuleViewSet(BaseViewSet):
.order_by("-is_favorite", "-created_at")
)
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
]
)
def create(self, request, slug, project_id):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
@@ -381,7 +386,7 @@ class ModuleViewSet(BaseViewSet):
return Response(module, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True)
@@ -430,7 +435,12 @@ class ModuleViewSet(BaseViewSet):
)
return Response(modules, status=status.HTTP_200_OK)
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
]
)
def retrieve(self, request, slug, project_id, pk):
queryset = (
@@ -861,7 +871,7 @@ class ModuleFavoriteViewSet(BaseViewSet):
class ModuleUserPropertiesEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def patch(self, request, slug, project_id, module_id):
module_properties = ModuleUserProperties.objects.get(
user=request.user,
@@ -884,7 +894,7 @@ class ModuleUserPropertiesEndpoint(BaseAPIView):
serializer = ModuleUserPropertiesSerializer(module_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, module_id):
module_properties, _ = ModuleUserProperties.objects.get_or_create(
user=request.user,
+6 -1
View File
@@ -91,7 +91,12 @@ class ModuleIssueViewSet(BaseViewSet):
).distinct()
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
]
)
def list(self, request, slug, project_id, module_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters)
@@ -41,7 +41,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
level="WORKSPACE",
)
def list(self, request, slug):
@@ -174,7 +174,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
level="WORKSPACE",
)
def partial_update(self, request, slug, pk):
@@ -195,8 +195,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def mark_read(self, request, slug, pk):
notification = Notification.objects.get(
@@ -246,7 +245,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
class UnreadNotificationEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
level="WORKSPACE",
)
def get(self, request, slug):
+66 -11
View File
@@ -32,8 +32,10 @@ from plane.db.models import (
UserFavorite,
ProjectMember,
ProjectPage,
Project,
)
from plane.utils.error_codes import ERROR_CODES
# Module imports
from ..base import BaseAPIView, BaseViewSet
@@ -120,7 +122,7 @@ class PageViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id):
serializer = PageSerializer(
data=request.data,
@@ -142,7 +144,7 @@ class PageViewSet(BaseViewSet):
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])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(
@@ -208,9 +210,38 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def retrieve(self, request, slug, project_id, pk=None):
page = self.get_queryset().filter(pk=pk).first()
project = Project.objects.get(pk=project_id)
"""
if the role is guest and guest_view_all_features is false and owned by is not
the requesting user then dont show the page
"""
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 page.owned_by == request.user
):
return Response(
{"error": "You are not allowed to view this page"},
status=status.HTTP_400_BAD_REQUEST,
)
if page is None:
return Response(
{"error": "Page not found"},
@@ -234,7 +265,7 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def lock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -244,7 +275,7 @@ class PageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -255,7 +286,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0)
page = Page.objects.filter(
@@ -278,13 +309,31 @@ class PageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
project = Project.objects.get(pk=project_id)
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
):
queryset = queryset.filter(owned_by=request.user)
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -319,7 +368,7 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -477,7 +526,13 @@ class SubPagesEndpoint(BaseAPIView):
class PagesDescriptionViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def retrieve(self, request, slug, project_id, pk):
page = (
Page.objects.filter(
@@ -507,7 +562,7 @@ class PagesDescriptionViewSet(BaseViewSet):
)
return response
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
page = (
Page.objects.filter(
+1 -1
View File
@@ -15,7 +15,7 @@ from plane.app.permissions import allow_permission, ROLE
class PageVersionEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]
)
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
+27 -20
View File
@@ -71,13 +71,6 @@ class ProjectViewSet(BaseViewSet):
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(
Q(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
| Q(network=2)
)
.select_related(
"workspace",
"workspace__owner",
@@ -155,7 +148,7 @@ class ProjectViewSet(BaseViewSet):
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
level="WORKSPACE",
)
def list(self, request, slug):
@@ -165,6 +158,31 @@ class ProjectViewSet(BaseViewSet):
if field
]
projects = self.get_queryset().order_by("sort_order", "name")
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=10,
).exists():
projects = projects.filter(
Q(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
| Q(network=2)
)
if request.GET.get("per_page", False) and request.GET.get(
"cursor", False
):
@@ -177,24 +195,13 @@ class ProjectViewSet(BaseViewSet):
).data,
)
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role__in=[5, 10],
).exists():
projects = projects.filter(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
projects = ProjectListSerializer(
projects, many=True, fields=fields if fields else None
).data
return Response(projects, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
level="WORKSPACE",
)
def retrieve(self, request, slug, pk):
+18 -24
View File
@@ -17,7 +17,7 @@ from rest_framework.permissions import AllowAny
from .base import BaseViewSet, BaseAPIView
from plane.app.serializers import ProjectMemberInviteSerializer
from plane.app.permissions import ProjectBasePermission
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import (
ProjectMember,
@@ -35,10 +35,6 @@ class ProjectInvitationsViewset(BaseViewSet):
search_fields = []
permission_classes = [
ProjectBasePermission,
]
def get_queryset(self):
return self.filter_queryset(
super()
@@ -49,6 +45,7 @@ class ProjectInvitationsViewset(BaseViewSet):
.select_related("workspace", "workspace__owner")
)
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
emails = request.data.get("emails", [])
@@ -59,24 +56,21 @@ class ProjectInvitationsViewset(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
requesting_user = ProjectMember.objects.get(
workspace__slug=slug,
project_id=project_id,
member_id=request.user.id,
)
for email in emails:
workspace_role = WorkspaceMember.objects.filter(
workspace__slug=slug,
member__email=email.get("email"),
is_active=True,
).role
# Check if any invited user has an higher role
if len(
[
email
for email in emails
if int(email.get("role", 10)) > requesting_user.role
]
):
return Response(
{"error": "You cannot invite a user with higher role"},
status=status.HTTP_400_BAD_REQUEST,
)
if workspace_role in [5, 20] and workspace_role != email.get(
"role", 5
):
return Response(
{
"error": "You cannot invite a user with different role than workspace role"
},
)
workspace = Workspace.objects.get(slug=slug)
@@ -97,7 +91,7 @@ class ProjectInvitationsViewset(BaseViewSet):
settings.SECRET_KEY,
algorithm="HS256",
),
role=email.get("role", 10),
role=email.get("role", 5),
created_by=request.user,
)
)
@@ -170,7 +164,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
ProjectMember(
project_id=project_id,
member=request.user,
role=15 if workspace_role >= 15 else 10,
role=workspace_role,
workspace=workspace,
created_by=request.user,
)
+20 -8
View File
@@ -95,9 +95,21 @@ class ProjectMemberViewSet(BaseViewSet):
member=member,
is_active=True,
).role
if workspace_member_role in [5, 10] and member_roles.get(
member
) in [15, 20]:
if workspace_member_role in [20] and member_roles.get(member) in [
5,
15,
]:
return Response(
{
"error": "You cannot add a user with role lower than the workspace role"
},
status=status.HTTP_400_BAD_REQUEST,
)
if workspace_member_role in [5] and member_roles.get(member) in [
15,
20,
]:
return Response(
{
"error": "You cannot add a user with role higher than the workspace role"
@@ -143,7 +155,7 @@ class ProjectMemberViewSet(BaseViewSet):
bulk_project_members.append(
ProjectMember(
member_id=member.get("member_id"),
role=member.get("role", 10),
role=member.get("role", 5),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=(
@@ -189,7 +201,7 @@ class ProjectMemberViewSet(BaseViewSet):
# Return the serialized data
return Response(serializer.data, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
# Get the list of project members for the project
project_members = ProjectMember.objects.filter(
@@ -230,7 +242,7 @@ class ProjectMemberViewSet(BaseViewSet):
member=project_member.member,
is_active=True,
).role
if workspace_role in [5, 10] and int(
if workspace_role in [5] and int(
request.data.get("role", project_member.role)
) in [15, 20]:
return Response(
@@ -261,7 +273,7 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@allow_permission([ROLE.ADMIN])
def destroy(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(
workspace__slug=slug,
@@ -298,7 +310,7 @@ class ProjectMemberViewSet(BaseViewSet):
project_member.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def leave(self, request, slug, project_id):
project_member = ProjectMember.objects.get(
workspace__slug=slug,
+6 -4
View File
@@ -9,7 +9,8 @@ from rest_framework import status
from .. import BaseViewSet
from plane.app.serializers import StateSerializer
from plane.app.permissions import (
ProjectEntityPermission,
ROLE,
allow_permission
)
from plane.db.models import State, Issue
from plane.utils.cache import invalidate_cache
@@ -18,9 +19,6 @@ from plane.utils.cache import invalidate_cache
class StateViewSet(BaseViewSet):
serializer_class = StateSerializer
model = State
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
return self.filter_queryset(
@@ -42,6 +40,7 @@ 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():
@@ -49,6 +48,7 @@ class StateViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
states = StateSerializer(self.get_queryset(), many=True).data
grouped = request.GET.get("grouped", False)
@@ -65,6 +65,7 @@ class StateViewSet(BaseViewSet):
@invalidate_cache(
path="workspaces/:slug/states/", url_params=True, user=False
)
@allow_permission([ROLE.ADMIN])
def mark_as_default(self, request, slug, project_id, pk):
# Select all the states which are marked as default
_ = State.objects.filter(
@@ -78,6 +79,7 @@ class StateViewSet(BaseViewSet):
@invalidate_cache(
path="workspaces/:slug/states/", url_params=True, user=False
)
@allow_permission([ROLE.ADMIN])
def destroy(self, request, slug, project_id, pk):
state = State.objects.get(
is_triage=False,
+55 -23
View File
@@ -35,6 +35,7 @@ from plane.db.models import (
Workspace,
WorkspaceMember,
ProjectMember,
Project,
)
from plane.utils.grouper import (
issue_group_values,
@@ -75,7 +76,7 @@ class WorkspaceViewViewSet(BaseViewSet):
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
level="WORKSPACE",
)
def list(self, request, slug):
@@ -259,7 +260,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
@method_decorator(gzip_page)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
level="WORKSPACE",
)
def list(self, request, slug):
@@ -272,15 +273,24 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.annotate(cycle_id=F("issue_cycle__cycle_id"))
)
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
issue_queryset = issue_queryset.filter(
created_by=request.user,
# check for the project member role, if the role is 5 then check for the guest_view_all_features if it is true then show all the issues else show only the issues created by the user
issue_queryset = issue_queryset.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
@@ -421,19 +431,21 @@ class IssueViewViewSet(BaseViewSet):
.distinct()
)
allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
queryset = self.get_queryset()
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists():
project = Project.objects.get(id=project_id)
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
):
queryset = queryset.filter(owned_by=request.user)
fields = [
field
@@ -445,14 +457,34 @@ class IssueViewViewSet(BaseViewSet):
).data
return Response(views, status=status.HTTP_200_OK)
allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk):
issue_view = (
self.get_queryset().filter(pk=pk, project_id=project_id).first()
)
project = Project.objects.get(id=project_id)
"""
if the role is guest and guest_view_all_features is false and owned by is not
the requesting user then dont show the view
"""
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_view.owned_by == request.user
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueViewSerializer(issue_view)
recent_visited_task.delay(
slug=slug,
@@ -43,6 +43,7 @@ from plane.db.models import (
WorkspaceMember,
WorkspaceTheme,
)
from plane.app.permissions import ROLE, allow_permission
from plane.utils.cache import cache_response, invalidate_cache
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
@@ -147,11 +148,25 @@ class WorkSpaceViewSet(BaseViewSet):
)
@cache_response(60 * 60 * 2)
@allow_permission(
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
],
level="WORKSPACE",
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@invalidate_cache(path="/api/workspaces/", user=False)
@invalidate_cache(path="/api/users/me/workspaces/")
@allow_permission(
[
ROLE.ADMIN,
],
level="WORKSPACE",
)
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
@@ -162,6 +177,7 @@ class WorkSpaceViewSet(BaseViewSet):
@invalidate_cache(
path="/api/users/me/settings/", multiple=True, user=False
)
@allow_permission([ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
@@ -74,7 +74,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
[
email
for email in emails
if int(email.get("role", 10)) > requesting_user.role
if int(email.get("role", 5)) > requesting_user.role
]
):
return Response(
@@ -119,7 +119,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
settings.SECRET_KEY,
algorithm="HS256",
),
role=email.get("role", 10),
role=email.get("role", 5),
created_by=request.user,
)
)
+15 -37
View File
@@ -13,7 +13,8 @@ from rest_framework.response import Response
from plane.app.permissions import (
WorkSpaceAdminPermission,
WorkspaceEntityPermission,
WorkspaceUserPermission,
allow_permission,
ROLE,
)
# Module imports
@@ -43,22 +44,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
serializer_class = WorkspaceMemberAdminSerializer
model = WorkspaceMember
permission_classes = [
WorkspaceEntityPermission,
]
def get_permissions(self):
if self.action == "leave":
self.permission_classes = [
WorkspaceUserPermission,
]
else:
self.permission_classes = [
WorkspaceEntityPermission,
]
return super(WorkSpaceMemberViewSet, self).get_permissions()
search_fields = [
"member__display_name",
"member__first_name",
@@ -77,6 +62,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
)
@cache_response(60 * 60 * 2)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def list(self, request, slug):
workspace_member = WorkspaceMember.objects.get(
member=request.user,
@@ -87,7 +75,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Get all active workspace members
workspace_members = self.get_queryset()
if workspace_member.role > 10:
if workspace_member.role > 5:
serializer = WorkspaceMemberAdminSerializer(
workspace_members,
fields=("id", "member", "role"),
@@ -107,6 +95,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
user=False,
multiple=True,
)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def partial_update(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(
pk=pk,
@@ -120,25 +109,10 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
# Get the requested user role
requested_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug,
member=request.user,
is_active=True,
)
# Check if role is being updated
# One cannot update role higher than his own role
if (
"role" in request.data
and int(request.data.get("role", workspace_member.role))
> requested_workspace_member.role
):
return Response(
{
"error": "You cannot update a role that is higher than your own role"
},
status=status.HTTP_400_BAD_REQUEST,
)
if workspace_member.role > int(request.data.get("role")):
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id
).update(role=int(request.data.get("role")))
serializer = WorkSpaceMemberSerializer(
workspace_member, data=request.data, partial=True
@@ -159,6 +133,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
@invalidate_cache(
path="/api/users/me/workspaces/", user=False, multiple=True
)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, slug, pk):
# Check the user role who is deleting the user
workspace_member = WorkspaceMember.objects.get(
@@ -233,6 +208,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
@invalidate_cache(
path="api/users/me/workspaces/", user=False, multiple=True
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def leave(self, request, slug):
workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug,
+1 -1
View File
@@ -288,7 +288,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
is_active=True,
)
projects = []
if requesting_workspace_member.role >= 10:
if requesting_workspace_member.role >= 15:
projects = (
Project.objects.filter(
workspace__slug=slug,
@@ -49,7 +49,7 @@ def process_workspace_project_invitations(user):
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
if project_member_invite.role in [5, 15]
else 15
),
member=user,
@@ -67,7 +67,7 @@ def process_workspace_project_invitations(user):
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
if project_member_invite.role in [5, 15]
else 15
),
member=user,
@@ -0,0 +1,120 @@
# Django imports
from django.db.models import Sum
from django.utils import timezone
from django.db.models import F
from django.db.models.functions import RowNumber
from django.db.models import Max, Subquery, OuterRef
# Third party imports
from celery import shared_task
from plane.db.models import Cycle, CycleIssueStateProgress, CycleAnalytics
@shared_task
def track_cycle_issue_state_progress():
active_cycles = Cycle.objects.filter(
start_date__lte=timezone.now(), end_date__gte=timezone.now()
).values_list("id", "project_id", "workspace_id")
analytics_records = []
current_date = timezone.now().date()
for cycle_id, project_id, workspace_id in active_cycles:
# Subquery to get the latest id for each issue_id
# Subquery to get the latest created_at for each issue_id
# latest_created_at = CycleIssueStateProgress.objects.filter(
# cycle_id=cycle_id,
# type__in=["ADDED", "UPDATED"],
# issue_id=OuterRef("issue_id"),
# created_at__lte=timezone.now(),
# ).values('issue_id').annotate(
# latest_created=Max('created_at')
# ).values('latest_created')
# # Main query to get the latest unique issues
# cycle_issues = CycleIssueStateProgress.objects.filter(
# cycle_id=cycle_id,
# type__in=["ADDED", "UPDATED"],
# created_at=Subquery(latest_created_at),
# issue_id=OuterRef("issue_id")
# ).order_by("issue_id")
cycle_issues = CycleIssueStateProgress.objects.filter(
id=Subquery(
CycleIssueStateProgress.objects.filter(
cycle_id=cycle_id,
type__in=["ADDED", "UPDATED"],
issue=OuterRef("issue"),
)
.order_by("-created_at")
.values("id")[:1]
)
)
# print()
for issue in cycle_issues.values():
print(issue, "issues")
total_issues = cycle_issues.count()
total_estimate_points = (
cycle_issues.aggregate(
total_estimate_points=Sum("estimate_value")
)["total_estimate_points"]
or 0
)
state_groups = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
state_data = {
group: {
"count": cycle_issues.filter(state_group=group).count(),
"estimate_points": cycle_issues.filter(
state_group=group
).aggregate(total_estimate_points=Sum("estimate_value"))[
"total_estimate_points"
]
or 0,
}
for group in state_groups
}
# Prepare analytics record for bulk insert
analytics_records.append(
CycleAnalytics(
cycle_id=cycle_id,
date=current_date,
total_issues=total_issues,
total_estimate_points=total_estimate_points,
backlog_issues=state_data["backlog"]["count"],
unstarted_issues=state_data["unstarted"]["count"],
started_issues=state_data["started"]["count"],
completed_issues=state_data["completed"]["count"],
cancelled_issues=state_data["cancelled"]["count"],
backlog_estimate_points=state_data["backlog"][
"estimate_points"
],
unstarted_estimate_points=state_data["unstarted"][
"estimate_points"
],
started_estimate_points=state_data["started"][
"estimate_points"
],
completed_estimate_points=state_data["completed"][
"estimate_points"
],
cancelled_estimate_points=state_data["cancelled"][
"estimate_points"
],
project_id=project_id,
workspace_id=workspace_id,
)
)
# Bulk create the records at once
if analytics_records:
CycleAnalytics.objects.bulk_create(analytics_records)
+13 -5
View File
@@ -244,9 +244,13 @@ def update_json_row(rows, row):
)
assignee, label = row["Assignee"], row["Labels"]
if assignee is not None and assignee not in existing_assignees:
if assignee is not None and (
existing_assignees is None or label not in existing_assignees
):
rows[matched_index]["Assignee"] += f", {assignee}"
if label is not None and label not in existing_labels:
if label is not None and (
existing_labels is None or label not in existing_labels
):
rows[matched_index]["Labels"] += f", {label}"
else:
rows.append(row)
@@ -266,9 +270,13 @@ def update_table_row(rows, row):
existing_assignees, existing_labels = rows[matched_index][7:9]
assignee, label = row[7:9]
if assignee is not None and assignee not in existing_assignees:
rows[matched_index][7] += f", {assignee}"
if label is not None and label not in existing_labels:
if assignee is not None and (
existing_assignees is None or label not in existing_assignees
):
rows[matched_index][8] += f", {assignee}"
if label is not None and (
existing_labels is None or label not in existing_labels
):
rows[matched_index][8] += f", {label}"
else:
rows.append(row)
@@ -1530,7 +1530,19 @@ def update_draft_issue_activity(
workspace_id=workspace_id,
comment="created the issue",
verb="updated",
actor_id=actor_id,
epoch=epoch,
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="updated the draft issue",
field="draft",
verb="updated",
actor_id=actor_id,
epoch=epoch,
)
+4
View File
@@ -40,6 +40,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.deletion_task.hard_delete",
"schedule": crontab(hour=0, minute=0),
},
"track-cycle-issue-state-progress": {
"task": "plane.bgtasks.cycle_issue_state_progress_task.track_cycle_issue_state_progress",
"schedule": crontab(hour=9, minute=6),
},
}
# Load task modules from all registered Django app configs.
@@ -0,0 +1,60 @@
# Generated by Django 4.2.15 on 2024-08-30 07:34
from django.db import migrations, models
def update_workspace_project_member_role(apps, schema_editor):
WorkspaceMember = apps.get_model("db", "WorkspaceMember")
ProjectMember = apps.get_model("db", "ProjectMember")
# update all existing members with role 10 to role 5
WorkspaceMember.objects.filter(role=10).update(role=5)
ProjectMember.objects.filter(role=10).update(role=5)
class Migration(migrations.Migration):
dependencies = [
("db", "0075_alter_fileasset_asset"),
]
operations = [
migrations.AlterField(
model_name="projectmember",
name="role",
field=models.PositiveSmallIntegerField(
choices=[(20, "Admin"), (15, "Member"), (5, "Guest")],
default=5,
),
),
migrations.AlterField(
model_name="projectmemberinvite",
name="role",
field=models.PositiveSmallIntegerField(
choices=[(20, "Admin"), (15, "Member"), (5, "Guest")],
default=5,
),
),
migrations.AlterField(
model_name="workspacemember",
name="role",
field=models.PositiveSmallIntegerField(
choices=[(20, "Admin"), (15, "Member"), (5, "Guest")],
default=5,
),
),
migrations.AlterField(
model_name="workspacememberinvite",
name="role",
field=models.PositiveSmallIntegerField(
choices=[(20, "Admin"), (15, "Member"), (5, "Guest")],
default=5,
),
),
migrations.AddField(
model_name="project",
name="guest_view_all_features",
field=models.BooleanField(default=False),
),
migrations.RunPython(update_workspace_project_member_role),
]
File diff suppressed because one or more lines are too long
+10 -1
View File
@@ -2,7 +2,16 @@ from .analytic import AnalyticView
from .api import APIActivityLog, APIToken
from .asset import FileAsset
from .base import BaseModel
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
from .cycle import (
Cycle,
CycleFavorite,
CycleIssue,
CycleUserProperties,
CycleAnalytics,
CycleUpdates,
CycleUpdateReaction,
CycleIssueStateProgress,
)
from .dashboard import Dashboard, DashboardWidget, Widget
from .deploy_board import DeployBoard
from .estimate import Estimate, EstimatePoint
+149 -2
View File
@@ -1,3 +1,6 @@
# Python Imports
import pytz
# Django imports
from django.conf import settings
from django.db import models
@@ -55,10 +58,12 @@ class Cycle(ProjectBaseModel):
description = models.TextField(
verbose_name="Cycle Description", blank=True
)
start_date = models.DateField(
start_date = models.DateTimeField(
verbose_name="Start Date", blank=True, null=True
)
end_date = models.DateField(verbose_name="End Date", blank=True, null=True)
end_date = models.DateTimeField(
verbose_name="End Date", blank=True, null=True
)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
@@ -71,6 +76,11 @@ class Cycle(ProjectBaseModel):
progress_snapshot = models.JSONField(default=dict)
archived_at = models.DateTimeField(null=True)
logo_props = models.JSONField(default=dict)
# timezone
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
user_timezone = models.CharField(
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
)
class Meta:
verbose_name = "Cycle"
@@ -176,3 +186,140 @@ class CycleUserProperties(ProjectBaseModel):
def __str__(self):
return f"{self.cycle.name} {self.user.email}"
class TypeEnum(models.TextChoices):
ADDED = "ADDED", "Added"
UPDATED = "UPDATED", "Updated"
REMOVED = "REMOVED", "Removed"
TRANSFER = "TRANSFER", "Transfer"
class CycleIssueStateProgress(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle",
on_delete=models.DO_NOTHING,
related_name="cycle_issue_state_progress",
)
state = models.ForeignKey(
"db.State",
on_delete=models.DO_NOTHING,
related_name="cycle_issue_state_progress",
)
issue = models.ForeignKey(
"db.Issue",
on_delete=models.DO_NOTHING,
related_name="cycle_issue_state_progress",
)
state_group = models.CharField(max_length=255)
type = models.CharField(
max_length=30,
choices=TypeEnum.choices,
)
estimate_id = models.UUIDField(null=True)
estimate_value = models.FloatField(null=True)
class Meta:
verbose_name = "Cycle Issue State Progress"
verbose_name_plural = "Cycle Issue State Progress"
db_table = "cycle_issue_state_progress"
ordering = ("-created_at",)
def __str__(self):
return f"{self.cycle.name} {self.issue.name}"
class CycleAnalytics(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle", on_delete=models.CASCADE, related_name="cycle_analytics"
)
date = models.DateField()
data = models.JSONField(default=dict)
total_issues = models.FloatField(default=0)
total_estimate_points = models.FloatField(default=0)
# state group wise distribution
backlog_issues = models.FloatField(default=0)
unstarted_issues = models.FloatField(default=0)
started_issues = models.FloatField(default=0)
completed_issues = models.FloatField(default=0)
cancelled_issues = models.FloatField(default=0)
backlog_estimate_points = models.FloatField(default=0)
unstarted_estimate_points = models.FloatField(default=0)
started_estimate_points = models.FloatField(default=0)
completed_estimate_points = models.FloatField(default=0)
cancelled_estimate_points = models.FloatField(default=0)
class Meta:
unique_together = ["cycle", "date"]
verbose_name = "Cycle Analytics"
verbose_name_plural = "Cycle Analytics"
db_table = "cycle_analytics"
ordering = ("-created_at",)
def __str__(self):
return f"{self.user.email} <{self.cycle.name}>"
class UpdatesEnum(models.TextChoices):
ONTRACK = "ONTRACK", "On Track"
OFFTRACK = "OFFTRACK", "Off Track"
AT_RISK = "AT_RISK", "At Risk"
STARTED = "STARTED", "Started"
SCOPE_INCREASED = "SCOPE_INCREASED", "Scope Increased"
SCOPE_DECREASED = "SCOPE_DECREASED", "Scope Decreased"
class CycleUpdates(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle", on_delete=models.CASCADE, related_name="cycle_updates"
)
description = models.TextField(blank=True)
status = models.CharField(
max_length=30,
choices=UpdatesEnum.choices,
)
completed_issues = models.FloatField(default=0)
total_issues = models.FloatField(default=0)
total_estimate_points = models.FloatField(default=0)
completed_estimate_points = models.FloatField(default=0)
class Meta:
verbose_name = "Cycle Updates"
verbose_name_plural = "Cycle Updates"
db_table = "cycle_updates"
ordering = ("-created_at",)
def __str__(self):
return f"{self.cycle.name}"
class CycleUpdateReaction(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle",
on_delete=models.CASCADE,
related_name="cycle_update_reactions",
)
update = models.ForeignKey(
"db.CycleUpdates",
on_delete=models.CASCADE,
related_name="cycle_update_reactions",
)
reaction = models.CharField(max_length=20)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="cycle_update_reactions",
)
class Meta:
verbose_name = "Cycle Update Reaction"
verbose_name_plural = "Cycle Update Reactions"
db_table = "cycle_update_reactions"
ordering = ("-created_at",)
def __str__(self):
return f"{self.actor.email} <{self.cycle.name}>"
+9 -3
View File
@@ -1,4 +1,5 @@
# Python imports
import pytz
from uuid import uuid4
# Django imports
@@ -16,7 +17,6 @@ from .base import BaseModel
ROLE_CHOICES = (
(20, "Admin"),
(15, "Member"),
(10, "Viewer"),
(5, "Guest"),
)
@@ -98,6 +98,7 @@ class Project(BaseModel):
inbox_view = models.BooleanField(default=False)
is_time_tracking_enabled = models.BooleanField(default=False)
is_issue_type_enabled = models.BooleanField(default=False)
guest_view_all_features = models.BooleanField(default=False)
cover_image = models.URLField(blank=True, null=True, max_length=800)
estimate = models.ForeignKey(
"db.Estimate",
@@ -119,6 +120,11 @@ class Project(BaseModel):
related_name="default_state",
)
archived_at = models.DateTimeField(null=True)
# timezone
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
user_timezone = models.CharField(
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
)
def __str__(self):
"""Return name of the project"""
@@ -173,7 +179,7 @@ class ProjectMemberInvite(ProjectBaseModel):
token = models.CharField(max_length=255)
message = models.TextField(null=True)
responded_at = models.DateTimeField(null=True)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5)
class Meta:
verbose_name = "Project Member Invite"
@@ -194,7 +200,7 @@ class ProjectMember(ProjectBaseModel):
related_name="member_project",
)
comment = models.TextField(blank=True, null=True)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5)
view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props)
preferences = models.JSONField(default=get_default_preferences)
+4 -5
View File
@@ -8,9 +8,8 @@ from .base import BaseModel
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
ROLE_CHOICES = (
(20, "Owner"),
(15, "Admin"),
(10, "Member"),
(20, "Admin"),
(15, "Member"),
(5, "Guest"),
)
@@ -177,7 +176,7 @@ class WorkspaceMember(BaseModel):
on_delete=models.CASCADE,
related_name="member_workspace",
)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5)
company_role = models.TextField(null=True, blank=True)
view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props)
@@ -214,7 +213,7 @@ class WorkspaceMemberInvite(BaseModel):
token = models.CharField(max_length=255)
message = models.TextField(null=True)
responded_at = models.DateTimeField(null=True)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5)
class Meta:
unique_together = ["email", "workspace", "deleted_at"]
+4 -3
View File
@@ -265,7 +265,7 @@ if AMQP_URL:
CELERY_BROKER_URL = AMQP_URL
else:
CELERY_BROKER_URL = f"amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}"
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
@@ -279,6 +279,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
"plane.bgtasks.api_logs_task",
"plane.bgtasks.cycle_issue_state_progress_task",
# management tasks
"plane.bgtasks.dummy_data_task",
)
@@ -336,14 +337,14 @@ SESSION_COOKIE_SECURE = secure_origins
SESSION_COOKIE_HTTPONLY = True
SESSION_ENGINE = "plane.db.models.session"
SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800)
SESSION_COOKIE_NAME = "plane-session-id"
SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id")
SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
SESSION_SAVE_EVERY_REQUEST = (
os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1"
)
# Admin Cookie
ADMIN_SESSION_COOKIE_NAME = "plane-admin-session-id"
ADMIN_SESSION_COOKIE_NAME = "admin-session-id"
ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)
# CSRF cookies
+1 -1
View File
@@ -1 +1 @@
python-3.12.3
python-3.12.6
+2 -2
View File
@@ -442,10 +442,10 @@ function backupData() {
local BACKUP_FOLDER=$PLANE_INSTALL_DIR/backup/$datetime
mkdir -p "$BACKUP_FOLDER"
volumes=$(docker volume ls -f "name=plane-app" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
volumes=$(docker volume ls -f "name=$SERVICE_FOLDER" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
# Check if there are any matching volumes
if [ -z "$volumes" ]; then
echo "No volumes found starting with 'plane-app'"
echo "No volumes found starting with '$SERVICE_FOLDER'"
exit 1
fi
+6 -5
View File
@@ -12,6 +12,7 @@ DEBUG=0
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
API_BASE_URL=http://api:8000
#DB SETTINGS
PGHOST=plane-db
@@ -29,11 +30,11 @@ REDIS_PORT=6379
REDIS_URL=
# RabbitMQ Settings
RABBITMQ_HOST="plane-mq"
RABBITMQ_PORT="5672"
RABBITMQ_USER="plane"
RABBITMQ_PASSWORD="plane"
RABBITMQ_VHOST="plane"
RABBITMQ_HOST=plane-mq
RABBITMQ_PORT=5672
RABBITMQ_USER=plane
RABBITMQ_PASSWORD=plane
RABBITMQ_VHOST=plane
AMQP_URL=
# Secret Key
+3 -1
View File
@@ -3,11 +3,13 @@ RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
COPY . .
RUN yarn global add turbo
RUN yarn install
EXPOSE 3003
ENV TURBO_TELEMETRY_DISABLED 1
VOLUME [ "/app/node_modules", "/app/live/node_modules"]
CMD ["yarn","dev", "--filter=live"]
+4
View File
@@ -28,6 +28,8 @@ RUN yarn install
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
ENV TURBO_TELEMETRY_DISABLED 1
RUN yarn turbo build --filter=live
FROM base AS runner
@@ -36,4 +38,6 @@ WORKDIR /app
COPY --from=installer /app .
# COPY --from=installer /app/live/node_modules ./node_modules
ENV TURBO_TELEMETRY_DISABLED 1
EXPOSE 3000
+5 -2
View File
@@ -24,10 +24,12 @@
"@tiptap/core": "^2.4.0",
"@tiptap/html": "^2.3.0",
"axios": "^1.7.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express": "^4.20.0",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
"lodash": "^4.17.21",
"morgan": "^1.10.0",
@@ -42,6 +44,7 @@
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
@@ -51,6 +54,6 @@
"nodemon": "^3.1.0",
"ts-node": "^10.9.2",
"tsup": "^7.2.0",
"typescript": "^5.4.5"
"typescript": "5.4.5"
}
}
+33 -25
View File
@@ -24,7 +24,7 @@ import { TDocumentTypes } from "@/core/types/common.js";
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
import { updateDocument } from "@/plane-live/lib/update-document.js";
export const getExtensions: () => Extension[] = () => {
export const getExtensions: () => Promise<Extension[]> = async () => {
const extensions: Extension[] = [
new Logger({
onChange: false,
@@ -65,7 +65,7 @@ export const getExtensions: () => Extension[] = () => {
}
resolve(fetchedData);
} catch (error) {
console.error("Error in fetching document", error);
manualLogger.error("Error in fetching document", error);
}
});
},
@@ -97,7 +97,7 @@ export const getExtensions: () => Extension[] = () => {
});
}
} catch (error) {
console.error("Error in updating document", error);
manualLogger.error("Error in updating document:", error);
}
});
},
@@ -106,34 +106,42 @@ export const getExtensions: () => Extension[] = () => {
const redisUrl = getRedisUrl();
// Add the Redis extension only if configured
if (redisUrl) {
try {
const redisClient = new Redis(redisUrl);
redisClient.on("error", (error: any) => {
// if auth fails or the server is down, disconnect redis
if (
error?.code === "ENOTFOUND" ||
error.message.includes("WRONGPASS") ||
error.message.includes("NOAUTH")
) {
redisClient.disconnect();
}
manualLogger.error(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data betwen multiple plane live servers)`,
);
manualLogger.error(error);
await new Promise<void>((resolve, reject) => {
redisClient.on("error", (error: any) => {
if (
error?.code === "ENOTFOUND" ||
error.message.includes("WRONGPASS") ||
error.message.includes("NOAUTH")
) {
redisClient.disconnect();
}
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
reject(error);
});
redisClient.on("ready", () => {
extensions.push(new HocusPocusRedis({ redis: redisClient }));
manualLogger.info("Redis Client connected ✅");
resolve();
});
});
redisClient.on("ready", () => {
manualLogger.info("Redis Client connected");
});
if (!redisClient) {
throw new Error("Redis client is not defined");
}
extensions.push(new HocusPocusRedis({ redis: redisClient }));
} catch (error) {
manualLogger.error("Failed to connect to Redis:", error);
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
}
} else {
manualLogger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
}
return extensions;
+23
View File
@@ -7,9 +7,32 @@ const transport = {
},
};
const hooks = {
logMethod(inputArgs: any, method: any): any {
if (inputArgs.length >= 2) {
const arg1 = inputArgs.shift();
const arg2 = inputArgs.shift();
return method.apply(this, [arg2, arg1, ...inputArgs]);
}
return method.apply(this, inputArgs);
},
};
export const logger = pinoHttp({
level: "info",
transport: transport,
hooks: hooks,
serializers: {
req(req) {
return `${req.method} ${req.url}`;
},
res(res) {
return `${res.statusCode} ${res?.statusMessage || ""}`;
},
responseTime(time) {
return `${time}ms`;
},
},
});
export const manualLogger = logger.logger;
+31 -28
View File
@@ -3,33 +3,36 @@ import { Server } from "@hocuspocus/server";
import { handleAuthentication } from "@/core/lib/authentication.js";
import { getExtensions } from "@/core/extensions/index.js";
export const HocusPocusServer = Server.configure({
onAuthenticate: async ({
requestHeaders,
requestParameters,
connection,
// user id used as token for authentication
token,
}) => {
// request headers
const cookie = requestHeaders.cookie?.toString();
// params
const params = requestParameters;
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
return Server.configure({
onAuthenticate: async ({
requestHeaders,
requestParameters,
connection,
// user id used as token for authentication
token,
}) => {
// request headers
const cookie = requestHeaders.cookie?.toString();
// params
const params = requestParameters;
if (!cookie) {
throw Error("Credentials not provided");
}
if (!cookie) {
throw Error("Credentials not provided");
}
try {
await handleAuthentication({
connection,
cookie,
params,
token,
});
} catch (error) {
throw Error("Authentication unsuccessful!");
}
},
extensions: getExtensions(),
});
try {
await handleAuthentication({
connection,
cookie,
params,
token,
});
} catch (error) {
throw Error("Authentication unsuccessful!");
}
},
extensions,
});
};
+18 -11
View File
@@ -5,6 +5,8 @@ import { UserService } from "@/core/services/user.service.js";
import { TDocumentTypes } from "@/core/types/common.js";
// plane live lib
import { authenticateUser } from "@/plane-live/lib/authentication.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
const userService = new UserService();
@@ -26,7 +28,7 @@ export const handleAuthentication = async (props: Props) => {
try {
response = await userService.currentUser(cookie);
} catch (error) {
console.error("Failed to fetch current user:", error);
manualLogger.error("Failed to fetch current user:", error);
throw error;
}
if (response.id !== token) {
@@ -43,22 +45,27 @@ export const handleAuthentication = async (props: Props) => {
);
}
// fetch current user's project membership info
const projectMembershipInfo = await userService.getUserProjectMembership(
workspaceSlug,
projectId,
cookie
);
const projectRole = projectMembershipInfo.role;
// make the connection read only for roles lower than a member
if (projectRole < 15) {
connection.readOnly = true;
try {
const projectMembershipInfo = await userService.getUserProjectMembership(
workspaceSlug,
projectId,
cookie
);
const projectRole = projectMembershipInfo.role;
// make the connection read only for roles lower than a member
if (projectRole < 15) {
connection.readOnly = true;
}
} catch (error) {
manualLogger.error("Failed to fetch project membership info:", error);
throw error;
}
} else {
await authenticateUser({
connection,
cookie,
documentType,
params
params,
});
}
+24 -18
View File
@@ -1,18 +1,22 @@
// helpers
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page.js";
import {
getAllDocumentFormatsFromBinaryData,
getBinaryDataFromHTMLString,
} from "@/core/helpers/page.js";
// services
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
const pageService = new PageService();
export const updatePageDescription = async (
params: URLSearchParams,
pageId: string,
updatedDescription: Uint8Array,
cookie: string | undefined
cookie: string | undefined,
) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error(
"Invalid updatedDescription: must be an instance of Uint8Array"
"Invalid updatedDescription: must be an instance of Uint8Array",
);
}
@@ -20,11 +24,8 @@ export const updatePageDescription = async (
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const {
contentBinaryEncoded,
contentHTML,
contentJSON
} = getAllDocumentFormatsFromBinaryData(updatedDescription);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
@@ -37,10 +38,10 @@ export const updatePageDescription = async (
projectId,
pageId,
payload,
cookie
cookie,
);
} catch (error) {
console.error("Update error:", error);
manualLogger.error("Update error:", error);
throw error;
}
};
@@ -49,7 +50,7 @@ const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
cookie: string,
) => {
if (!workspaceSlug || !projectId || !cookie) return;
@@ -58,12 +59,17 @@ const fetchDescriptionHTMLAndTransform = async (
workspaceSlug,
projectId,
pageId,
cookie
cookie,
);
const { contentBinary } = getBinaryDataFromHTMLString(
pageDetails.description_html ?? "<p></p>",
);
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>")
return contentBinary;
} catch (error) {
console.error("Error while transforming from HTML to Uint8Array", error);
manualLogger.error(
"Error while transforming from HTML to Uint8Array",
error,
);
throw error;
}
};
@@ -71,7 +77,7 @@ const fetchDescriptionHTMLAndTransform = async (
export const fetchPageDescriptionBinary = async (
params: URLSearchParams,
pageId: string,
cookie: string | undefined
cookie: string | undefined,
) => {
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
@@ -82,7 +88,7 @@ export const fetchPageDescriptionBinary = async (
workspaceSlug,
projectId,
pageId,
cookie
cookie,
);
const binaryData = new Uint8Array(response);
@@ -91,7 +97,7 @@ export const fetchPageDescriptionBinary = async (
workspaceSlug,
projectId,
pageId,
cookie
cookie,
);
if (binary) {
return binary;
@@ -100,7 +106,7 @@ export const fetchPageDescriptionBinary = async (
return binaryData;
} catch (error) {
console.error("Fetch error:", error);
manualLogger.error("Fetch error:", error);
throw error;
}
};
+68 -4
View File
@@ -3,12 +3,14 @@ import "@/core/config/sentry-config.js";
import express from "express";
import expressWs from "express-ws";
import * as Sentry from "@sentry/node";
import compression from "compression";
import helmet from "helmet";
// cors
import cors from "cors";
// core hocuspocus server
import { HocusPocusServer } from "@/core/hocuspocus-server.js";
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { logger, manualLogger } from "@/core/helpers/logger.js";
@@ -19,6 +21,17 @@ expressWs(app);
app.set("port", process.env.PORT || 3000);
// Security middleware
app.use(helmet());
// Middleware for response compression
app.use(
compression({
level: 6,
threshold: 5 * 1000,
}),
);
// Logging middleware
app.use(logger);
@@ -31,17 +44,27 @@ app.use(cors());
const router = express.Router();
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
router.get("/health", (_req, res) => {
res.status(200).json({ status: "OK" });
});
router.ws("/collaboration", (ws, req) => {
HocusPocusServer.handleConnection(ws, req);
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
ws.close();
}
});
app.use(process.env.LIVE_BASE_PATH || "/live", router);
app.use((_req, res, _next) => {
app.use((_req, res) => {
res.status(404).send("Not Found");
});
@@ -49,6 +72,47 @@ Sentry.setupExpressErrorHandler(app);
app.use(errorHandler);
app.listen(app.get("port"), () => {
const liveServer = app.listen(app.get("port"), () => {
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
});
const gracefulShutdown = async () => {
manualLogger.info("Starting graceful shutdown...");
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
);
// Close the Express server
liveServer.close(() => {
manualLogger.info("Express server closed gracefully.");
process.exit(1);
});
} catch (err) {
manualLogger.error("Error during shutdown:", err);
process.exit(1);
}
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
manualLogger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
gracefulShutdown();
});
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});
+3 -2
View File
@@ -14,7 +14,8 @@
"packages/tsconfig",
"packages/ui",
"packages/types",
"packages/constants"
"packages/constants",
"packages/helpers"
],
"scripts": {
"build": "turbo run build",
@@ -35,7 +36,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^2.0.14"
"turbo": "^2.1.1"
},
"resolutions": {
"@types/react": "18.2.48"
+1 -1
View File
@@ -79,7 +79,7 @@
"tailwind-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "4.9.5"
"typescript": "5.4.5"
},
"keywords": [
"editor",
@@ -4,20 +4,17 @@ import { SlashCommand } from "@/extensions";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
import { TExtensions, TUserDetails } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
fileHandler: TFileHandler;
issueEmbedConfig: TIssueEmbedConfig | undefined;
provider: HocuspocusProvider;
userDetails: TUserDetails;
};
export const DocumentEditorAdditionalExtensions = (props: Props) => {
const { fileHandler } = props;
const extensions: Extensions = [SlashCommand(fileHandler.upload)];
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const extensions: Extensions = [SlashCommand()];
return extensions;
};
@@ -3,14 +3,14 @@ import React from "react";
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { IssueWidget } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// plane editor types
import { TEmbedConfig } from "@/plane-editor/types";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// types
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
import { IssueWidget } from "@/extensions";
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
const {
@@ -1,7 +1,5 @@
import { FC, ReactNode } from "react";
import { Editor, EditorContent } from "@tiptap/react";
// extensions
import { ImageResizer } from "@/extensions/image";
interface EditorContentProps {
children?: ReactNode;
@@ -16,7 +14,6 @@ export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
return (
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} id={id} />}
{children}
</div>
);
@@ -8,10 +8,10 @@ import { SideMenuExtension, SlashCommand } from "@/extensions";
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const { dragDropEnabled, fileHandler } = props;
const { dragDropEnabled } = props;
const getExtensions = useCallback(() => {
const extensions = [SlashCommand(fileHandler.upload)];
const extensions = [SlashCommand()];
extensions.push(
SideMenuExtension({
@@ -21,7 +21,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
);
return extensions;
}, [dragDropEnabled, fileHandler.upload]);
}, [dragDropEnabled]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
@@ -101,7 +101,8 @@ export const BlockMenu = (props: BlockMenuProps) => {
icon: Copy,
key: "duplicate",
label: "Duplicate",
isDisabled: editor.state.selection.content().content.firstChild?.type.name === "image",
isDisabled:
editor.state.selection.content().content.firstChild?.type.name === "image" || editor.isActive("imageComponent"),
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
@@ -23,7 +23,6 @@ import {
} from "lucide-react";
// helpers
import {
insertImageCommand,
insertTableCommand,
setText,
toggleBlockquote,
@@ -43,7 +42,7 @@ import {
toggleUnderline,
} from "@/helpers/editor-commands";
// types
import { TEditorCommands, UploadImage } from "@/types";
import { TEditorCommands } from "@/types";
export interface EditorMenuItem {
key: TEditorCommands;
@@ -189,16 +188,17 @@ export const TableItem = (editor: Editor): EditorMenuItem => ({
icon: TableIcon,
});
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
export const ImageItem = (editor: Editor) =>
({
key: "image",
name: "Image",
isActive: () => editor?.isActive("image"),
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
command: (savedSelection: Selection | null) =>
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
icon: ImageIcon,
}) as const;
export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImage) {
export function getEditorMenuItems(editor: Editor | null) {
if (!editor) {
return [];
}
@@ -220,6 +220,6 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
NumberedListItem(editor),
QuoteItem(editor),
TableItem(editor),
ImageItem(editor, uploadFile),
ImageItem(editor),
];
}
@@ -11,6 +11,7 @@ import { CustomCodeInlineExtension } from "./code-inline";
import { CustomLinkExtension } from "./custom-link";
import { CustomHorizontalRule } from "./horizontal-rule";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
import { CustomQuoteExtension } from "./quote";
@@ -61,6 +62,7 @@ export const CoreEditorExtensionsWithoutProps = [
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps(),
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -0,0 +1,128 @@
import React, { useRef, useState, useCallback, useLayoutEffect } from "react";
import { NodeSelection } from "@tiptap/pm/state";
// extensions
import { CustomImageNodeViewProps } from "@/extensions/custom-image";
// helpers
import { cn } from "@/helpers/common";
const MIN_SIZE = 100;
export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
const { node, updateAttributes, selected, getPos, editor } = props;
const { src, width, height } = node.attrs;
const [size, setSize] = useState({ width: width || "35%", height: height || "auto" });
const [isLoading, setIsLoading] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const isResizing = useRef(false);
const aspectRatio = useRef(1);
useLayoutEffect(() => {
if (imageRef.current) {
const img = imageRef.current;
img.onload = () => {
if (node.attrs.width === "35%" && node.attrs.height === "auto") {
aspectRatio.current = img.naturalWidth / img.naturalHeight;
const initialWidth = Math.max(img.naturalWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatio.current;
setSize({ width: `${initialWidth}px`, height: `${initialHeight}px` });
}
setIsLoading(false);
};
}
}, [src]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
isResizing.current = true;
if (containerRef.current) {
containerRect.current = containerRef.current.getBoundingClientRect();
}
}, []);
useLayoutEffect(() => {
// for realtime resizing and undo/redo
setSize({ width, height });
}, [width, height]);
const handleResize = useCallback((e: MouseEvent | TouchEvent) => {
if (!isResizing.current || !containerRef.current || !containerRect.current) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
const newHeight = newWidth / aspectRatio.current;
setSize({ width: `${newWidth}px`, height: `${newHeight}px` });
}, []);
const handleResizeEnd = useCallback(() => {
if (isResizing.current) {
isResizing.current = false;
updateAttributes(size);
}
}, [size, updateAttributes]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const pos = getPos();
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));
},
[editor, getPos]
);
useLayoutEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => handleResize(e);
const handleGlobalMouseUp = () => handleResizeEnd();
document.addEventListener("mousemove", handleGlobalMouseMove);
document.addEventListener("mouseup", handleGlobalMouseUp);
return () => {
document.removeEventListener("mousemove", handleGlobalMouseMove);
document.removeEventListener("mouseup", handleGlobalMouseUp);
};
}, [handleResize, handleResizeEnd]);
return (
<div
ref={containerRef}
className="group/image-component relative inline-block max-w-full"
onMouseDown={handleMouseDown}
style={{
width: size.width,
height: size.height,
}}
>
{isLoading && <div className="animate-pulse bg-custom-background-80 rounded-md" style={{ width, height }} />}
<img
ref={imageRef}
src={src}
className={cn("block rounded-md", {
hidden: isLoading,
"read-only-image": !editor.isEditable,
})}
style={{
width: size.width,
height: size.height,
}}
/>
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
{editor.isEditable && (
<>
<div className="opacity-0 group-hover/image-component:opacity-100 absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out" />
<div
className="opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out"
onMouseDown={handleResizeStart}
/>
</>
)}
</div>
);
};
@@ -0,0 +1,122 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Editor, NodeViewWrapper } from "@tiptap/react";
// extensions
import {
CustomImageBlock,
CustomImageUploader,
UploadEntity,
UploadImageExtensionStorage,
} from "@/extensions/custom-image";
export type CustomImageNodeViewProps = {
getPos: () => number;
editor: Editor;
node: ProsemirrorNode & {
attrs: {
src: string;
width: string;
height: string;
};
};
updateAttributes: (attrs: Record<string, any>) => void;
selected: boolean;
};
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const [isUploaded, setIsUploaded] = useState(!!node.attrs.src);
const id = node.attrs.id as string;
const editorStorage = editor.storage.imageComponent as UploadImageExtensionStorage | undefined;
const getUploadEntity = useCallback(
(): UploadEntity | undefined => editorStorage?.fileMap.get(id),
[editorStorage, id]
);
const onUpload = useCallback(
(url: string) => {
if (url) {
setIsUploaded(true);
// Update the node view's src attribute
updateAttributes({ src: url });
editorStorage?.fileMap.delete(id);
}
},
[editorStorage?.fileMap, id, updateAttributes]
);
const uploadFile = useCallback(
async (file: File) => {
try {
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(file);
if (!url) {
throw new Error("Something went wrong while uploading the image");
}
onUpload(url);
} catch (error) {
console.error("Error uploading file:", error);
}
},
[editor.commands, onUpload]
);
useEffect(() => {
const uploadEntity = getUploadEntity();
if (uploadEntity) {
if (uploadEntity.event === "drop" && "file" in uploadEntity) {
uploadFile(uploadEntity.file);
} else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
const entity = editorStorage?.fileMap.get(id);
if (entity && entity.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
hasTriggeredFilePickerRef.current = true;
if (!entity) return;
editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true });
}
}
}, [getUploadEntity, uploadFile]);
useEffect(() => {
if (node.attrs.src) {
setIsUploaded(true);
}
}, [node.attrs.src]);
const existingFile = React.useMemo(() => {
const entity = getUploadEntity();
return entity && entity.event === "drop" ? entity.file : undefined;
}, [getUploadEntity]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle>
{isUploaded ? (
<CustomImageBlock
editor={editor}
getPos={getPos}
node={node}
updateAttributes={updateAttributes}
selected={selected}
/>
) : (
<CustomImageUploader
onUpload={onUpload}
editor={editor}
fileInputRef={fileInputRef}
existingFile={existingFile}
selected={selected}
/>
)}
</div>
</NodeViewWrapper>
);
};
@@ -0,0 +1,90 @@
import { ChangeEvent, useCallback, useEffect, useRef } from "react";
import { Editor } from "@tiptap/core";
import { ImageIcon } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
// hooks
import { useUploader, useFileUpload, useDropZone } from "@/hooks/use-file-upload";
// plugins
import { isFileValid } from "@/plugins/image";
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
const assignRef = (ref: RefType, value: HTMLInputElement | null) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<HTMLInputElement | null>).current = value;
}
};
export const CustomImageUploader = (props: {
onUpload: (url: string) => void;
editor: Editor;
fileInputRef: RefType;
existingFile?: File;
selected: boolean;
}) => {
const { selected, onUpload, editor, fileInputRef, existingFile } = props;
const { loading, uploadFile } = useUploader({ onUpload, editor });
const { handleUploadClick, ref: internalRef } = useFileUpload();
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
const localRef = useRef<HTMLInputElement | null>(null);
const onFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (isFileValid(file)) {
uploadFile(file);
}
}
},
[uploadFile]
);
useEffect(() => {
// no need to validate as the file is already validated before the drop onto
// the editor
if (existingFile) {
uploadFile(existingFile);
}
}, [existingFile, uploadFile]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-200": draggedInside,
},
{
"text-custom-primary-200 bg-custom-primary-100/10": selected,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={handleUploadClick}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">
{loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"}
</div>
<input
className="size-0 overflow-hidden"
ref={(element) => {
localRef.current = element;
assignRef(fileInputRef, element);
assignRef(internalRef as RefType, element);
}}
hidden
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
/>
</div>
);
};

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