Compare commits

..
Author SHA1 Message Date
Jayash Tripathy b4e7dcda41 chore: removed eslint config 2026-03-06 14:43:47 +05:30
Jayash Tripathy 5017534e5c fix: format 2026-03-06 14:19:25 +05:30
Jayash Tripathy 6e5031196b Merge branch 'preview' of https://github.com/makeplane/plane into refactor/tabs-implementation 2026-03-06 14:18:29 +05:30
Jayash Tripathy bcfefea323 refactor: update Tabs component usage across multiple files to use new structure with TabsList, TabsTrigger, and TabsContent 2025-12-31 17:40:16 +05:30
Jayash Tripathy 3927fbd0c7 Merge branch 'preview' of https://github.com/makeplane/plane into refactor/tabs-implementation 2025-12-31 17:36:22 +05:30
Jayash Tripathy 74320c1062 Merge branch 'preview' of https://github.com/makeplane/plane into refactor/tabs-implementation 2025-12-30 13:53:22 +05:30
Jayash Tripathy 2acba2980b refactor: migrate from Headless UI Tabs to custom Tabs component
- Replaced instances of Headless UI's Tab component with a new custom Tabs component across various components, including analytics modals, image pickers, and navigation panes.
- Updated tab handling logic to align with the new Tabs API, ensuring consistent behavior and styling.
- Removed unused Tab imports and cleaned up related code for improved maintainability.
- This refactor enhances the overall structure and consistency of tab navigation within the application.
2025-12-03 17:37:45 +05:30
448 changed files with 2771 additions and 3351 deletions
+7
View File
@@ -0,0 +1,7 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,*.svg,i18n,*-lock.yaml,*.css,.codespellrc,migrations,*.js,*.map,*.mjs
check-hidden = true
# ignore all CamelCase and camelCase
ignore-regex = \b[A-Za-z][a-z]+[A-Z][a-zA-Z]+\b
ignore-words-list = tread
+55
View File
@@ -0,0 +1,55 @@
version: 2
updates:
# JavaScript/TypeScript dependencies (pnpm monorepo root)
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "javascript"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
# Python dependencies
- package-ecosystem: "pip"
directory: "/apps/api"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "python"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "github-actions"
groups:
actions:
patterns:
- "*"
# Docker - API
- package-ecosystem: "docker"
directory: "/apps/api"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "docker"
+14 -15
View File
@@ -134,7 +134,7 @@ jobs:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v6
uses: actions/checkout@v4
branch_build_push_admin:
name: Build-Push Admin Docker Image
@@ -142,7 +142,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Admin Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -164,7 +164,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Web Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -186,7 +186,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Space Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -208,7 +208,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Live Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -230,7 +230,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Backend Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -252,7 +252,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Proxy Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -282,7 +282,7 @@ jobs:
- branch_build_push_proxy
steps:
- name: Checkout Files
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Prepare AIO Assets
id: prepare_aio_assets
@@ -298,13 +298,13 @@ jobs:
echo "AIO_BUILD_VERSION=${aio_version}" >> $GITHUB_OUTPUT
- name: Upload AIO Assets
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
path: ./deployments/aio/community/dist
name: aio-assets-dist
- name: AIO Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.1.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -337,7 +337,7 @@ jobs:
- branch_build_push_proxy
steps:
- name: Checkout Files
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Update Assets
run: |
@@ -352,7 +352,7 @@ jobs:
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env
- name: Upload Assets
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: community-assets
path: |
@@ -381,7 +381,7 @@ jobs:
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Update Assets
run: |
@@ -391,13 +391,12 @@ jobs:
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v2.1.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ env.REL_VERSION }}
name: ${{ env.REL_VERSION }}
target_commitish: ${{ github.sha }}
draft: false
prerelease: ${{ env.IS_PRERELEASE }}
generate_release_notes: true
+2 -2
View File
@@ -10,13 +10,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
- name: Get PR Branch version
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
+25
View File
@@ -0,0 +1,25 @@
# Codespell configuration is within .codespellrc
---
name: Codespell
on:
push:
branches: [preview]
pull_request:
branches: [preview]
permissions:
contents: read
jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@v1
- name: Codespell
uses: codespell-project/actions-codespell@v2
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: "1.22"
+6 -6
View File
@@ -48,7 +48,7 @@ jobs:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v6
uses: actions/checkout@v4
full_build_push:
runs-on: ubuntu-22.04
@@ -63,23 +63,23 @@ jobs:
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v7.0.0
uses: docker/build-push-action@v6.9.0
with:
context: .
file: ./aio/Dockerfile-app
@@ -112,7 +112,7 @@ jobs:
sudo apt-get install -y python3-pip
pip3 install awscli
- name: Tailscale
uses: tailscale/github-action@v4
uses: tailscale/github-action@v2
with:
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
@@ -8,6 +8,8 @@ on:
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
concurrency:
@@ -44,7 +46,7 @@ jobs:
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -87,7 +89,7 @@ jobs:
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -95,7 +97,7 @@ jobs:
pnpm-store-${{ runner.os }}-
- name: Restore Turbo cache
uses: actions/cache/restore@v5
uses: actions/cache/restore@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
@@ -110,7 +112,7 @@ jobs:
run: pnpm turbo run build --affected
- name: Save Turbo cache
uses: actions/cache/save@v5
uses: actions/cache/save@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
@@ -144,7 +146,7 @@ jobs:
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -185,7 +187,7 @@ jobs:
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -193,7 +195,7 @@ jobs:
pnpm-store-${{ runner.os }}-
- name: Restore Turbo cache
uses: actions/cache/restore@v5
uses: actions/cache/restore@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
+52
View File
@@ -0,0 +1,52 @@
name: Create PR on Sync
on:
workflow_dispatch:
push:
branches:
- "sync/**"
env:
CURRENT_BRANCH: ${{ github.ref_name }}
TARGET_BRANCH: "preview" # 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
ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }}
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
jobs:
create_pull_request:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for all branches and tags
- name: Setup Git
run: |
git config user.name "$ACCOUNT_USER_NAME"
git config user.email "$ACCOUNT_USER_EMAIL"
- name: Setup GH CLI and Git Config
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
- 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 $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 $CURRENT_BRANCH --title "${{ vars.SYNC_PR_TITLE }}" --body "")
echo "Pull Request created: $PR_URL"
fi
+44
View File
@@ -0,0 +1,44 @@
name: Sync Repositories
on:
workflow_dispatch:
push:
branches:
- preview
env:
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
jobs:
sync_changes:
runs-on: ubuntu-22.04
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Setup GH CLI
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
- name: Push Changes to Target Repo
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
+1 -1
View File
@@ -10,7 +10,7 @@
<p align="center">
<a href="https://plane.so/"><b>Website</b></a> •
<a href="https://forum.plane.so"><b>Forum</b></a> •
<a href="https://x.com/planepowers"><b>X</b></a> •
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
<a href="https://docs.plane.so/"><b>Documentation</b></a>
</p>
+1 -1
View File
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
FROM base AS builder
RUN pnpm add -g turbo@2.9.4
RUN pnpm add -g turbo@2.8.12
COPY . .
@@ -16,6 +16,8 @@ import { Input, ToggleSwitch } from "@plane/ui";
import { ControllerInput } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
// components
import { IntercomConfig } from "./intercom";
export interface IGeneralConfigurationForm {
instance: IInstance;
@@ -25,13 +27,14 @@ export interface IGeneralConfigurationForm {
export const GeneralConfigurationForm = observer(function GeneralConfigurationForm(props: IGeneralConfigurationForm) {
const { instance, instanceAdmins } = props;
// hooks
const { updateInstanceInfo } = useInstance();
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
watch,
} = useForm<Partial<IInstance>>({
defaultValues: {
instance_name: instance?.instance_name,
@@ -42,6 +45,17 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
const onSubmit = async (formData: Partial<IInstance>) => {
const payload: Partial<IInstance> = { ...formData };
// update the intercom configuration
const isIntercomEnabled =
instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1";
if (!payload.is_telemetry_enabled && isIntercomEnabled) {
try {
await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" });
} catch (error) {
console.error(error);
}
}
await updateInstanceInfo(payload)
.then(() =>
setToast({
@@ -98,7 +112,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
</div>
<div className="space-y-6">
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Telemetry</div>
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Chat + telemetry</div>
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="flex items-center gap-14">
<div className="flex grow items-center gap-4">
<div className="shrink-0">
@@ -0,0 +1,86 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { MessageSquare } from "lucide-react";
import type { IFormattedInstanceConfiguration } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
type TIntercomConfig = {
isTelemetryEnabled: boolean;
};
export const IntercomConfig = observer(function IntercomConfig(props: TIntercomConfig) {
const { isTelemetryEnabled } = props;
// hooks
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// derived values
const isIntercomEnabled = isTelemetryEnabled
? instanceConfigurations
? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"
? true
: false
: undefined
: false;
const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () =>
isTelemetryEnabled ? fetchInstanceConfigurations() : null
);
const initialLoader = isLoading && isIntercomEnabled === undefined;
const submitInstanceConfigurations = async (payload: Partial<IFormattedInstanceConfiguration>) => {
try {
await updateInstanceConfigurations(payload);
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const enableIntercomConfig = () => {
void submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
};
return (
<>
<div className="flex items-center gap-14">
<div className="flex grow items-center gap-4">
<div className="shrink-0">
<div className="flex size-11 items-center justify-center rounded-lg bg-layer-1">
<MessageSquare className="size-5 p-0.5 text-tertiary" />
</div>
</div>
<div className="grow">
<div className="text-13 leading-5 font-medium text-primary">Chat with us</div>
<div className="text-11 leading-5 font-regular text-tertiary">
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off
automatically.
</div>
</div>
<div className="ml-auto">
<ToggleSwitch
value={isIntercomEnabled ? true : false}
onChange={enableIntercomConfig}
size="sm"
disabled={!isTelemetryEnabled || isSubmitting || initialLoader}
/>
</div>
</div>
</div>
</>
);
});
+1 -1
View File
@@ -88,7 +88,7 @@ export function HydrateFallback() {
);
}
export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) {
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div>
<p>Something went wrong.</p>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "1.3.0",
"version": "1.2.0",
"private": true,
"description": "Admin UI for Plane",
"license": "AGPL-3.0",
@@ -49,6 +49,7 @@
"uuid": "catalog:"
},
"devDependencies": {
"@dotenvx/dotenvx": "catalog:",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@react-router/dev": "catalog:",
@@ -56,7 +57,6 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"dotenv": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "^5.1.4"
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import * as dotenv from "dotenv";
import * as dotenv from "@dotenvx/dotenvx";
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "plane-api",
"version": "1.3.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend",
"license": "AGPL-3.0"
"description": "API server powering Plane's backend"
}
@@ -1 +0,0 @@
from .workspace import WorkspaceAdminOnlyPermission, WorkspaceAdminWriteMemberReadPermission
@@ -1,54 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from rest_framework.permissions import BasePermission, SAFE_METHODS
from plane.db.models import WorkspaceMember
Admin = 20
Member = 15
class WorkspaceAdminOnlyPermission(BasePermission):
"""
Permission class for external APIs that restricts access to workspace admins only.
"""
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role=Admin,
is_active=True,
).exists()
class WorkspaceAdminWriteMemberReadPermission(BasePermission):
"""
Permission class for external APIs that allows workspace members to read
but restricts write operations to workspace admins only.
"""
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
if request.method in SAFE_METHODS:
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role__in=[Admin, Member],
is_active=True,
).exists()
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role=Admin,
is_active=True,
).exists()
+1 -5
View File
@@ -25,10 +25,6 @@ from .issue import (
IssueCommentCreateSerializer,
IssueLinkCreateSerializer,
IssueLinkUpdateSerializer,
IssueRelationCreateSerializer,
IssueRelationResponseSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
)
from .state import StateLiteSerializer, StateSerializer
from .cycle import (
@@ -53,7 +49,7 @@ from .intake import (
IntakeIssueCreateSerializer,
IntakeIssueUpdateSerializer,
)
from .estimate import EstimateSerializer, EstimatePointSerializer
from .estimate import EstimatePointSerializer
from .asset import (
UserAssetUploadSerializer,
AssetUpdateSerializer,
+9 -25
View File
@@ -2,36 +2,20 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third party imports
from rest_framework import serializers
# Module imports
from plane.db.models import Estimate, EstimatePoint
from plane.db.models import EstimatePoint
from .base import BaseSerializer
class EstimateSerializer(BaseSerializer):
class Meta:
model = Estimate
fields = "__all__"
read_only_fields = ["workspace", "project", "deleted_at"]
def create(self, validated_data):
validated_data["workspace"] = self.context["workspace"]
validated_data["project"] = self.context["project"]
return super().create(validated_data)
class EstimatePointSerializer(BaseSerializer):
def validate(self, data):
if not data:
raise serializers.ValidationError("Estimate points are required")
value = data.get("value")
if value and len(value) > 20:
raise serializers.ValidationError("Value can't be more than 20 characters")
return data
"""
Serializer for project estimation points and story point values.
Handles numeric estimation data for work item sizing and sprint planning,
providing standardized point values for project velocity calculations.
"""
class Meta:
model = EstimatePoint
fields = "__all__"
read_only_fields = ["estimate", "workspace", "project"]
fields = ["id", "value"]
read_only_fields = fields
-179
View File
@@ -20,7 +20,6 @@ from plane.db.models import (
IssueComment,
IssueLabel,
IssueLink,
IssueRelation,
Label,
ProjectMember,
State,
@@ -480,184 +479,6 @@ class IssueLinkSerializer(BaseSerializer):
]
class IssueRelationResponseSerializer(serializers.Serializer):
"""
Serializer for issue relations response showing grouped relation types.
Returns issue IDs organized by relation type for efficient client-side processing.
"""
blocking = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that are blocking this issue",
)
blocked_by = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that this issue is blocked by",
)
duplicate = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that are duplicates of this issue",
)
relates_to = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that relate to this issue",
)
start_after = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that start after this issue",
)
start_before = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that start before this issue",
)
finish_after = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that finish after this issue",
)
finish_before = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that finish before this issue",
)
class IssueRelationCreateSerializer(serializers.Serializer):
"""
Serializer for creating issue relations.
Creates issue relations with the specified relation type and issues.
Validates relation types and ensures proper issue ID format.
"""
RELATION_TYPE_CHOICES = [
("blocking", "Blocking"),
("blocked_by", "Blocked By"),
("duplicate", "Duplicate"),
("relates_to", "Relates To"),
("start_before", "Start Before"),
("start_after", "Start After"),
("finish_before", "Finish Before"),
("finish_after", "Finish After"),
]
relation_type = serializers.ChoiceField(
choices=RELATION_TYPE_CHOICES,
required=True,
help_text="Type of relationship between work items",
)
issues = serializers.ListField(
child=serializers.UUIDField(),
required=True,
min_length=1,
help_text="Array of work item IDs to create relations with",
)
def validate_issues(self, value):
"""Validate that issues list is not empty and contains valid UUIDs."""
if not value:
raise serializers.ValidationError("At least one issue ID is required.")
return value
class IssueRelationRemoveSerializer(serializers.Serializer):
"""
Serializer for removing issue relations.
Removes existing relationships between work items by specifying
the related issue ID.
"""
related_issue = serializers.UUIDField(
required=True, help_text="ID of the related work item to remove relation with"
)
class IssueRelationSerializer(BaseSerializer):
"""
Serializer for issue relationships showing related issue details.
Provides comprehensive information about related issues including
project context, sequence ID, and relationship type.
"""
id = serializers.UUIDField(source="related_issue.id", read_only=True)
project_id = serializers.UUIDField(source="related_issue.project_id", read_only=True)
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
name = serializers.CharField(source="related_issue.name", read_only=True)
relation_type = serializers.CharField(read_only=True)
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
priority = serializers.CharField(source="related_issue.priority", read_only=True)
class Meta:
model = IssueRelation
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
"name",
"state_id",
"priority",
"created_by",
"created_at",
"updated_at",
"updated_by",
]
read_only_fields = [
"workspace",
"project",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
class RelatedIssueSerializer(BaseSerializer):
"""
Serializer for reverse issue relationships showing issue details.
Provides comprehensive information about the source issue in a relationship
including project context, sequence ID, and relationship type.
"""
id = serializers.UUIDField(source="issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
name = serializers.CharField(source="issue.name", read_only=True)
type_id = serializers.UUIDField(source="issue.type.id", read_only=True)
relation_type = serializers.CharField(read_only=True)
is_epic = serializers.BooleanField(source="issue.type.is_epic", read_only=True)
state_id = serializers.UUIDField(source="issue.state.id", read_only=True)
priority = serializers.CharField(source="issue.priority", read_only=True)
class Meta:
model = IssueRelation
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
"name",
"type_id",
"is_epic",
"state_id",
"priority",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
read_only_fields = [
"workspace",
"project",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
class IssueAttachmentSerializer(BaseSerializer):
"""
Serializer for work item file attachments.
-29
View File
@@ -1,29 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.api.views.estimate import (
ProjectEstimateAPIEndpoint,
EstimatePointListCreateAPIEndpoint,
EstimatePointDetailAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/",
ProjectEstimateAPIEndpoint.as_view(http_method_names=["get", "post", "patch", "delete"]),
name="project-estimate",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/",
EstimatePointListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="estimate-point-list-create",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/<uuid:estimate_point_id>/",
EstimatePointDetailAPIEndpoint.as_view(http_method_names=["patch", "delete"]),
name="estimate-point-detail",
),
]
-6
View File
@@ -17,7 +17,6 @@ from plane.api.views import (
IssueAttachmentDetailAPIEndpoint,
WorkspaceIssueAPIEndpoint,
IssueSearchEndpoint,
IssueRelationListCreateAPIEndpoint,
)
# Deprecated url patterns
@@ -146,11 +145,6 @@ new_url_patterns = [
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="work-item-attachment-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/relations/",
IssueRelationListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="work-item-relation-list",
),
]
urlpatterns = old_url_patterns + new_url_patterns
-1
View File
@@ -29,7 +29,6 @@ from .issue import (
IssueAttachmentListCreateAPIEndpoint,
IssueAttachmentDetailAPIEndpoint,
IssueSearchEndpoint,
IssueRelationListCreateAPIEndpoint,
)
from .cycle import (
-291
View File
@@ -1,291 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
# Module imports
from plane.app.permissions.project import ProjectEntityPermission
from plane.api.views.base import BaseAPIView
from plane.db.models import Estimate, EstimatePoint, Project, Workspace
from plane.api.serializers import EstimateSerializer, EstimatePointSerializer
from plane.utils.openapi.decorators import estimate_docs, estimate_point_docs
from plane.utils.openapi import (
ESTIMATE_CREATE_EXAMPLE,
ESTIMATE_UPDATE_EXAMPLE,
ESTIMATE_POINT_CREATE_EXAMPLE,
ESTIMATE_POINT_UPDATE_EXAMPLE,
ESTIMATE_EXAMPLE,
ESTIMATE_POINT_EXAMPLE,
DELETED_RESPONSE,
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ESTIMATE_ID_PARAMETER,
)
class ProjectEstimateAPIEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission]
model = Estimate
serializer_class = EstimateSerializer
def get_queryset(self):
return self.model.objects.filter(workspace__slug=self.workspace_slug, project_id=self.project_id)
@estimate_docs(
operation_id="create_estimate",
summary="Create an estimate",
description="Create an estimate for a project",
request=OpenApiRequest(
request=EstimateSerializer,
examples=[ESTIMATE_CREATE_EXAMPLE],
),
)
def post(self, request, slug, project_id):
project = Project.objects.filter(id=project_id, workspace__slug=slug).first()
if not project:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Project not found"})
workspace = Workspace.objects.filter(slug=slug).first()
if not workspace:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Workspace not found"})
project_estimate = self.get_queryset().first()
if project_estimate:
# return 409 if the project estimate already exists
return Response(
status=status.HTTP_409_CONFLICT,
data={"error": "An estimate already exists for this project", "id": str(project_estimate.id)},
)
# create the project estimate
serializer = self.serializer_class(data=request.data, context={"workspace": workspace, "project": project})
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@estimate_docs(
operation_id="get_estimate",
summary="Get an estimate",
description="Get an estimate for a project",
responses={
200: OpenApiResponse(
description="Estimate",
response=EstimateSerializer,
examples=[ESTIMATE_EXAMPLE],
),
},
)
def get(self, request, slug, project_id):
estimate = self.get_queryset().first()
if not estimate:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
serializer = self.serializer_class(estimate)
return Response(serializer.data, status=status.HTTP_200_OK)
@estimate_docs(
operation_id="update_estimate",
summary="Update an estimate",
description="Update an estimate for a project",
request=OpenApiRequest(
request=EstimateSerializer,
examples=[ESTIMATE_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Estimate",
response=EstimateSerializer,
examples=[ESTIMATE_EXAMPLE],
),
},
)
def patch(self, request, slug, project_id):
ALLOWED_FIELDS = ["name", "description"]
estimate = self.get_queryset().first()
if not estimate:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
filtered_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS}
if not filtered_data:
serializer = self.serializer_class(estimate)
return Response(serializer.data, status=status.HTTP_200_OK)
serializer = self.serializer_class(estimate, data=filtered_data, partial=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@estimate_docs(
operation_id="delete_estimate",
summary="Delete an estimate",
description="Delete an estimate for a project",
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, project_id):
estimate = self.get_queryset().first()
if not estimate:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
estimate.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class EstimatePointListCreateAPIEndpoint(BaseAPIView):
"""List and bulk create estimate points for an estimate."""
permission_classes = [ProjectEntityPermission]
model = EstimatePoint
serializer_class = EstimatePointSerializer
def get_queryset(self):
return self.model.objects.filter(
estimate_id=self.kwargs["estimate_id"],
workspace__slug=self.kwargs["slug"],
project_id=self.kwargs["project_id"],
).select_related("estimate", "workspace", "project")
@estimate_point_docs(
operation_id="get_estimate_points",
summary="Get estimate points",
description="Get estimate points for an estimate",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ESTIMATE_ID_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Estimate points",
response=EstimatePointSerializer(many=True),
examples=[ESTIMATE_POINT_EXAMPLE],
),
},
)
def get(self, request, slug, project_id, estimate_id):
estimate = Estimate.objects.filter(
id=estimate_id,
workspace__slug=slug,
project_id=project_id,
).first()
if not estimate:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
estimate_points = self.get_queryset()
serializer = self.serializer_class(estimate_points, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@estimate_point_docs(
operation_id="create_estimate_points",
summary="Create estimate points",
description="Create estimate points for an estimate",
request=OpenApiRequest(
request=EstimatePointSerializer,
examples=[ESTIMATE_POINT_CREATE_EXAMPLE],
),
responses={
201: OpenApiResponse(
description="Estimate points",
response=EstimatePointSerializer(many=True),
examples=[ESTIMATE_POINT_EXAMPLE],
),
},
)
def post(self, request, slug, project_id, estimate_id):
estimate = Estimate.objects.filter(
id=estimate_id,
workspace__slug=slug,
project_id=project_id,
).first()
if not estimate:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
estimate_points_data = (
request.data if isinstance(request.data, list) else request.data.get("estimate_points", [])
)
if not estimate_points_data:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"error": "Estimate points are required"},
)
serializer = self.serializer_class(data=estimate_points_data, many=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
estimate_points = [
EstimatePoint(
estimate=estimate,
workspace=estimate.workspace,
project=estimate.project,
**item,
)
for item in serializer.validated_data
]
created = EstimatePoint.objects.bulk_create(estimate_points)
return Response(
self.serializer_class(created, many=True).data,
status=status.HTTP_201_CREATED,
)
class EstimatePointDetailAPIEndpoint(BaseAPIView):
"""Update and delete a single estimate point."""
permission_classes = [ProjectEntityPermission]
model = EstimatePoint
serializer_class = EstimatePointSerializer
def get_queryset(self):
return self.model.objects.filter(
estimate_id=self.kwargs["estimate_id"],
workspace__slug=self.kwargs["slug"],
project_id=self.kwargs["project_id"],
)
@estimate_point_docs(
operation_id="update_estimate_point",
summary="Update an estimate point",
description="Update an estimate point for an estimate",
request=OpenApiRequest(
request=EstimatePointSerializer,
examples=[ESTIMATE_POINT_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Estimate point",
response=EstimatePointSerializer,
examples=[ESTIMATE_POINT_EXAMPLE],
),
},
)
def patch(self, request, slug, project_id, estimate_id, estimate_point_id):
estimate_point = self.get_queryset().filter(id=estimate_point_id).first()
if not estimate_point:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate point not found"})
ALLOWED_FIELDS = ["key", "value", "description"]
filtered_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS}
if not filtered_data:
return Response(self.serializer_class(estimate_point).data, status=status.HTTP_200_OK)
serializer = self.serializer_class(estimate_point, data=filtered_data, partial=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@estimate_point_docs(
operation_id="delete_estimate_point",
summary="Delete an estimate point",
description="Delete an estimate point for an estimate",
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, project_id, estimate_id, estimate_point_id):
estimate_point = self.get_queryset().filter(id=estimate_point_id).first()
if not estimate_point:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate point not found"})
estimate_point.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+12 -274
View File
@@ -23,11 +23,7 @@ from django.db.models import (
Value,
When,
Subquery,
UUIDField,
)
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.utils import timezone
from django.conf import settings
@@ -49,9 +45,6 @@ from plane.api.serializers import (
IssueActivitySerializer,
IssueCommentSerializer,
IssueLinkSerializer,
IssueRelationCreateSerializer,
IssueRelationResponseSerializer,
IssueRelationSerializer,
IssueSerializer,
LabelSerializer,
IssueAttachmentUploadSerializer,
@@ -60,7 +53,6 @@ from plane.api.serializers import (
IssueLinkCreateSerializer,
IssueLinkUpdateSerializer,
LabelCreateUpdateSerializer,
RelatedIssueSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
@@ -74,7 +66,6 @@ from plane.db.models import (
FileAsset,
IssueComment,
IssueLink,
IssueRelation,
Label,
Project,
ProjectMember,
@@ -85,12 +76,10 @@ from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.utils.issue_relation_mapper import get_actual_relation
from plane.bgtasks.webhook_task import model_activity
from plane.app.permissions import ROLE
from plane.utils.openapi import (
work_item_docs,
work_item_relation_docs,
label_docs,
issue_link_docs,
issue_comment_docs,
@@ -640,16 +629,6 @@ class IssueDetailAPIEndpoint(BaseAPIView):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
# Send the model activity for webhook dispatch
model_activity.delay(
model_name="issue",
model_id=str(issue.id),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
# If the serializer is not valid, respond with 400 bad
@@ -698,16 +677,6 @@ class IssueDetailAPIEndpoint(BaseAPIView):
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# Send the model activity for webhook dispatch
model_activity.delay(
model_name="issue",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
@@ -783,16 +752,6 @@ class IssueDetailAPIEndpoint(BaseAPIView):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
# Send the model activity for webhook dispatch
model_activity.delay(
model_name="issue",
model_id=str(pk),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1130,9 +1089,9 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: (
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links, many=True, fields=self.fields, expand=self.expand
).data,
)
@issue_link_docs(
@@ -1237,9 +1196,9 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: (
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links, many=True, fields=self.fields, expand=self.expand
).data,
)
issue_link = self.get_queryset().get(pk=pk)
serializer = IssueLinkSerializer(issue_link, fields=self.fields, expand=self.expand)
@@ -1388,9 +1347,9 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_comments: (
IssueCommentSerializer(issue_comments, many=True, fields=self.fields, expand=self.expand).data
),
on_results=lambda issue_comments: IssueCommentSerializer(
issue_comments, many=True, fields=self.fields, expand=self.expand
).data,
)
@issue_comment_docs(
@@ -1699,9 +1658,9 @@ class IssueActivityListAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(issue_activities),
on_results=lambda issue_activity: (
IssueActivitySerializer(issue_activity, many=True, fields=self.fields, expand=self.expand).data
),
on_results=lambda issue_activity: IssueActivitySerializer(
issue_activity, many=True, fields=self.fields, expand=self.expand
).data,
)
@@ -2261,224 +2220,3 @@ class IssueSearchEndpoint(BaseAPIView):
)[: int(limit)]
return Response({"issues": issue_results}, status=status.HTTP_200_OK)
class IssueRelationListCreateAPIEndpoint(BaseAPIView):
"""Issue Relation List and Create Endpoint"""
serializer_class = IssueRelationSerializer
model = IssueRelation
permission_classes = [ProjectEntityPermission]
use_read_replica = True
@work_item_relation_docs(
operation_id="list_work_item_relations",
summary="List work item relations",
description="Retrieve all relationships for a work item including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after relations.", # noqa E501
parameters=[
ISSUE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Work item relations grouped by relation type",
response=IssueRelationResponseSerializer,
examples=[
OpenApiExample(
name="Work Item Relations Response",
value={
"blocking": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
],
"blocked_by": ["550e8400-e29b-41d4-a716-446655440002"],
"duplicate": [],
"relates_to": ["550e8400-e29b-41d4-a716-446655440003"],
"start_after": [],
"start_before": ["550e8400-e29b-41d4-a716-446655440004"],
"finish_after": [],
"finish_before": [],
},
)
],
),
400: INVALID_REQUEST_RESPONSE,
404: ISSUE_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug, project_id, issue_id):
"""List work item relations
Retrieve all relationships for a work item organized by relation type.
Returns a structured response with relations grouped by type.
"""
empty_uuid_array = Value([], output_field=ArrayField(UUIDField()))
def _agg_ids(field, **filter_kwargs):
return Coalesce(
ArrayAgg(field, filter=Q(**filter_kwargs), distinct=True),
empty_uuid_array,
)
issue_relation_qs = IssueRelation.objects.filter(
Q(issue_id=issue_id) | Q(related_issue_id=issue_id),
workspace__slug=slug,
)
relation_ids = issue_relation_qs.aggregate(
blocking_ids=_agg_ids("issue_id", relation_type="blocked_by", related_issue_id=issue_id),
blocked_by_ids=_agg_ids("related_issue_id", relation_type="blocked_by", issue_id=issue_id),
duplicate_ids=_agg_ids("related_issue_id", relation_type="duplicate", issue_id=issue_id),
duplicate_ids_related=_agg_ids("issue_id", relation_type="duplicate", related_issue_id=issue_id),
relates_to_ids=_agg_ids("related_issue_id", relation_type="relates_to", issue_id=issue_id),
relates_to_ids_related=_agg_ids("issue_id", relation_type="relates_to", related_issue_id=issue_id),
start_after_ids=_agg_ids("issue_id", relation_type="start_before", related_issue_id=issue_id),
start_before_ids=_agg_ids("related_issue_id", relation_type="start_before", issue_id=issue_id),
finish_after_ids=_agg_ids("issue_id", relation_type="finish_before", related_issue_id=issue_id),
finish_before_ids=_agg_ids("related_issue_id", relation_type="finish_before", issue_id=issue_id),
)
response_data = {
"blocking": relation_ids["blocking_ids"],
"blocked_by": relation_ids["blocked_by_ids"],
"duplicate": list(set(relation_ids["duplicate_ids"] + relation_ids["duplicate_ids_related"])),
"relates_to": list(set(relation_ids["relates_to_ids"] + relation_ids["relates_to_ids_related"])),
"start_after": relation_ids["start_after_ids"],
"start_before": relation_ids["start_before_ids"],
"finish_after": relation_ids["finish_after_ids"],
"finish_before": relation_ids["finish_before_ids"],
}
return Response(response_data, status=status.HTTP_200_OK)
@work_item_relation_docs(
operation_id="create_work_item_relation",
summary="Create work item relation",
description="Create relationships between work items. Supports various relation types including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after.", # noqa E501
parameters=[
ISSUE_ID_PARAMETER,
],
request=OpenApiRequest(
request=IssueRelationCreateSerializer,
examples=[
OpenApiExample(
name="Create blocking relation",
value={
"relation_type": "blocking",
"issues": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
],
},
)
],
),
responses={
201: OpenApiResponse(
description="Work item relations created successfully",
response=IssueRelationSerializer(many=True),
examples=[
OpenApiExample(
name="Relations created",
value=[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Fix authentication bug",
"sequence_id": 42,
"project_id": "550e8400-e29b-41d4-a716-446655440001",
"relation_type": "blocked_by",
"state_id": "550e8400-e29b-41d4-a716-446655440002",
"priority": "high",
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-15T10:00:00Z",
"created_by": "550e8400-e29b-41d4-a716-446655440004",
"updated_by": "550e8400-e29b-41d4-a716-446655440004",
}
],
)
],
),
400: INVALID_REQUEST_RESPONSE,
404: ISSUE_NOT_FOUND_RESPONSE,
},
)
def post(self, request, slug, project_id, issue_id):
"""Create work item relation
Create relationships between work items with specified relation type.
Automatically tracks relation creation activity.
"""
# Validate request data using serializer
serializer = IssueRelationCreateSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
relation_type = serializer.validated_data["relation_type"]
issues = serializer.validated_data["issues"]
project = Project.objects.get(pk=project_id, workspace__slug=slug)
actual_relation = get_actual_relation(relation_type)
is_reverse = relation_type in ["blocking", "start_after", "finish_after"]
IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=(issue if is_reverse else issue_id),
related_issue_id=(issue_id if is_reverse else issue),
relation_type=actual_relation,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
issue_activity.delay(
type="issue_relation.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
# Re-fetch with select_related to avoid N+1 queries in serializers.
# bulk_create with ignore_conflicts=True may not return PKs,
# so query by the issue/related_issue pairs and relation type.
if is_reverse:
refetch_filter = Q(
issue_id__in=issues,
related_issue_id=issue_id,
relation_type=actual_relation,
)
else:
refetch_filter = Q(
issue_id=issue_id,
related_issue_id__in=issues,
relation_type=actual_relation,
)
refetched_relations = IssueRelation.objects.filter(
refetch_filter,
workspace__slug=slug,
).select_related(
"issue__state",
"related_issue__state",
)
serializer_class = RelatedIssueSerializer if is_reverse else IssueRelationSerializer
return Response(
serializer_class(refetched_relations, many=True).data,
status=status.HTTP_201_CREATED,
)
-11
View File
@@ -22,17 +22,6 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
def _wrapped_view(instance, request, *args, **kwargs):
# Check for creator if required
if creator and model:
# check if the user is part of the workspace or not
if not WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
is_active=True,
).exists():
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
)
obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists()
if obj:
return view_func(instance, request, *args, **kwargs)
+6 -1
View File
@@ -3,7 +3,7 @@
# See the LICENSE file for details.
from django.urls import path
from plane.app.views import ApiTokenEndpoint
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [
# API Tokens
@@ -17,5 +17,10 @@ urlpatterns = [
ApiTokenEndpoint.as_view(),
name="api-tokens-details",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
ServiceApiTokenEndpoint.as_view(),
name="service-api-tokens",
),
## End API Tokens
]
+1 -1
View File
@@ -165,7 +165,7 @@ from .module.issue import ModuleIssueViewSet
from .module.archive import ModuleArchiveUnarchiveEndpoint
from .api import ApiTokenEndpoint
from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
from .page.base import (
PageViewSet,
+41 -14
View File
@@ -29,7 +29,7 @@ from plane.db.models import (
Module,
)
from plane.utils.analytics_plot import build_graph_plot, VALID_ANALYTICS_FIELDS, VALID_YAXIS
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters
from plane.app.permissions import allow_permission, ROLE
@@ -41,15 +41,32 @@ class AnalyticsEndpoint(BaseAPIView):
y_axis = request.GET.get("y_axis", False)
segment = request.GET.get("segment", False)
valid_xaxis_segment = [
"state_id",
"state__group",
"labels__id",
"assignees__id",
"estimate_point__value",
"issue_cycle__cycle_id",
"issue_module__module_id",
"priority",
"start_date",
"target_date",
"created_at",
"completed_at",
]
valid_yaxis = ["issue_count", "estimate"]
# Check for x-axis and y-axis as thery are required parameters
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
return Response(
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# If segment is present it cannot be same as x-axis
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
return Response(
{"error": "Both segment and x axis cannot be same and segment should be valid"},
status=status.HTTP_400_BAD_REQUEST,
@@ -197,20 +214,13 @@ class SavedAnalyticEndpoint(BaseAPIView):
x_axis = analytic_view.query_dict.get("x_axis", False)
y_axis = analytic_view.query_dict.get("y_axis", False)
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
if not x_axis or not y_axis:
return Response(
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
{"error": "x-axis and y-axis dimensions are required"},
status=status.HTTP_400_BAD_REQUEST,
)
segment = request.GET.get("segment", False)
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
return Response(
{"error": "Both segment and x axis cannot be same and segment should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment)
total_issues = queryset.count()
return Response(
@@ -226,15 +236,32 @@ class ExportAnalyticsEndpoint(BaseAPIView):
y_axis = request.data.get("y_axis", False)
segment = request.data.get("segment", False)
valid_xaxis_segment = [
"state_id",
"state__group",
"labels__id",
"assignees__id",
"estimate_point",
"issue_cycle__cycle_id",
"issue_module__module_id",
"priority",
"start_date",
"target_date",
"created_at",
"completed_at",
]
valid_yaxis = ["issue_count", "estimate"]
# Check for x-axis and y-axis as thery are required parameters
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
return Response(
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# If segment is present it cannot be same as x-axis
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
return Response(
{"error": "Both segment and x axis cannot be same and segment should be valid"},
status=status.HTTP_400_BAD_REQUEST,
+27 -1
View File
@@ -13,8 +13,9 @@ from rest_framework import status
# Module import
from .base import BaseAPIView
from plane.db.models import APIToken
from plane.db.models import APIToken, Workspace
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
from plane.app.permissions import WorkspaceEntityPermission
class ApiTokenEndpoint(BaseAPIView):
@@ -60,3 +61,28 @@ class ApiTokenEndpoint(BaseAPIView):
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request: Request, slug: str) -> Response:
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first()
if api_token:
return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK)
else:
# Check the user type
user_type = 1 if request.user.is_bot else 0
api_token = APIToken.objects.create(
label=str(uuid4().hex),
description="Service Token",
user=request.user,
workspace=workspace,
user_type=user_type,
is_service=True,
)
return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED)
+1 -4
View File
@@ -64,10 +64,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
pk=pk, workspace__slug=slug, project_id=project_id, issue_id=issue_id
).first()
if not issue_attachment:
return Response(
{"error": "Issue attachment not found."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(status=status.HTTP_404_NOT_FOUND)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(
+1 -1
View File
@@ -1118,7 +1118,7 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
epoch = int(timezone.now().timestamp())
# Fetch all relevant issues in a single query
issues = list(Issue.objects.filter(id__in=issue_ids, workspace__slug=slug, project_id=project_id))
issues = list(Issue.objects.filter(id__in=issue_ids))
issues_dict = {str(issue.id): issue for issue in issues}
issues_to_update = []
+14 -29
View File
@@ -226,36 +226,21 @@ class ProjectMemberViewSet(BaseViewSet):
is_active=True,
)
if "role" in request.data:
# Only Admins can modify roles
if requested_project_member.role < ROLE.ADMIN.value and not is_workspace_admin:
return Response(
{"error": "You do not have permission to update roles"},
status=status.HTTP_403_FORBIDDEN,
)
if workspace_role in [5] and int(request.data.get("role", project_member.role)) in [15, 20]:
return Response(
{"error": "You cannot add a user with role higher than the workspace role"},
status=status.HTTP_400_BAD_REQUEST,
)
# Cannot modify a member whose role is equal to or higher than your own
if project_member.role >= requested_project_member.role and not is_workspace_admin:
return Response(
{"error": "You cannot update the role of a member with a role equal to or higher than your own"},
status=status.HTTP_403_FORBIDDEN,
)
new_role = int(request.data.get("role"))
# Cannot assign a role equal to or higher than your own
if new_role >= requested_project_member.role and not is_workspace_admin:
return Response(
{"error": "You cannot assign a role equal to or higher than your own"},
status=status.HTTP_403_FORBIDDEN,
)
# Cannot assign a role higher than the target's workspace role
if workspace_role in [5] and new_role in [15, 20]:
return Response(
{"error": "You cannot add a user with role higher than the workspace role"},
status=status.HTTP_400_BAD_REQUEST,
)
if (
"role" in request.data
and int(request.data.get("role", project_member.role)) > requested_project_member.role
and not is_workspace_admin
):
return Response(
{"error": "You cannot update a role that is higher than your own role"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True)
+24 -52
View File
@@ -13,7 +13,7 @@ from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin
import base64
import ipaddress
from typing import Dict, Any, Tuple
from typing import Dict, Any
from typing import Optional
from plane.db.models import IssueLink
from plane.utils.exception_logger import log_exception
@@ -66,52 +66,6 @@ def validate_url_ip(url: str) -> None:
MAX_REDIRECTS = 5
def safe_get(
url: str,
headers: Optional[Dict[str, str]] = None,
timeout: int = 1,
) -> Tuple[requests.Response, str]:
"""
Perform a GET request that validates every redirect hop against private IPs.
Prevents SSRF by ensuring no redirect lands on a private/internal address.
Args:
url: The URL to fetch
headers: Optional request headers
timeout: Request timeout in seconds
Returns:
A tuple of (final Response object, final URL after redirects)
Raises:
ValueError: If any URL in the redirect chain points to a private IP
requests.RequestException: On network errors
RuntimeError: If max redirects exceeded
"""
validate_url_ip(url)
current_url = url
response = requests.get(
current_url, headers=headers, timeout=timeout, allow_redirects=False
)
redirect_count = 0
while response.is_redirect:
if redirect_count >= MAX_REDIRECTS:
raise RuntimeError(f"Too many redirects for URL: {url}")
redirect_url = response.headers.get("Location")
if not redirect_url:
break
current_url = urljoin(current_url, redirect_url)
validate_url_ip(current_url)
redirect_count += 1
response = requests.get(
current_url, headers=headers, timeout=timeout, allow_redirects=False
)
return response, current_url
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"""
Crawls a URL to extract the title and favicon.
@@ -132,8 +86,26 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
title = None
final_url = url
validate_url_ip(final_url)
try:
response, final_url = safe_get(url, headers=headers)
# Manually follow redirects to validate each URL before requesting
redirect_count = 0
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
while response.is_redirect and redirect_count < MAX_REDIRECTS:
redirect_url = response.headers.get("Location")
if not redirect_url:
break
# Resolve relative redirects against current URL
final_url = urljoin(final_url, redirect_url)
# Validate the redirect target BEFORE making the request
validate_url_ip(final_url)
redirect_count += 1
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
if redirect_count >= MAX_REDIRECTS:
logger.warning(f"Too many redirects for URL: {url}")
soup = BeautifulSoup(response.content, "html.parser")
title_tag = soup.find("title")
@@ -141,10 +113,8 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
except requests.RequestException as e:
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
except (ValueError, RuntimeError) as e:
logger.warning(f"URL validation failed: {str(e)}")
# Fetch and encode favicon using final URL (after redirects) for correct relative href resolution
# Fetch and encode favicon using final URL (after redirects)
favicon_base64 = fetch_and_encode_favicon(headers, soup, final_url)
# Prepare result
@@ -234,7 +204,9 @@ def fetch_and_encode_favicon(
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
response, _ = safe_get(favicon_url, headers=headers)
validate_url_ip(favicon_url)
response = requests.get(favicon_url, headers=headers, timeout=1)
# Get content type
content_type = response.headers.get("content-type", "image/x-icon")
@@ -1,18 +0,0 @@
# Generated by Django 4.2.28 on 2026-02-26 14:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0120_issueview_archived_at'),
]
operations = [
migrations.AlterField(
model_name='estimate',
name='type',
field=models.CharField(choices=[('categories', 'Categories'), ('points', 'Points')], default='categories', max_length=255),
),
]
+1 -5
View File
@@ -10,15 +10,11 @@ from django.db.models import Q
# Module imports
from .project import ProjectBaseModel
class EstimateType(models.TextChoices):
CATEGORIES = "categories", "Categories"
POINTS = "points", "Points"
class Estimate(ProjectBaseModel):
name = models.CharField(max_length=255)
description = models.TextField(verbose_name="Estimate Description", blank=True)
type = models.CharField(max_length=255, choices=EstimateType.choices, default=EstimateType.CATEGORIES)
type = models.CharField(max_length=255, default="categories")
last_used = models.BooleanField(default=False)
def __str__(self):
@@ -45,8 +45,7 @@ class InstanceConfigurationEndpoint(BaseAPIView):
bulk_configurations = []
for configuration in configurations:
raw_value = request.data.get(configuration.key, configuration.value)
value = "" if raw_value is None else str(raw_value).strip()
value = request.data.get(configuration.key, configuration.value)
if configuration.is_encrypted:
configuration.value = encrypt_data(value)
else:
@@ -63,6 +63,8 @@ class InstanceEndpoint(BaseAPIView):
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
LLM_API_KEY,
IS_INTERCOM_ENABLED,
INTERCOM_APP_ID,
) = get_configuration_value(
[
{
@@ -122,6 +124,15 @@ class InstanceEndpoint(BaseAPIView):
"key": "LLM_API_KEY",
"default": os.environ.get("LLM_API_KEY", ""),
},
# Intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"default": os.environ.get("IS_INTERCOM_ENABLED", "1"),
},
{
"key": "INTERCOM_APP_ID",
"default": os.environ.get("INTERCOM_APP_ID", ""),
},
]
)
@@ -158,6 +169,10 @@ class InstanceEndpoint(BaseAPIView):
# is smtp configured
data["is_smtp_configured"] = bool(EMAIL_HOST)
# Intercom settings
data["is_intercom_enabled"] = IS_INTERCOM_ENABLED == "1"
data["intercom_app_id"] = INTERCOM_APP_ID
# Base URL
data["admin_base_url"] = settings.ADMIN_BASE_URL
data["space_base_url"] = settings.SPACE_BASE_URL
@@ -1,126 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
from unittest.mock import patch, MagicMock
from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip
def _make_response(status_code=200, headers=None, is_redirect=False, content=b""):
"""Create a mock requests.Response."""
resp = MagicMock()
resp.status_code = status_code
resp.is_redirect = is_redirect
resp.headers = headers or {}
resp.content = content
return resp
@pytest.mark.unit
class TestValidateUrlIp:
"""Test validate_url_ip blocks private/internal IPs."""
def test_rejects_private_ip(self):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url_ip("http://example.com")
def test_rejects_loopback(self):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url_ip("http://example.com")
def test_rejects_non_http_scheme(self):
with pytest.raises(ValueError, match="Only HTTP and HTTPS"):
validate_url_ip("file:///etc/passwd")
def test_allows_public_ip(self):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
validate_url_ip("https://example.com") # Should not raise
@pytest.mark.unit
class TestSafeGet:
"""Test safe_get follows redirects safely and blocks SSRF."""
@patch("plane.bgtasks.work_item_link_task.requests.get")
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
def test_returns_response_for_non_redirect(self, mock_validate, mock_get):
final_resp = _make_response(status_code=200, content=b"OK")
mock_get.return_value = final_resp
response, final_url = safe_get("https://example.com")
assert response is final_resp
assert final_url == "https://example.com"
mock_validate.assert_called_once_with("https://example.com")
@patch("plane.bgtasks.work_item_link_task.requests.get")
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
def test_follows_redirect_and_validates_each_hop(self, mock_validate, mock_get):
redirect_resp = _make_response(
status_code=301,
is_redirect=True,
headers={"Location": "https://other.com/page"},
)
final_resp = _make_response(status_code=200, content=b"OK")
mock_get.side_effect = [redirect_resp, final_resp]
response, final_url = safe_get("https://example.com")
assert response is final_resp
assert final_url == "https://other.com/page"
# Should validate both the initial URL and the redirect target
assert mock_validate.call_count == 2
mock_validate.assert_any_call("https://example.com")
mock_validate.assert_any_call("https://other.com/page")
@patch("plane.bgtasks.work_item_link_task.requests.get")
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
def test_blocks_redirect_to_private_ip(self, mock_validate, mock_get):
redirect_resp = _make_response(
status_code=302,
is_redirect=True,
headers={"Location": "http://192.168.1.1:8080"},
)
mock_get.return_value = redirect_resp
# First call (initial URL) succeeds, second call (redirect target) fails
mock_validate.side_effect = [None, ValueError("Access to private/internal networks is not allowed")]
with pytest.raises(ValueError, match="private/internal"):
safe_get("https://evil.com/redirect")
@patch("plane.bgtasks.work_item_link_task.requests.get")
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
def test_raises_on_too_many_redirects(self, mock_validate, mock_get):
redirect_resp = _make_response(
status_code=302,
is_redirect=True,
headers={"Location": "https://example.com/loop"},
)
mock_get.return_value = redirect_resp
with pytest.raises(RuntimeError, match="Too many redirects"):
safe_get("https://example.com/start")
@patch("plane.bgtasks.work_item_link_task.requests.get")
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
def test_succeeds_at_exact_max_redirects(self, mock_validate, mock_get):
"""After exactly MAX_REDIRECTS hops, if the final response is 200, it should succeed."""
redirect_resp = _make_response(
status_code=302,
is_redirect=True,
headers={"Location": "https://example.com/next"},
)
final_resp = _make_response(status_code=200, content=b"OK")
# 5 redirects then a 200
mock_get.side_effect = [redirect_resp] * 5 + [final_resp]
response, final_url = safe_get("https://example.com/start")
assert response is final_resp
assert not response.is_redirect
-26
View File
@@ -22,23 +22,6 @@ from django.utils import timezone
# Module imports
from plane.db.models import Issue, Project
VALID_ANALYTICS_FIELDS = [
"state_id",
"state__group",
"labels__id",
"assignees__id",
"estimate_point__value",
"issue_cycle__cycle_id",
"issue_module__module_id",
"priority",
"start_date",
"target_date",
"created_at",
"completed_at",
]
VALID_YAXIS = ["issue_count", "estimate"]
def annotate_with_monthly_dimension(queryset, field_name, attribute):
# Get the year and the months
@@ -51,8 +34,6 @@ def annotate_with_monthly_dimension(queryset, field_name, attribute):
def extract_axis(queryset, x_axis):
if x_axis not in VALID_ANALYTICS_FIELDS:
raise ValueError(f"Invalid x_axis value: {x_axis}")
# Format the dimension when the axis is in date
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension")
@@ -71,13 +52,6 @@ def sort_data(data, temp_axis):
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
if x_axis not in VALID_ANALYTICS_FIELDS:
raise ValueError(f"Invalid x_axis value: {x_axis}")
if y_axis not in VALID_YAXIS:
raise ValueError(f"Invalid y_axis value: {y_axis}")
if segment and segment not in VALID_ANALYTICS_FIELDS:
raise ValueError(f"Invalid segment value: {segment}")
# temp x_axis
temp_axis = x_axis
# Extract the x_axis and queryset
@@ -232,6 +232,21 @@ unsplash_config_variables = [
},
]
intercom_config_variables = [
{
"key": "IS_INTERCOM_ENABLED",
"value": os.environ.get("IS_INTERCOM_ENABLED", "1"),
"category": "INTERCOM",
"is_encrypted": False,
},
{
"key": "INTERCOM_APP_ID",
"value": os.environ.get("INTERCOM_APP_ID", ""),
"category": "INTERCOM",
"is_encrypted": False,
},
]
core_config_variables = [
*authentication_config_variables,
*workspace_management_config_variables,
@@ -242,4 +257,5 @@ core_config_variables = [
*smtp_config_variables,
*llm_config_variables,
*unsplash_config_variables,
*intercom_config_variables,
]
-20
View File
@@ -47,7 +47,6 @@ from .parameters import (
CYCLE_VIEW_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
ESTIMATE_ID_PARAMETER,
)
# Responses
@@ -127,10 +126,6 @@ from .examples import (
STATE_UPDATE_EXAMPLE,
INTAKE_ISSUE_CREATE_EXAMPLE,
INTAKE_ISSUE_UPDATE_EXAMPLE,
ESTIMATE_CREATE_EXAMPLE,
ESTIMATE_UPDATE_EXAMPLE,
ESTIMATE_POINT_CREATE_EXAMPLE,
ESTIMATE_POINT_UPDATE_EXAMPLE,
# Response Examples
CYCLE_EXAMPLE,
TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE,
@@ -150,8 +145,6 @@ from .examples import (
PROJECT_MEMBER_EXAMPLE,
CYCLE_ISSUE_EXAMPLE,
STICKY_EXAMPLE,
ESTIMATE_EXAMPLE,
ESTIMATE_POINT_EXAMPLE,
)
# Helper decorators
@@ -164,7 +157,6 @@ from .decorators import (
user_docs,
cycle_docs,
work_item_docs,
work_item_relation_docs,
label_docs,
issue_link_docs,
issue_comment_docs,
@@ -173,8 +165,6 @@ from .decorators import (
module_docs,
module_issue_docs,
state_docs,
estimate_docs,
estimate_point_docs,
)
# Schema processing hooks
@@ -216,7 +206,6 @@ __all__ = [
"CYCLE_VIEW_PARAMETER",
"FIELDS_PARAMETER",
"EXPAND_PARAMETER",
"ESTIMATE_ID_PARAMETER",
# Responses
"UNAUTHORIZED_RESPONSE",
"FORBIDDEN_RESPONSE",
@@ -290,10 +279,6 @@ __all__ = [
"STATE_UPDATE_EXAMPLE",
"INTAKE_ISSUE_CREATE_EXAMPLE",
"INTAKE_ISSUE_UPDATE_EXAMPLE",
"ESTIMATE_CREATE_EXAMPLE",
"ESTIMATE_UPDATE_EXAMPLE",
"ESTIMATE_POINT_CREATE_EXAMPLE",
"ESTIMATE_POINT_UPDATE_EXAMPLE",
# Response Examples
"CYCLE_EXAMPLE",
"TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE",
@@ -313,8 +298,6 @@ __all__ = [
"PROJECT_MEMBER_EXAMPLE",
"CYCLE_ISSUE_EXAMPLE",
"STICKY_EXAMPLE",
"ESTIMATE_EXAMPLE",
"ESTIMATE_POINT_EXAMPLE",
# Decorators
"workspace_docs",
"project_docs",
@@ -324,7 +307,6 @@ __all__ = [
"user_docs",
"cycle_docs",
"work_item_docs",
"work_item_relation_docs",
"label_docs",
"issue_link_docs",
"issue_comment_docs",
@@ -333,8 +315,6 @@ __all__ = [
"module_docs",
"module_issue_docs",
"state_docs",
"estimate_docs",
"estimate_point_docs",
# Hooks
"preprocess_filter_api_v1_paths",
"generate_operation_summary",
@@ -223,21 +223,6 @@ def issue_attachment_docs(**kwargs):
return extend_schema(**_merge_schema_options(defaults, kwargs))
def work_item_relation_docs(**kwargs):
"""Decorator for work item relation endpoints"""
defaults = {
"tags": ["Work Item Relations"],
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
"responses": {
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
def module_docs(**kwargs):
"""Decorator for module management endpoints"""
defaults = {
@@ -297,29 +282,3 @@ def sticky_docs(**kwargs):
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
def estimate_docs(**kwargs):
"""Decorator for estimate-related endpoints"""
defaults = {
"tags": ["Estimates"],
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
"responses": {
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
def estimate_point_docs(**kwargs):
"""Decorator for estimate point-related endpoints"""
defaults = {
"tags": ["Estimate Points"],
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
"responses": {
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
-83
View File
@@ -686,69 +686,6 @@ STICKY_EXAMPLE = OpenApiExample(
},
)
# Estimate Examples
ESTIMATE_EXAMPLE = OpenApiExample(
name="Estimate",
value={
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Estimate 1",
"description": "Estimate 1 description",
},
description="Example response for an estimate",
)
ESTIMATE_POINT_EXAMPLE = OpenApiExample(
name="EstimatePoint",
value={
"id": "550e8400-e29b-41d4-a716-446655440000",
"estimate": "550e8400-e29b-41d4-a716-446655440001",
"key": 1,
"value": "1",
},
description="Example response for an estimate point",
)
ESTIMATE_CREATE_EXAMPLE = OpenApiExample(
name="EstimateCreateSerializer",
value={
"name": "Estimate 1",
"description": "Estimate 1 description",
},
description="Example request for creating an estimate",
)
ESTIMATE_UPDATE_EXAMPLE = OpenApiExample(
name="EstimateUpdateSerializer",
value={
"name": "Estimate 1",
"description": "Estimate 1 description",
},
description="Example request for updating an estimate",
)
# Estimate Point Examples
ESTIMATE_POINT_CREATE_EXAMPLE = OpenApiExample(
name="EstimatePointCreateSerializer",
value=[
{
"value": "1",
"description": "Estimate Point 1 description",
},
{
"value": "2",
"description": "Estimate Point 2 description",
},
],
description="Example request for creating an estimate point",
)
ESTIMATE_POINT_UPDATE_EXAMPLE = OpenApiExample(
name="EstimatePointUpdateSerializer",
value={
"value": "1",
"description": "Estimate Point 1 description",
},
description="Example request for updating an estimate point",
)
# Sample data for different entity types
SAMPLE_ISSUE = {
"id": "550e8400-e29b-41d4-a716-446655440000",
@@ -864,24 +801,6 @@ SAMPLE_STICKY = {
"created_at": "2024-01-01T10:30:00Z",
}
SAMPLE_ESTIMATE = {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Estimate 1",
"description": "Estimate 1 description",
"type": "categories",
"last_used": False,
"created_at": "2024-01-01T10:30:00Z",
}
SAMPLE_ESTIMATE_POINT = {
"id": "550e8400-e29b-41d4-a716-446655440000",
"estimate": "550e8400-e29b-41d4-a716-446655440001",
"key": 1,
"value": "1",
"description": "Estimate Point 1 description",
"created_at": "2024-01-01T10:30:00Z",
}
# Mapping of schema types to sample data
SCHEMA_EXAMPLES = {
"Issue": SAMPLE_ISSUE,
@@ -897,8 +816,6 @@ SCHEMA_EXAMPLES = {
"Intake": SAMPLE_INTAKE,
"CycleIssue": SAMPLE_CYCLE_ISSUE,
"Sticky": SAMPLE_STICKY,
"Estimate": SAMPLE_ESTIMATE,
"EstimatePoint": SAMPLE_ESTIMATE_POINT,
}
@@ -495,11 +495,3 @@ EXPAND_PARAMETER = OpenApiParameter(
),
],
)
ESTIMATE_ID_PARAMETER = OpenApiParameter(
name="estimate_id",
description="Estimate ID",
required=True,
type=OpenApiTypes.UUID,
location=OpenApiParameter.PATH,
)
+4 -4
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.30
Django==4.2.29
# rest framework
djangorestframework==3.15.2
# postgres
@@ -45,13 +45,13 @@ scout-apm==3.1.0
# xlsx generation
openpyxl==3.1.2
# logging
python-json-logger==4.0.0
python-json-logger==3.3.0
# html parser
beautifulsoup4==4.12.3
# analytics
posthog==3.5.0
# crypto
cryptography==46.0.7
cryptography==46.0.5
# html validator
lxml==6.0.0
# s3
@@ -61,7 +61,7 @@ zxcvbn==4.4.28
# timezone
pytz==2024.1
# jwt
PyJWT==2.12.0
PyJWT==2.8.0
# OpenTelemetry
opentelemetry-api==1.28.1
opentelemetry-sdk==1.28.1
+2 -2
View File
@@ -1,6 +1,6 @@
-r base.txt
# test framework
pytest==9.0.2
pytest==7.4.0
pytest-django==4.5.2
pytest-cov==4.1.0
pytest-xdist==3.3.1
@@ -9,4 +9,4 @@ factory-boy==3.3.0
freezegun==1.2.2
coverage==7.2.7
httpx==0.24.1
requests==2.33.0
requests==2.32.4
@@ -261,7 +261,7 @@
<td height="5" width="8" style=" font-size: 5px; line-height: 5px; " > ­ </td>
</tr>
<tr>
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://x.com/planepowers" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/twitter_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/twitter_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
<tr class="nl2go-responsive-hide" >
@@ -233,7 +233,7 @@
<td>
<div style="font-size: 0.8rem; color: #1c2024">
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the {{entity_type}}</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://x.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://twitter.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
</div>
</td>
</tr>
@@ -155,7 +155,7 @@
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
<td align="left" valign="top" class="r21-i nl2go-default-textstyle" style=" color: #3b3f44; font-family: georgia, serif; font-size: 16px; word-break: break-word; line-height: 1.5; padding-bottom: 5px; padding-left: 5px; padding-right: 5px; padding-top: 5px; text-align: left; " >
<div>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://forum.plane.so" title="Plane Forum" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="https://x.com/planepowers" title="@planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://forum.plane.so" title="Plane Forum" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="http://twitter.com/planepowers" title="@planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
</div>
</td>
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
@@ -220,7 +220,7 @@
<th width="40" class="r25-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://x.com/planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -831,7 +831,7 @@
"
>
<a
href="https://x.com/planepowers"
href="https://twitter.com/planepowers"
target="_blank"
style="
color: #006399;
@@ -973,7 +973,7 @@
><span style="font-size: 13px"
>, </span
><a
href="https://x.com/planepowers"
href="http://twitter.com/planepowers"
title="@planepowers"
target="_blank"
style="
@@ -1344,7 +1344,7 @@
"
>
<a
href="https://x.com/planepowers"
href="https://twitter.com/planepowers"
target="_blank"
style="
color: #006399;
@@ -974,7 +974,7 @@
><span style="font-size: 13px"
>, </span
><a
href="https://x.com/planepowers"
href="http://twitter.com/planepowers"
title="@planepowers"
target="_blank"
style="
@@ -1345,7 +1345,7 @@
"
>
<a
href="https://x.com/planepowers"
href="https://twitter.com/planepowers"
target="_blank"
style="
color: #006399;
+1 -1
View File
@@ -15,7 +15,7 @@ RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ARG TURBO_VERSION=2.9.4
ARG TURBO_VERSION=2.8.12
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . .
RUN turbo prune --scope=live --docker
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.3.0",
"version": "1.2.0",
"private": true,
"description": "A realtime collaborative server powers Plane's rich text editor",
"license": "AGPL-3.0",
@@ -14,7 +14,7 @@
},
"scripts": {
"build": "tsc --noEmit && tsdown",
"dev": "tsdown --watch --no-clean --onSuccess \"node --env-file=.env .\"",
"dev": "tsdown --watch --onSuccess \"node --env-file=.env .\"",
"start": "node --env-file=.env .",
"test": "vitest run",
"test:watch": "vitest",
@@ -27,6 +27,7 @@
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"@dotenvx/dotenvx": "catalog:",
"@effect/platform": "^0.94.0",
"@effect/platform-node": "^0.104.0",
"@fontsource/inter": "5.2.8",
@@ -46,8 +47,7 @@
"axios": "catalog:",
"compression": "1.8.1",
"cors": "^2.8.5",
"dotenv": "catalog:",
"effect": "3.20.0",
"effect": "^3.16.3",
"express": "catalog:",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
+1 -1
View File
@@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import * as dotenv from "dotenv";
import * as dotenv from "@dotenvx/dotenvx";
import { z } from "zod";
dotenv.config();
+1
View File
@@ -12,6 +12,7 @@ import {
CODE_COLORS,
LINK_COLORS,
MENTION_COLORS,
NEUTRAL_COLORS,
TEXT_COLORS,
} from "./colors";
+1 -1
View File
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
FROM base AS builder
RUN pnpm add -g turbo@2.9.4
RUN pnpm add -g turbo@2.8.12
COPY . .
+1 -1
View File
@@ -95,6 +95,6 @@ export function HydrateFallback() {
);
}
export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) {
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return <ErrorPage />;
}
+1 -1
View File
@@ -25,7 +25,7 @@ export type TEditorFlaggingHookReturnType = {
/**
* @description extensions disabled in various editors
*/
export const useEditorFlagging = (_anchor: string): TEditorFlaggingHookReturnType => ({
export const useEditorFlagging = (anchor: string): TEditorFlaggingHookReturnType => ({
document: {
disabled: [],
flagged: [],
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "1.3.0",
"version": "1.2.0",
"private": true,
"license": "AGPL-3.0",
"type": "module",
@@ -53,6 +53,7 @@
"uuid": "catalog:"
},
"devDependencies": {
"@dotenvx/dotenvx": "catalog:",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@react-router/dev": "catalog:",
@@ -61,7 +62,6 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"dotenv": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "^5.1.4"
+3 -3
View File
@@ -9,7 +9,7 @@ import { action, makeObservable, observable, runInAction } from "mobx";
import { SitesCycleService } from "@plane/services";
import type { TPublicCycle } from "@/types/cycle";
// store
import type { RootStore } from "./root.store";
import type { CoreRootStore } from "./root.store";
export interface ICycleStore {
// observables
@@ -23,9 +23,9 @@ export interface ICycleStore {
export class CycleStore implements ICycleStore {
cycles: TPublicCycle[] | undefined = undefined;
cycleService: SitesCycleService;
rootStore: RootStore;
rootStore: CoreRootStore;
constructor(_rootStore: RootStore) {
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observables
cycles: observable,
@@ -23,7 +23,7 @@ import type {
} from "@plane/types";
// types
import type { IIssue, TIssuesResponse } from "@/types/issue";
import type { RootStore } from "../root.store";
import type { CoreRootStore } from "../root.store";
// constants
// helpers
@@ -81,7 +81,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
// root store
rootIssueStore;
constructor(_rootStore: RootStore) {
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observable
loader: observable,
+2 -2
View File
@@ -10,7 +10,7 @@ import { observable, action, makeObservable, runInAction } from "mobx";
import { InstanceService } from "@plane/services";
import type { IInstance, IInstanceConfig } from "@plane/types";
// store
import type { RootStore } from "@/store/root.store";
import type { CoreRootStore } from "@/store/root.store";
type TError = {
status: string;
@@ -40,7 +40,7 @@ export class InstanceStore implements IInstanceStore {
// services
instanceService;
constructor(private store: RootStore) {
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observable
isLoading: observable.ref,
+3 -3
View File
@@ -13,7 +13,7 @@ import { SitesFileService, SitesIssueService } from "@plane/services";
import type { TFileSignedURLResponse, TIssuePublicComment } from "@plane/types";
import { EFileAssetType } from "@plane/types";
// store
import type { RootStore } from "@/store/root.store";
import type { CoreRootStore } from "@/store/root.store";
// types
import type { IIssue, IPeekMode, IVote } from "@/types/issue";
@@ -60,12 +60,12 @@ export class IssueDetailStore implements IIssueDetailStore {
[key: string]: IIssue;
} = {};
// root store
rootStore: RootStore;
rootStore: CoreRootStore;
// services
issueService: SitesIssueService;
fileService: SitesFileService;
constructor(_rootStore: RootStore) {
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
loader: observable.ref,
error: observable.ref,
+2 -2
View File
@@ -11,7 +11,7 @@ import { computedFn } from "mobx-utils";
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@plane/constants";
import type { IssuePaginationOptions, TIssueParams } from "@plane/types";
// store
import type { RootStore } from "@/store/root.store";
import type { CoreRootStore } from "@/store/root.store";
// types
import type {
TIssueLayoutOptions,
@@ -60,7 +60,7 @@ export class IssueFilterStore implements IIssueFilterStore {
};
filters: { [anchor: string]: TIssueFilters } | undefined = undefined;
constructor(private store: RootStore) {
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observables
layoutOptions: observable,
+3 -3
View File
@@ -9,7 +9,7 @@ import { action, makeObservable, runInAction } from "mobx";
import { SitesIssueService } from "@plane/services";
import type { IssuePaginationOptions, TLoader } from "@plane/types";
// store
import type { RootStore } from "@/store/root.store";
import type { CoreRootStore } from "@/store/root.store";
// types
import { BaseIssuesStore } from "./helpers/base-issues.store";
import type { IBaseIssuesStore } from "./helpers/base-issues.store";
@@ -28,11 +28,11 @@ export interface IIssueStore extends IBaseIssuesStore {
export class IssueStore extends BaseIssuesStore implements IIssueStore {
// root store
rootStore: RootStore;
rootStore: CoreRootStore;
// services
issueService: SitesIssueService;
constructor(_rootStore: RootStore) {
constructor(_rootStore: CoreRootStore) {
super(_rootStore);
makeObservable(this, {
// actions
+3 -3
View File
@@ -10,7 +10,7 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"
import { SitesLabelService } from "@plane/services";
import type { IIssueLabel } from "@plane/types";
// store
import type { RootStore } from "./root.store";
import type { CoreRootStore } from "./root.store";
export interface IIssueLabelStore {
// observables
@@ -25,9 +25,9 @@ export interface IIssueLabelStore {
export class LabelStore implements IIssueLabelStore {
labelMap: Record<string, IIssueLabel> = {};
labelService: SitesLabelService;
rootStore: RootStore;
rootStore: CoreRootStore;
constructor(_rootStore: RootStore) {
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observables
labelMap: observable,
+3 -3
View File
@@ -9,7 +9,7 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"
// plane imports
import { SitesMemberService } from "@plane/services";
import type { TPublicMember } from "@/types/member";
import type { RootStore } from "./root.store";
import type { CoreRootStore } from "./root.store";
export interface IIssueMemberStore {
// observables
@@ -24,9 +24,9 @@ export interface IIssueMemberStore {
export class MemberStore implements IIssueMemberStore {
memberMap: Record<string, TPublicMember> = {};
memberService: SitesMemberService;
rootStore: RootStore;
rootStore: CoreRootStore;
constructor(_rootStore: RootStore) {
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observables
memberMap: observable,
+3 -3
View File
@@ -11,7 +11,7 @@ import { SitesModuleService } from "@plane/services";
// types
import type { TPublicModule } from "@/types/modules";
// root store
import type { RootStore } from "./root.store";
import type { CoreRootStore } from "./root.store";
export interface IIssueModuleStore {
// observables
@@ -26,9 +26,9 @@ export interface IIssueModuleStore {
export class ModuleStore implements IIssueModuleStore {
moduleMap: Record<string, TPublicModule> = {};
moduleService: SitesModuleService;
rootStore: RootStore;
rootStore: CoreRootStore;
constructor(_rootStore: RootStore) {
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observables
moduleMap: observable,
+2 -2
View File
@@ -11,7 +11,7 @@ import { UserService } from "@plane/services";
import type { TUserProfile } from "@plane/types";
import { EStartOfTheWeek } from "@plane/types";
// store
import type { RootStore } from "@/store/root.store";
import type { CoreRootStore } from "@/store/root.store";
type TError = {
status: string;
@@ -64,7 +64,7 @@ export class ProfileStore implements IProfileStore {
// services
userService: UserService;
constructor(public store: RootStore) {
constructor(public store: CoreRootStore) {
makeObservable(this, {
// observables
isLoading: observable.ref,
+2 -2
View File
@@ -14,7 +14,7 @@ import type {
TProjectPublishViewProps,
} from "@plane/types";
// store
import type { RootStore } from "../root.store";
import type { CoreRootStore } from "../root.store";
export interface IPublishStore extends TProjectPublishSettings {
// computed
@@ -45,7 +45,7 @@ export class PublishStore implements IPublishStore {
workspace_detail: IWorkspaceLite | undefined;
constructor(
private store: RootStore,
private store: CoreRootStore,
publishSettings: TProjectPublishSettings
) {
this.anchor = publishSettings.anchor;
@@ -11,7 +11,7 @@ import { SitesProjectPublishService } from "@plane/services";
import type { TProjectPublishSettings } from "@plane/types";
// store
import { PublishStore } from "@/store/publish/publish.store";
import type { RootStore } from "@/store/root.store";
import type { CoreRootStore } from "@/store/root.store";
export interface IPublishListStore {
// observables
@@ -26,7 +26,7 @@ export class PublishListStore implements IPublishListStore {
// service
publishService;
constructor(private rootStore: RootStore) {
constructor(private rootStore: CoreRootStore) {
makeObservable(this, {
// observables
publishMap: observable,
+3 -3
View File
@@ -12,7 +12,7 @@ import type { IState } from "@plane/types";
// helpers
import { sortStates } from "@/helpers/state.helper";
// store
import type { RootStore } from "./root.store";
import type { CoreRootStore } from "./root.store";
export interface IStateStore {
// observables
@@ -28,9 +28,9 @@ export interface IStateStore {
export class StateStore implements IStateStore {
states: IState[] | undefined = undefined;
stateService: SitesStateService;
rootStore: RootStore;
rootStore: CoreRootStore;
constructor(_rootStore: RootStore) {
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observables
states: observable,
+2 -2
View File
@@ -14,7 +14,7 @@ import type { ActorDetail, IUser } from "@plane/types";
import type { IProfileStore } from "@/store/profile.store";
import { ProfileStore } from "@/store/profile.store";
// store
import type { RootStore } from "@/store/root.store";
import type { CoreRootStore } from "@/store/root.store";
type TUserErrorStatus = {
status: string;
@@ -50,7 +50,7 @@ export class UserStore implements IUserStore {
// service
userService: UserService;
constructor(private store: RootStore) {
constructor(private store: CoreRootStore) {
// stores
this.profile = new ProfileStore(store);
// service
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import * as dotenv from "dotenv";
import * as dotenv from "@dotenvx/dotenvx";
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
+1 -1
View File
@@ -14,7 +14,7 @@ RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ARG TURBO_VERSION=2.9.4
ARG TURBO_VERSION=2.8.12
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . .
@@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
@@ -11,7 +11,7 @@ import { useRouter } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import { Tabs } from "@plane/propel/tabs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
// components
import { cn } from "@plane/utils";
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
@@ -80,9 +80,9 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
"flex w-full items-center justify-between gap-4 overflow-hidden border-b border-subtle bg-surface-1 px-6 py-2"
)}
>
<Tabs.List className={"flex h-7 w-fit overflow-x-auto"}>
<TabsList className={"flex h-7 w-fit overflow-x-auto"}>
{ANALYTICS_TABS.map((tab) => (
<Tabs.Trigger
<TabsTrigger
key={tab.key}
value={tab.key}
disabled={tab.isDisabled}
@@ -95,22 +95,22 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
}}
>
{tab.label}
</Tabs.Trigger>
</TabsTrigger>
))}
</Tabs.List>
</TabsList>
<div className="flex-shrink-0">
<AnalyticsFilterActions />
</div>
</div>
{ANALYTICS_TABS.map((tab) => (
<Tabs.Content
<TabsContent
key={tab.key}
value={tab.key}
className={"h-full overflow-hidden overflow-y-auto px-2"}
>
<tab.content />
</Tabs.Content>
</TabsContent>
))}
</div>
</Tabs>
@@ -5,6 +5,7 @@
*/
// ui
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import { PanelRight } from "lucide-react";
@@ -13,6 +14,7 @@ import { useTranslation } from "@plane/i18n";
import { YourWorkIcon, ChevronDownIcon } from "@plane/propel/icons";
import type { IUserProfileProjectSegregation } from "@plane/types";
import { Breadcrumbs, Header, CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { ProfileIssuesFilter } from "@/components/profile/profile-issues-filter";
@@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
// plane helpers
+1
View File
@@ -81,6 +81,7 @@ function UserInvitationsPage() {
.then(() => {
mutate(USER_WORKSPACES_LIST);
const firstInviteId = invitationsRespond[0];
const invitation = invitations?.find((i) => i.id === firstInviteId);
const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace;
updateUserProfile({ last_workspace_id: redirectWorkspace?.id })
.then(() => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

+7
View File
@@ -13,6 +13,8 @@ import { TranslationProvider } from "@plane/i18n";
import { Toast } from "@plane/propel/toast";
// helpers
import { resolveGeneralTheme } from "@plane/utils";
// polyfills
import "@/lib/polyfills";
// mobx store provider
import { StoreProvider } from "@/lib/store-context";
@@ -29,6 +31,10 @@ const InstanceWrapper = lazy(function InstanceWrapper() {
return import("@/lib/wrappers/instance-wrapper");
});
const ChatSupportModal = lazy(function ChatSupportModal() {
return import("@/components/global/chat-support-modal");
});
export interface IAppProvider {
children: React.ReactNode;
}
@@ -47,6 +53,7 @@ export function AppProvider(props: IAppProvider) {
<StoreWrapper>
<InstanceWrapper>
<Suspense>
<ChatSupportModal />
<SWRConfig value={WEB_SWR_CONFIG}>{children}</SWRConfig>
</Suspense>
</InstanceWrapper>
@@ -8,7 +8,7 @@ import { useMemo } from "react";
import { useTranslation } from "@plane/i18n";
import { getAnalyticsTabs } from "./tabs";
export const useAnalyticsTabs = (_workspaceSlug: string) => {
export const useAnalyticsTabs = (workspaceSlug: string) => {
const { t } = useTranslation();
const analyticsTabs = useMemo(() => getAnalyticsTabs(t), [t]);
@@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import React from "react";
export type TCustomAutomationsRootProps = {
@@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
@@ -10,6 +10,6 @@ type TProps = {
workspace?: IWorkspace;
};
export function SubscriptionPill(_props: TProps) {
export function SubscriptionPill(props: TProps) {
return <></>;
}
@@ -46,7 +46,7 @@ type ActiveCyclesComponentProps = {
const ActiveCyclesComponent = observer(function ActiveCyclesComponent({
cycleId,
activeCycle,
activeCycleResolvedPath: _activeCycleResolvedPath,
activeCycleResolvedPath,
workspaceSlug,
projectId,
handleFiltersUpdate,
@@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import { observer } from "mobx-react";
type Props = {
cycleId: string;
@@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import React from "react";
// components
import { SidebarChart } from "./base";
@@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import React from "react";
// local components
@@ -14,6 +15,7 @@ type TDeDupeButtonRoot = {
label: string;
};
export function DeDupeButtonRoot(_props: TDeDupeButtonRoot) {
export function DeDupeButtonRoot(props: TDeDupeButtonRoot) {
const { workspaceSlug, isDuplicateModalOpen, label, handleOnClick } = props;
return <></>;
}
@@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
// types
import type { TDeDupeIssue } from "@plane/types";
@@ -13,6 +14,7 @@ type TDuplicateModalRootProps = {
handleDuplicateIssueModal: (value: boolean) => void;
};
export function DuplicateModalRoot(_props: TDuplicateModalRootProps) {
export function DuplicateModalRoot(props: TDuplicateModalRootProps) {
const { workspaceSlug, issues, handleDuplicateIssueModal } = props;
return <></>;
}
@@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// types
@@ -4,11 +4,14 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
type TDeDupeIssueButtonLabelProps = {
isOpen: boolean;
buttonLabel: string;
};
export function DeDupeIssueButtonLabel(_props: TDeDupeIssueButtonLabelProps) {
export function DeDupeIssueButtonLabel(props: TDeDupeIssueButtonLabelProps) {
const { isOpen, buttonLabel } = props;
return <></>;
}

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