Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0864354d69 | ||
|
|
69b4f155fc | ||
|
|
8f492e4c6c | ||
|
|
8533eba07d | ||
|
|
edf0ab8175 | ||
|
|
45da70cf6a | ||
|
|
2e816656e5 | ||
|
|
6826ce0465 | ||
|
|
c4b5c737f3 | ||
|
|
89a1c0b534 | ||
|
|
74507559b8 | ||
|
|
3ce84f78f1 | ||
|
|
5ba1eeaf4c | ||
|
|
c14d20c2e0 | ||
|
|
f155a13929 | ||
|
|
485caaf2ec | ||
|
|
b44dd28ac0 | ||
|
|
1b0e31027e | ||
|
|
1efb067274 | ||
|
|
b2533b94ce | ||
|
|
441385fc95 | ||
|
|
5f1939cdeb | ||
|
|
9d694ab006 | ||
|
|
48e97477ed | ||
|
|
33dd5fe8cc | ||
|
|
aed2f2dd47 | ||
|
|
eb84f165f4 | ||
|
|
572644f7f9 | ||
|
|
ddbd9dfdc8 | ||
|
|
09578c9a7d | ||
|
|
e5ddfd322d | ||
|
|
87d6544b72 | ||
|
|
fdcd9a376c | ||
|
|
7013a36629 | ||
|
|
bb49d27a84 | ||
|
|
00b76300f5 | ||
|
|
71f3c5c12a | ||
|
|
99ab274216 | ||
|
|
04b10cabc8 | ||
|
|
545717cc51 | ||
|
|
1ca0a15792 | ||
|
|
c5971f03aa | ||
|
|
902403a54d | ||
|
|
1d6ebb7c41 | ||
|
|
106914e14e | ||
|
|
8acb60baef | ||
|
|
1da97d5814 | ||
|
|
5fb2dd0b6e | ||
|
|
ff6c3ce1a0 | ||
|
|
ec51e9d8ce | ||
|
|
cc07992e47 | ||
|
|
069f8b950e | ||
|
|
5eb868e07d | ||
|
|
7c77fc1680 | ||
|
|
99a7867a5e | ||
|
|
c44bf861e0 | ||
|
|
4d38a10f8b | ||
|
|
7c3fc690e9 | ||
|
|
8cf1c2d136 | ||
|
|
fe280b2beb | ||
|
|
ad5c6ee4f5 | ||
|
|
ba0d1ba518 | ||
|
|
70ea1459cd | ||
|
|
8154a190d2 | ||
|
|
29fd1186ee | ||
|
|
68b412badf | ||
|
|
c95aa6a0f7 | ||
|
|
751cd6c862 | ||
|
|
1032bc75d7 | ||
|
|
9415a5ba00 | ||
|
|
d24a4e18a2 | ||
|
|
52f78a86af | ||
|
|
c84c37805c | ||
|
|
c2758caf95 |
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./upgrade-button";
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -18,6 +18,7 @@ export const AdminLayout: FC<TAdminLayout> = observer((props) => {
|
||||
const { children } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const { isUserLoggedIn } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "ce/components/common";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "ce/store/root.store";
|
||||
Vendored
+1
-1
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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__"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -176,6 +176,7 @@ class UserAdminLiteSerializer(BaseSerializer):
|
||||
"is_bot",
|
||||
"display_name",
|
||||
"email",
|
||||
"last_login_medium",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
|
||||
@@ -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."}
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
+192
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 @@
|
||||
python-3.12.3
|
||||
python-3.12.6
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user