Compare commits

..

1 Commits

Author SHA1 Message Date
Palanikannan M 202336dd9c fix: rendering node views reliably 2025-12-01 16:15:33 +05:30
670 changed files with 8816 additions and 13227 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
name: Bug report
description: Create a bug report to help us improve Plane
title: "[bug]: "
labels: [🐛bug, plane]
labels: [🐛bug]
assignees: [vihar, pushya22]
body:
- type: markdown
@@ -1,7 +1,7 @@
name: Feature request
description: Suggest a feature to improve Plane
title: "[feature]: "
labels: [✨feature, plane]
labels: [✨feature]
assignees: [vihar, pushya22]
body:
- type: markdown
@@ -27,13 +27,11 @@ jobs:
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12.x"
cache: 'pip'
cache-dependency-path: 'apps/api/requirements.txt'
python-version: "3.x"
- name: Install Pylint
run: python -m pip install ruff
- name: Install API Dependencies
@@ -17,11 +17,10 @@ concurrency:
cancel-in-progress: true
jobs:
# Format check has no build dependencies - run immediately in parallel
check-format:
name: check:format
build-and-lint:
name: Build and lint web apps
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 25
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
@@ -30,140 +29,28 @@ jobs:
TURBO_SCM_HEAD: ${{ github.sha }}
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 50
filter: blob:none
- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
- name: Enable Corepack and pnpm
run: corepack enable pnpm
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-store-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check formatting
- name: Lint Affected
run: pnpm turbo run check:lint --affected
- name: Check Affected format
run: pnpm turbo run check:format --affected
# Build packages - required for lint and type checks
build:
name: Build packages
runs-on: ubuntu-latest
timeout-minutes: 15
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
TURBO_SCM_HEAD: ${{ github.sha }}
NODE_OPTIONS: "--max-old-space-size=4096"
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 50
filter: blob:none
- name: Check Affected types
run: pnpm turbo run check:types --affected
- name: Set up Node.js
uses: actions/setup-node@v6
- name: Enable Corepack and pnpm
run: corepack enable pnpm
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-store-${{ runner.os }}-
- name: Restore Turbo cache
uses: actions/cache/restore@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-
turbo-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build packages
- name: Build Affected
run: pnpm turbo run build --affected
- name: Save Turbo cache
uses: actions/cache/save@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
# Lint and type checks depend on build artifacts
check:
name: ${{ matrix.task }}
runs-on: ubuntu-latest
needs: build
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
task: [check:lint, check:types]
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
TURBO_SCM_HEAD: ${{ github.sha }}
NODE_OPTIONS: "--max-old-space-size=4096"
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 50
filter: blob:none
- name: Set up Node.js
uses: actions/setup-node@v6
- name: Enable Corepack and pnpm
run: corepack enable pnpm
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-store-${{ runner.os }}-
- name: Restore Turbo cache
uses: actions/cache/restore@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run ${{ matrix.task }}
run: pnpm turbo run ${{ matrix.task }} --affected
-1
View File
@@ -111,4 +111,3 @@ build/
.react-router/
AGENTS.md
temp/
scripts/
-1
View File
@@ -1 +0,0 @@
pnpm lint-staged
+25 -45
View File
@@ -1,54 +1,34 @@
# ------------------------------
# Core Workspace Behavior
# ------------------------------
# Enforce pnpm workspace behavior and allow Turbo's lifecycle hooks if scripts are disabled
# This repo uses pnpm with workspaces.
# Always prefer using local workspace packages when available
prefer-workspace-packages = true
# Prefer linking local workspace packages when available
prefer-workspace-packages=true
link-workspace-packages=true
shared-workspace-lockfile=true
# Symlink workspace packages instead of duplicating them
link-workspace-packages = true
# Make peer installs smoother across the monorepo
auto-install-peers=true
strict-peer-dependencies=false
# Use a single lockfile across the whole monorepo
shared-workspace-lockfile = true
# If scripts are disabled (e.g., CI with --ignore-scripts), allowlisted packages can still run their hooks
# Turbo occasionally performs postinstall tasks for optimal performance
# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo)
# Ensure packages added from workspace save using workspace: protocol
save-workspace-protocol = true
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=prettier
public-hoist-pattern[]=typescript
# Reproducible installs across CI and dev
prefer-frozen-lockfile=true
# ------------------------------
# Dependency Resolution
# ------------------------------
# Prefer resolving to highest versions in monorepo to reduce duplication
resolution-mode=highest
# Choose the highest compatible version across the workspace
# → reduces fragmentation & node_modules bloat
resolution-mode = highest
# Speed up native module builds by caching side effects
side-effects-cache=true
# Automatically install peer dependencies instead of forcing every package to declare them
auto-install-peers = true
# Speed up local dev by reusing local store when possible
prefer-offline=true
# Don't break the install if peers are missing
strict-peer-dependencies = false
# ------------------------------
# Performance Optimizations
# ------------------------------
# Use cached artifacts for native modules (sharp, esbuild, etc.)
side-effects-cache = true
# Prefer local cached packages rather than hitting network
prefer-offline = true
# In CI, refuse to modify lockfile (prevents drift)
prefer-frozen-lockfile = true
# Use isolated linker (best compatibility with Node ecosystem tools)
node-linker = isolated
# Hoist commonly used tools to the root to prevent duplicates and speed up resolution
public-hoist-pattern[] = typescript
public-hoist-pattern[] = eslint
public-hoist-pattern[] = *@plane/*
public-hoist-pattern[] = vite
public-hoist-pattern[] = turbo
# Ensure workspace protocol is used when adding internal deps
save-workspace-protocol=true
-10
View File
@@ -1,10 +0,0 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
out/
pnpm-lock.yaml
storybook-static/
+2 -11
View File
@@ -1,15 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"overrides": [
{
"files": ["packages/codemods/**/*"],
"options": {
"printWidth": 80
}
}
],
"plugins": ["@prettier/plugin-oxc"],
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
"trailingComma": "es5",
"plugins": ["@prettier/plugin-oxc"]
}
-1
View File
@@ -1 +0,0 @@
eslint.config.mjs @lifeiscontent
+8 -10
View File
@@ -91,7 +91,7 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We lint with [ESLint 9](https://eslint.org/docs/latest/) using the shared `eslint.config.mjs` (type-aware via `typescript-eslint`) and format with [Prettier](https://prettier.io/) using `prettier.config.cjs`.
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
## Ways to contribute
@@ -187,19 +187,18 @@ Adding a new language involves several steps to ensure it integrates seamlessly
Add the new language to the TLanguage type in the language definitions file:
```ts
// packages/i18n/src/types/language.ts
export type TLanguage = "en" | "fr" | "your-lang";
// packages/i18n/src/types/language.ts
export type TLanguage = "en" | "fr" | "your-lang";
```
1. **Add language configuration**
Include the new language in the list of supported languages:
```ts
// packages/i18n/src/constants/language.ts
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "English", value: "en" },
{ label: "Your Language", value: "your-lang" },
];
// packages/i18n/src/constants/language.ts
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "English", value: "en" },
{ label: "Your Language", value: "your-lang" }
];
```
2. **Create translation files**
@@ -211,7 +210,6 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
3. **Update import logic**
Modify the language import logic to include your new language:
```ts
private importLanguageFile(language: TLanguage): Promise<any> {
switch (language) {
+14
View File
@@ -0,0 +1,14 @@
.next/*
.react-router/*
.vite/*
out/*
public/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test
+18
View File
@@ -0,0 +1,18 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
ignorePatterns: ["build/**", "dist/**", ".vite/**"],
rules: {
"import/no-duplicates": ["error", { "prefer-inline": false }],
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"@typescript-eslint/no-import-type-side-effects": "error",
"@typescript-eslint/consistent-type-imports": [
"error",
{
prefer: "type-imports",
fixStyle: "separate-type-imports",
disallowTypeAnnotations: false,
},
],
},
};
+7 -9
View File
@@ -1,10 +1,8 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
.next
.react-router
.vite
.vercel
.tubro
out/
pnpm-lock.yaml
storybook-static/
dist/
build/
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5",
"plugins": ["@prettier/plugin-oxc"]
}
+4 -6
View File
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
FROM base AS builder
RUN pnpm add -g turbo@2.6.3
RUN pnpm add -g turbo@2.5.8
COPY . .
@@ -62,12 +62,10 @@ COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
# Copy full directory structure before fetch to ensure all package.json files are available
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
# Build only the admin package
@@ -75,7 +73,7 @@ RUN pnpm turbo run build --filter=admin
# =========================================================================== #
FROM nginx:1.29-alpine AS production
FROM nginx:1.27-alpine AS production
COPY apps/admin/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/god-mode
@@ -163,6 +163,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
label={EMAIL_SECURITY_OPTIONS[emailSecurityKey]}
onChange={handleEmailSecurityChange}
buttonClassName="rounded-md border-custom-border-200"
optionsClassName="w-full"
input
>
{Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => (
@@ -11,7 +11,7 @@ import { cn } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
// assets
// eslint-disable-next-line import/order
import packageJson from "package.json";
const helpOptions = [
@@ -177,6 +177,7 @@ export function WorkspaceCreateForm() {
}
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
input
optionsClassName="w-full"
>
{ORGANIZATION_SIZE.map((item) => (
<CustomSelect.Option key={item} value={item}>
+6 -3
View File
@@ -26,7 +26,7 @@ export enum EErrorAlertType {
}
const errorCodeMessages: {
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string) => React.ReactNode };
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => React.ReactNode };
} = {
// admin
[EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
@@ -79,11 +79,14 @@ const errorCodeMessages: {
},
[EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
title: `User account deactivated`,
message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
},
};
export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string): TAdminAuthErrorInfo | undefined => {
export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes,
email?: string | undefined
): TAdminAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable import/order */
import * as Sentry from "@sentry/react-router";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
@@ -11,7 +11,6 @@ export function UpgradeButton() {
href="https://plane.so/pricing?mode=self-hosted"
target="_blank"
className={cn(getButtonStyling("primary", "sm"))}
rel="noreferrer"
>
Upgrade
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
@@ -24,7 +24,6 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
target="_blank"
className="group flex items-center justify-between p-4 gap-2.5 truncate border border-custom-border-200/70 hover:border-custom-border-200 hover:bg-custom-background-90 rounded-md"
rel="noreferrer"
>
<div className="flex items-start gap-4">
<span
+9 -7
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "1.2.0",
"version": "1.1.0",
"license": "AGPL-3.0",
"private": true,
"type": "module",
@@ -11,11 +11,11 @@
"preview": "react-router build && serve -s build/client -l 3001",
"start": "serve -s build/client -l 3001",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist && rm -rf build",
"check:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --max-warnings=485",
"check:lint": "eslint . --max-warnings 19",
"check:types": "react-router typegen && tsc --noEmit",
"check:format": "prettier . --cache --check",
"fix:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --fix --max-warnings=485",
"fix:format": "prettier . --cache --write"
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\""
},
"dependencies": {
"@bprogress/core": "catalog:",
@@ -37,7 +37,7 @@
"lucide-react": "catalog:",
"mobx": "catalog:",
"mobx-react": "catalog:",
"next-themes": "0.4.6",
"next-themes": "^0.2.1",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "7.51.5",
@@ -48,14 +48,16 @@
"uuid": "catalog:"
},
"devDependencies": {
"@dotenvx/dotenvx": "catalog:",
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@prettier/plugin-oxc": "0.0.4",
"@react-router/dev": "catalog:",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"dotenv": "^16.4.5",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "^5.1.4"
+1
View File
@@ -1 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = require("@plane/tailwind-config/postcss.config.js");
-21
View File
@@ -296,10 +296,6 @@ body {
}
/* scrollbar style */
::-webkit-scrollbar {
display: none;
}
@-moz-document url-prefix() {
* {
scrollbar-width: none;
@@ -360,23 +356,6 @@ body {
margin-top: 44px;
}
/* scrollbar xs size */
.scrollbar-xs::-webkit-scrollbar {
height: 10px;
width: 10px;
}
.scrollbar-xs::-webkit-scrollbar-thumb {
border: 3px solid rgba(0, 0, 0, 0);
}
.shadow-custom {
box-shadow: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); /* Convert #EAE7FA4D to rgba */
}
/* backdrop filter */
.backdrop-blur-custom {
@apply backdrop-filter blur-[9px];
}
/* scrollbar sm size */
.scrollbar-sm::-webkit-scrollbar {
height: 12px;
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
module.exports = {
+8 -13
View File
@@ -1,21 +1,16 @@
{
"extends": "@plane/typescript-config/react-router.json",
"compilerOptions": {
"noImplicitOverride": false,
"exactOptionalPropertyTypes": false,
"noUnusedParameters": false,
"noUnusedLocals": false,
"baseUrl": ".",
"rootDirs": [".", "./.react-router/types"],
"types": ["vite/client"],
"types": ["node", "vite/client"],
"paths": {
"package.json": ["./package.json"],
"ce/*": ["./ce/*"],
"@/app/*": ["./app/*"],
"@/*": ["./core/*"],
"@/plane-admin/*": ["./ce/*"],
"@/ce/*": ["./ce/*"],
"@/styles/*": ["./styles/*"]
}
"@/app/*": ["app/*"],
"@/*": ["core/*"],
"@/plane-admin/*": ["ce/*"],
"@/styles/*": ["styles/*"]
},
"strictNullChecks": true
},
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
"exclude": ["node_modules"]
+1 -1
View File
@@ -1,6 +1,6 @@
import path from "node:path";
import * as dotenv from "@dotenvx/dotenvx";
import { reactRouter } from "@react-router/dev/vite";
import dotenv from "dotenv";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { joinUrlPath } from "@plane/utils";
-3
View File
@@ -32,9 +32,6 @@ AWS_S3_ENDPOINT_URL="http://localhost:9000"
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# Signed URL expiration time in seconds (default: 3600 = 1 hour)
# Set to 30 for 30 seconds, 300 for 5 minutes, etc.
SIGNED_URL_EXPIRATION=3600
# Settings related to Docker
DOCKERIZED=1 # deprecated
-10
View File
@@ -1,10 +0,0 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
out/
pnpm-lock.yaml
storybook-static/
-3
View File
@@ -32,7 +32,4 @@ python manage.py create_bucket
# Clear Cache before starting to remove stale values
python manage.py clear_cache
# Collect static files
python manage.py collectstatic --noinput
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.2.0",
"version": "1.1.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+1 -2
View File
@@ -54,5 +54,4 @@ from .asset import (
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .sticky import StickySerializer
from .member import ProjectMemberSerializer
+8 -13
View File
@@ -4,7 +4,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue, User, Project
from plane.db.models import Cycle, CycleIssue, User
from plane.utils.timezone_converter import convert_to_utc
@@ -55,18 +55,6 @@ class CycleCreateSerializer(BaseSerializer):
]
def validate(self, data):
project_id = self.initial_data.get("project_id") or (
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
)
if not project_id:
raise serializers.ValidationError("Project ID is required")
project = Project.objects.filter(id=project_id).first()
if not project:
raise serializers.ValidationError("Project not found")
if not project.cycle_view:
raise serializers.ValidationError("Cycles are not enabled for this project")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
@@ -75,6 +63,13 @@ class CycleCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed end date")
if data.get("start_date", None) is not None and data.get("end_date", None) is not None:
project_id = self.initial_data.get("project_id") or (
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
)
if not project_id:
raise serializers.ValidationError("Project ID is required")
data["start_date"] = convert_to_utc(
date=str(data.get("start_date").date()),
project_id=project_id,
-9
View File
@@ -10,7 +10,6 @@ from plane.db.models import (
ModuleMember,
ModuleIssue,
ProjectMember,
Project,
)
@@ -54,14 +53,6 @@ class ModuleCreateSerializer(BaseSerializer):
]
def validate(self, data):
project_id = self.context.get("project_id")
if not project_id:
raise serializers.ValidationError("Project ID is required")
project = Project.objects.get(id=project_id)
if not project:
raise serializers.ValidationError("Project not found")
if not project.module_view:
raise serializers.ValidationError("Modules are not enabled for this project")
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
+1 -1
View File
@@ -171,7 +171,7 @@ class ProjectUpdateSerializer(ProjectCreateSerializer):
if (
validated_data.get("estimate", None) is not None
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate").id).exists()
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate")).exists()
):
# Check if the estimate is a estimate in the project
raise serializers.ValidationError("Estimate should be a estimate in the project")
-30
View File
@@ -1,30 +0,0 @@
from rest_framework import serializers
from .base import BaseSerializer
from plane.db.models import Sticky
from plane.utils.content_validator import validate_html_content, validate_binary_data
class StickySerializer(BaseSerializer):
class Meta:
model = Sticky
fields = "__all__"
read_only_fields = ["workspace", "owner"]
extra_kwargs = {"name": {"required": False}}
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"error": "html content is not valid"})
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
return data
-2
View File
@@ -9,7 +9,6 @@ from .state import urlpatterns as state_patterns
from .user import urlpatterns as user_patterns
from .work_item import urlpatterns as work_item_patterns
from .invite import urlpatterns as invite_patterns
from .sticky import urlpatterns as sticky_patterns
urlpatterns = [
*asset_patterns,
@@ -23,5 +22,4 @@ urlpatterns = [
*user_patterns,
*work_item_patterns,
*invite_patterns,
*sticky_patterns,
]
+1 -1
View File
@@ -15,4 +15,4 @@ router.register(r"invitations", WorkspaceInvitationsViewset, basename="workspace
# Wrap the router URLs with the workspace slug path
urlpatterns = [
path("workspaces/<str:slug>/", include(router.urls)),
]
]
+1 -5
View File
@@ -1,10 +1,6 @@
from django.urls import path
from plane.api.views import (
ProjectMemberListCreateAPIEndpoint,
ProjectMemberDetailAPIEndpoint,
WorkspaceMemberAPIEndpoint,
)
from plane.api.views import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
urlpatterns = [
# Project members
-12
View File
@@ -1,12 +0,0 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from plane.api.views import StickyViewSet
router = DefaultRouter()
router.register(r"stickies", StickyViewSet, basename="workspace-stickies")
urlpatterns = [
path("workspaces/<str:slug>/", include(router.urls)),
]
+1 -3
View File
@@ -54,6 +54,4 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpo
from .user import UserEndpoint
from .invite import WorkspaceInvitationsViewset
from .sticky import StickyViewSet
from .invite import WorkspaceInvitationsViewset
+2
View File
@@ -13,6 +13,8 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import APIException
from rest_framework.generics import GenericAPIView
+73 -24
View File
@@ -195,7 +195,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now())
queryset = queryset.filter(
start_date__lte=timezone.now(), end_date__gte=timezone.now()
)
data = CycleSerializer(
queryset,
many=True,
@@ -252,7 +254,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
# Incomplete Cycles
if cycle_view == "incomplete":
queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True))
queryset = queryset.filter(
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)
)
return self.paginate(
request=request,
queryset=(queryset),
@@ -298,10 +302,17 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
Create a new development cycle with specified name, description, and date range.
Supports external ID tracking for integration purposes.
"""
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
if (
request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None
) or (
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
):
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
serializer = CycleCreateSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -344,7 +355,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
{"error": "Both start date and end date are either required or are to be null"},
{
"error": "Both start date and end date are either required or are to be null"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -492,7 +505,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
"""
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
if cycle.archived_at:
return Response(
@@ -505,14 +520,20 @@ class CycleDetailAPIEndpoint(BaseAPIView):
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
}
else:
return Response(
{"error": "The Cycle has already been completed so it cannot be edited"},
{
"error": "The Cycle has already been completed so it cannot be edited"
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
serializer = CycleUpdateSerializer(
cycle, data=request.data, partial=True, context={"request": request}
)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -520,7 +541,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
and Cycle.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source", cycle.external_source),
external_source=request.data.get(
"external_source", cycle.external_source
),
external_id=request.data.get("external_id"),
).exists()
):
@@ -577,7 +600,11 @@ class CycleDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
)
issue_activity.delay(
type="cycle.activity.deleted",
@@ -597,7 +624,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
# Delete the cycle
cycle.delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete()
UserFavorite.objects.filter(
entity_type="cycle", entity_identifier=pk, project_id=project_id
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -735,7 +764,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data,
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
).data,
)
@cycle_docs(
@@ -754,7 +785,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
Move a completed cycle to archived status for historical tracking.
Only cycles that have ended can be archived.
"""
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
if cycle.end_date >= timezone.now():
return Response(
{"error": "Only completed cycles can be archived"},
@@ -785,7 +818,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
Restore an archived cycle to active status, making it available for regular use.
The cycle will reappear in active cycle lists.
"""
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -848,7 +883,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
# List
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True)
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
@@ -885,7 +922,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(issues),
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
on_results=lambda issues: IssueSerializer(
issues, many=True, fields=self.fields, expand=self.expand
).data,
)
@cycle_docs(
@@ -919,7 +958,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
if cycle.end_date is not None and cycle.end_date < timezone.now():
return Response(
@@ -931,9 +972,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
)
# Get all CycleWorkItems already created
cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues))
cycle_issues = list(
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
)
existing_issues = [
str(cycle_issue.issue_id) for cycle_issue in cycle_issues if str(cycle_issue.issue_id) in issues
str(cycle_issue.issue_id)
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
new_issues = list(set(issues) - set(existing_issues))
@@ -984,7 +1029,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize("json", created_records),
"created_cycle_issues": serializers.serialize(
"json", created_records
),
}
),
epoch=int(timezone.now().timestamp()),
@@ -1060,7 +1107,9 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView):
cycle_id=cycle_id,
issue_id=issue_id,
)
serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand)
serializer = CycleIssueSerializer(
cycle_issue, fields=self.fields, expand=self.expand
)
return Response(serializer.data, status=status.HTTP_200_OK)
@cycle_docs(
@@ -1165,7 +1214,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
{"error": "New Cycle Id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
old_cycle = Cycle.objects.get(
workspace__slug=slug,
project_id=project_id,
+1
View File
@@ -154,6 +154,7 @@ class ProjectMemberListCreateAPIEndpoint(BaseAPIView):
# API endpoint to get and update a project member
class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint):
@extend_schema(
operation_id="get_project_member",
summary="Get project member",
+2
View File
@@ -65,7 +65,9 @@ from plane.utils.openapi import (
ADMIN_ONLY_RESPONSE,
REQUIRED_FIELDS_RESPONSE,
MODULE_ISSUE_NOT_FOUND_RESPONSE,
ARCHIVED_RESPONSE,
CANNOT_ARCHIVE_RESPONSE,
UNARCHIVED_RESPONSE,
)
-109
View File
@@ -1,109 +0,0 @@
from rest_framework.response import Response
from rest_framework import status
from plane.api.views.base import BaseViewSet
from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import Sticky, Workspace
from plane.api.serializers import StickySerializer
# OpenAPI imports
from plane.utils.openapi.decorators import sticky_docs
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
from plane.utils.openapi import (
STICKY_EXAMPLE,
create_paginated_response,
DELETED_RESPONSE,
)
class StickyViewSet(BaseViewSet):
serializer_class = StickySerializer
model = Sticky
use_read_replica = True
permission_classes = [WorkspaceUserPermission]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(owner_id=self.request.user.id)
.distinct()
)
@sticky_docs(
operation_id="create_sticky",
summary="Create a new sticky",
description="Create a new sticky in the workspace",
request=OpenApiRequest(request=StickySerializer),
responses={
201: OpenApiResponse(description="Sticky created", response=StickySerializer, examples=[STICKY_EXAMPLE])
},
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = StickySerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@sticky_docs(
operation_id="list_stickies",
summary="List stickies",
description="List all stickies in the workspace",
responses={
200: create_paginated_response(
StickySerializer, "Sticky", "List of stickies", example_name="List of stickies"
)
},
)
def list(self, request, slug):
query = request.query_params.get("query", False)
stickies = self.get_queryset().order_by("-created_at")
if query:
stickies = stickies.filter(description_stripped__icontains=query)
return self.paginate(
request=request,
queryset=(stickies),
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
default_per_page=20,
)
@sticky_docs(
operation_id="retrieve_sticky",
summary="Retrieve a sticky",
description="Retrieve a sticky by its ID",
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
)
def retrieve(self, request, slug, pk):
sticky = self.get_object()
return Response(StickySerializer(sticky).data)
@sticky_docs(
operation_id="update_sticky",
summary="Update a sticky",
description="Update a sticky by its ID",
request=OpenApiRequest(request=StickySerializer),
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
)
def partial_update(self, request, slug, pk):
sticky = self.get_object()
serializer = StickySerializer(sticky, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@sticky_docs(
operation_id="delete_sticky",
summary="Delete a sticky",
description="Delete a sticky by its ID",
responses={204: DELETED_RESPONSE},
)
def destroy(self, request, slug, pk):
sticky = self.get_object()
sticky.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+1 -1
View File
@@ -9,4 +9,4 @@ urlpatterns = [
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
]
]
+6
View File
@@ -11,6 +11,7 @@ from plane.app.views import (
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
UserProjectInvitationsViewset,
ProjectPublicCoverImagesEndpoint,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectMemberPreferenceEndpoint,
@@ -105,6 +106,11 @@ urlpatterns = [
ProjectFavoritesViewSet.as_view({"delete": "destroy"}),
name="project-favorite",
),
path(
"project-covers/",
ProjectPublicCoverImagesEndpoint.as_view(),
name="project-covers",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
DeployBoardViewSet.as_view({"get": "list", "post": "create"}),
+1
View File
@@ -3,6 +3,7 @@ from .project.base import (
ProjectIdentifierEndpoint,
ProjectUserViewsEndpoint,
ProjectFavoritesViewSet,
ProjectPublicCoverImagesEndpoint,
DeployBoardViewSet,
ProjectArchiveUnarchiveEndpoint,
)
+76 -25
View File
@@ -45,7 +45,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
# Save the new avatar
user.avatar_asset_id = asset_id
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -63,7 +65,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
# Save the new cover image
user.cover_image_asset_id = asset_id
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -79,7 +83,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
user = User.objects.get(id=asset.user_id)
user.avatar_asset_id = None
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -92,7 +98,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
user = User.objects.get(id=asset.user_id)
user.cover_image_asset_id = None
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -152,7 +160,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
@@ -189,7 +199,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -253,14 +265,18 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
workspace.logo = ""
workspace.logo_asset_id = asset_id
workspace.save()
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/workspaces/", url_params=False, user=False, request=request
)
invalidate_cache_directly(
path="/api/users/me/workspaces/",
url_params=False,
user=True,
request=request,
)
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/instances/", url_params=False, user=False, request=request
)
return
# Project Cover
@@ -287,14 +303,18 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
return
workspace.logo_asset_id = None
workspace.save()
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/workspaces/", url_params=False, user=False, request=request
)
invalidate_cache_directly(
path="/api/users/me/workspaces/",
url_params=False,
user=True,
request=request,
)
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/instances/", url_params=False, user=False, request=request
)
return
# Project Cover
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
@@ -355,13 +375,17 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
workspace=workspace,
created_by=request.user,
entity_type=entity_type,
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_identifier),
**self.get_entity_id_field(
entity_type=entity_type, entity_id=entity_identifier
),
)
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
@@ -398,7 +422,9 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -561,7 +587,9 @@ class ProjectAssetEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
@@ -591,7 +619,9 @@ class ProjectAssetEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def delete(self, request, slug, project_id, pk):
# Get the asset
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
asset = FileAsset.objects.get(
id=pk, workspace__slug=slug, project_id=project_id
)
# Check deleted assets
asset.is_deleted = True
asset.deleted_at = timezone.now()
@@ -602,7 +632,9 @@ class ProjectAssetEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, pk):
# get the asset id
asset = FileAsset.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
asset = FileAsset.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
# Check if the asset is uploaded
if not asset.is_uploaded:
@@ -635,7 +667,9 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
# Check if the asset ids are provided
if not asset_ids:
return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST
)
# get the asset id
assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug)
@@ -689,11 +723,14 @@ class AssetCheckEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
asset = FileAsset.all_objects.filter(id=asset_id, workspace__slug=slug, deleted_at__isnull=True).exists()
asset = FileAsset.all_objects.filter(
id=asset_id, workspace__slug=slug, deleted_at__isnull=True
).exists()
return Response({"exists": asset}, status=status.HTTP_200_OK)
class DuplicateAssetEndpoint(BaseAPIView):
throttle_classes = [AssetRateThrottle]
def get_entity_id_field(self, entity_type, entity_id):
@@ -729,13 +766,17 @@ class DuplicateAssetEndpoint(BaseAPIView):
return {}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, asset_id):
project_id = request.data.get("project_id", None)
entity_id = request.data.get("entity_id", None)
entity_type = request.data.get("entity_type", None)
if not entity_type or entity_type not in FileAsset.EntityTypeContext.values:
if (
not entity_type
or entity_type not in FileAsset.EntityTypeContext.values
):
return Response(
{"error": "Invalid entity type or entity id"},
status=status.HTTP_400_BAD_REQUEST,
@@ -745,15 +786,23 @@ class DuplicateAssetEndpoint(BaseAPIView):
if project_id:
# check if project exists in the workspace
if not Project.objects.filter(id=project_id, workspace=workspace).exists():
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
return Response(
{"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND
)
storage = S3Storage(request=request)
original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first()
original_asset = FileAsset.objects.filter(
workspace=workspace, id=asset_id, is_uploaded=True
).first()
if not original_asset:
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
return Response(
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
)
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
destination_key = (
f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
)
duplicated_asset = FileAsset.objects.create(
attributes={
"name": original_asset.attributes.get("name"),
@@ -773,7 +822,9 @@ class DuplicateAssetEndpoint(BaseAPIView):
# Update the is_uploaded field for all newly created assets
FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True)
return Response({"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK)
return Response(
{"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK
)
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
+99 -31
View File
@@ -97,7 +97,9 @@ class CycleViewSet(BaseViewSet):
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(),
queryset=User.objects.only(
"avatar_asset", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
@@ -148,7 +150,8 @@ class CycleViewSet(BaseViewSet):
.annotate(
status=Case(
When(
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
Q(start_date__lte=current_time_in_utc)
& Q(end_date__gte=current_time_in_utc),
then=Value("CURRENT"),
),
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
@@ -167,7 +170,11 @@ class CycleViewSet(BaseViewSet):
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(issue_cycle__issue__assignees__id__isnull=True)
& (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)),
& (
Q(
issue_cycle__issue__issue_assignee__deleted_at__isnull=True
)
),
),
Value([], output_field=ArrayField(UUIDField())),
)
@@ -198,7 +205,9 @@ class CycleViewSet(BaseViewSet):
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc)
queryset = queryset.filter(
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
)
data = queryset.values(
# necessary fields
@@ -265,10 +274,16 @@ class CycleViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
if (
request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None
) or (
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
):
serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id})
serializer = CycleWriteSerializer(
data=request.data, context={"project_id": project_id}
)
if serializer.is_valid():
serializer.save(project_id=project_id, owned_by=request.user)
cycle = (
@@ -308,7 +323,9 @@ class CycleViewSet(BaseViewSet):
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(cycle, datetime_fields, project_timezone)
cycle = user_timezone_converter(
cycle, datetime_fields, project_timezone
)
# Send the model activity
model_activity.delay(
@@ -324,13 +341,17 @@ class CycleViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
{"error": "Both start date and end date are either required or are to be null"},
{
"error": "Both start date and end date are either required or are to be null"
},
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk)
queryset = self.get_queryset().filter(
workspace__slug=slug, project_id=project_id, pk=pk
)
cycle = queryset.first()
if cycle.archived_at:
return Response(
@@ -338,21 +359,29 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
request_data = request.data
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order for a completed cycle``
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
}
else:
return Response(
{"error": "The Cycle has already been completed so it cannot be edited"},
{
"error": "The Cycle has already been completed so it cannot be edited"
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id})
serializer = CycleWriteSerializer(
cycle, data=request.data, partial=True, context={"project_id": project_id}
)
if serializer.is_valid():
serializer.save()
cycle = queryset.values(
@@ -452,7 +481,9 @@ class CycleViewSet(BaseViewSet):
)
if data is None:
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
queryset = queryset.first()
# Fetch the project timezone
@@ -474,7 +505,11 @@ class CycleViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
)
issue_activity.delay(
type="cycle.activity.deleted",
@@ -525,7 +560,9 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
start_date = convert_to_utc(date=str(start_date), project_id=project_id, is_start_date=True)
start_date = convert_to_utc(
date=str(start_date), project_id=project_id, is_start_date=True
)
end_date = convert_to_utc(
date=str(end_date),
project_id=project_id,
@@ -629,8 +666,12 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
)
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
cycle_properties.rich_filters = request.data.get("rich_filters", cycle_properties.rich_filters)
cycle_properties.display_filters = request.data.get("display_filters", cycle_properties.display_filters)
cycle_properties.rich_filters = request.data.get(
"rich_filters", cycle_properties.rich_filters
)
cycle_properties.display_filters = request.data.get(
"display_filters", cycle_properties.display_filters
)
cycle_properties.display_properties = request.data.get(
"display_properties", cycle_properties.display_properties
)
@@ -654,9 +695,13 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
class CycleProgressEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first()
cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, id=cycle_id
).first()
if not cycle:
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
aggregate_estimates = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
@@ -702,7 +747,9 @@ class CycleProgressEndpoint(BaseAPIView):
output_field=FloatField(),
)
),
total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()),
total_estimate_points=Sum(
"value_as_float", default=Value(0), output_field=FloatField()
),
)
)
if cycle.progress_snapshot:
@@ -762,11 +809,22 @@ class CycleProgressEndpoint(BaseAPIView):
return Response(
{
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0,
"unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0,
"started_estimate_points": aggregate_estimates["started_estimate_point"] or 0,
"cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0,
"completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0,
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"]
or 0,
"unstarted_estimate_points": aggregate_estimates[
"unstarted_estimate_point"
]
or 0,
"started_estimate_points": aggregate_estimates["started_estimate_point"]
or 0,
"cancelled_estimate_points": aggregate_estimates[
"cancelled_estimate_point"
]
or 0,
"completed_estimate_points": aggregate_estimates[
"completed_estimate_points"
]
or 0,
"total_estimate_points": aggregate_estimates["total_estimate_points"],
"backlog_issues": backlog_issues,
"total_issues": total_issues,
@@ -784,7 +842,9 @@ class CycleAnalyticsEndpoint(BaseAPIView):
def get(self, request, slug, project_id, cycle_id):
analytic_type = request.GET.get("type", "issues")
cycle = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id)
Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, id=cycle_id
)
.annotate(
total_issues=Count(
"issue_cycle__issue__id",
@@ -867,7 +927,9 @@ class CycleAnalyticsEndpoint(BaseAPIView):
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -902,7 +964,9 @@ class CycleAnalyticsEndpoint(BaseAPIView):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -1004,7 +1068,11 @@ class CycleAnalyticsEndpoint(BaseAPIView):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
total_issues=Count(
"label_id", filter=Q(archived_at__isnull=True, is_draft=False)
)
)
.annotate(
completed_issues=Count(
"label_id",
+19 -26
View File
@@ -322,9 +322,6 @@ class IntakeIssueViewSet(BaseViewSet):
@allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue)
def partial_update(self, request, slug, project_id, pk):
skip_activity = request.data.pop("skip_activity", False)
is_description_update = request.data.get("description_html") is not None
intake_id = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
intake_issue = IntakeIssue.objects.get(
issue_id=pk,
@@ -421,30 +418,26 @@ class IntakeIssueViewSet(BaseViewSet):
# Both serializers are valid, now save them
if issue_serializer:
issue_serializer.save()
# Check if the update is a migration description update
is_migration_description_update = skip_activity and is_description_update
# Log all the updates
if not is_migration_description_update:
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=issue_requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=issue_current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=issue_current_instance,
issue_id=str(pk),
user_id=request.user.id,
)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=issue_requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=issue_current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=issue_current_instance,
issue_id=str(pk),
user_id=request.user.id,
)
if intake_serializer:
intake_serializer.save()
+26 -34
View File
@@ -611,10 +611,6 @@ class IssueViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk=None):
queryset = self.get_queryset()
queryset = self.apply_annotations(queryset)
skip_activity = request.data.pop("skip_activity", False)
is_description_update = request.data.get("description_html") is not None
issue = (
queryset.annotate(
label_ids=Coalesce(
@@ -663,36 +659,32 @@ class IssueViewSet(BaseViewSet):
serializer = IssueCreateSerializer(issue, data=request.data, partial=True, context={"project_id": project_id})
if serializer.is_valid():
serializer.save()
# Check if the update is a migration description update
is_migration_description_update = skip_activity and is_description_update
# Log all the updates
if not is_migration_description_update:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
model_activity.delay(
model_name="issue",
model_id=str(serializer.data.get("id", None)),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(serializer.data.get("id", None)),
user_id=request.user.id,
)
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
model_activity.delay(
model_name="issue",
model_id=str(serializer.data.get("id", None)),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(serializer.data.get("id", None)),
user_id=request.user.id,
)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+3 -1
View File
@@ -39,7 +39,9 @@ class LabelViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
try:
serializer = LabelSerializer(data=request.data, context={"project_id": project_id})
serializer = LabelSerializer(
data=request.data, context={"project_id": project_id}
)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
+16 -12
View File
@@ -495,12 +495,14 @@ class PagesDescriptionViewSet(BaseViewSet):
permission_classes = [ProjectPagePermission]
def retrieve(self, request, slug, project_id, page_id):
page = Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
page = (
Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
)
binary_data = page.description_binary
@@ -515,12 +517,14 @@ class PagesDescriptionViewSet(BaseViewSet):
return response
def partial_update(self, request, slug, project_id, page_id):
page = Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
page = (
Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
)
if page.is_locked:
+68 -27
View File
@@ -1,40 +1,44 @@
# Python imports
import boto3
from django.conf import settings
from django.utils import timezone
import json
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# Module imports
from plane.app.permissions import ROLE, ProjectMemberPermission, allow_permission
from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.serializers import (
DeployBoardSerializer,
ProjectListSerializer,
ProjectSerializer,
ProjectListSerializer,
DeployBoardSerializer,
)
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE
from plane.db.models import (
UserFavorite,
DeployBoard,
Intake,
DeployBoard,
IssueUserProperty,
Project,
ProjectIdentifier,
ProjectMember,
ProjectNetwork,
State,
DEFAULT_STATES,
Workspace,
WorkspaceMember,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.exception_logger import log_exception
from plane.utils.host import base_host
@@ -206,25 +210,19 @@ class ProjectViewSet(BaseViewSet):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def retrieve(self, request, slug, pk):
project = self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk).first()
project = (
self.get_queryset()
.filter(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
.filter(archived_at__isnull=True)
.filter(pk=pk)
).first()
if project is None:
return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND)
member_ids = [str(project_member.member_id) for project_member in project.members_list]
if str(request.user.id) not in member_ids:
if project.network == ProjectNetwork.SECRET.value:
return Response(
{"error": "You do not have permission"},
status=status.HTTP_403_FORBIDDEN,
)
else:
return Response(
{"error": "You are not a member of this project"},
status=status.HTTP_409_CONFLICT,
)
recent_visited_task.delay(
slug=slug,
project_id=pk,
@@ -521,6 +519,49 @@ class ProjectFavoritesViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [AllowAny]
# Cache the below api for 24 hours
@cache_response(60 * 60 * 24, user=False)
def get(self, request):
files = []
if settings.USE_MINIO:
s3 = boto3.client(
"s3",
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
else:
s3 = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
params = {
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
"Prefix": "static/project-cover/",
}
try:
response = s3.list_objects_v2(**params)
# Extracting file keys from the response
if "Contents" in response:
for content in response["Contents"]:
if not content["Key"].endswith(
"/"
): # This line ensures we're only getting files, not "sub-folders"
files.append(
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
return Response(files, status=status.HTTP_200_OK)
except Exception as e:
log_exception(e)
return Response([], status=status.HTTP_200_OK)
class DeployBoardViewSet(BaseViewSet):
permission_classes = [ProjectMemberPermission]
serializer_class = DeployBoardSerializer
@@ -164,40 +164,6 @@ class ProjectMemberViewSet(BaseViewSet):
serializer = ProjectMemberRoleSerializer(project_members, fields=("id", "member", "role"), many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk):
requesting_project_member = ProjectMember.objects.get(
project_id=project_id,
workspace__slug=slug,
member=request.user,
is_active=True,
)
project_member = (
ProjectMember.objects.filter(
pk=pk,
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
is_active=True,
)
.select_related("project", "member", "workspace")
.first()
)
if not project_member:
return Response(
{"error": "Project member not found"},
status=status.HTTP_404_NOT_FOUND,
)
if requesting_project_member.role > ROLE.GUEST.value:
serializer = ProjectMemberAdminSerializer(project_member)
else:
serializer = ProjectMemberRoleSerializer(project_member, fields=("id", "member", "role"))
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, is_active=True)
+63 -26
View File
@@ -129,7 +129,9 @@ class GlobalSearchEndpoint(BaseAPIView):
return (
cycles.order_by("-created_at")
.distinct()
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
def filter_modules(self, query, slug, project_id, workspace_search):
@@ -153,7 +155,9 @@ class GlobalSearchEndpoint(BaseAPIView):
return (
modules.order_by("-created_at")
.distinct()
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
def filter_pages(self, query, slug, project_id, workspace_search):
@@ -173,7 +177,9 @@ class GlobalSearchEndpoint(BaseAPIView):
)
.annotate(
project_ids=Coalesce(
ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)),
ArrayAgg(
"projects__id", distinct=True, filter=~Q(projects__id=True)
),
Value([], output_field=ArrayField(UUIDField())),
)
)
@@ -190,16 +196,20 @@ class GlobalSearchEndpoint(BaseAPIView):
)
if workspace_search == "false" and project_id:
project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list(
"project_id", flat=True
)[:1]
project_subquery = ProjectPage.objects.filter(
page_id=OuterRef("id"), project_id=project_id
).values_list("project_id", flat=True)[:1]
pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id)
pages = pages.annotate(project_id=Subquery(project_subquery)).filter(
project_id=project_id
)
return (
pages.order_by("-created_at")
.distinct()
.values("name", "id", "project_ids", "project_identifiers", "workspace__slug")
.values(
"name", "id", "project_ids", "project_identifiers", "workspace__slug"
)
)
def filter_views(self, query, slug, project_id, workspace_search):
@@ -223,7 +233,9 @@ class GlobalSearchEndpoint(BaseAPIView):
return (
issue_views.order_by("-created_at")
.distinct()
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
def filter_intakes(self, query, slug, project_id, workspace_search):
@@ -282,7 +294,9 @@ class GlobalSearchEndpoint(BaseAPIView):
# Determine which entities to search
if entities_param:
requested_entities = [e.strip() for e in entities_param.split(",") if e.strip()]
requested_entities = [
e.strip() for e in entities_param.split(",") if e.strip()
]
requested_entities = [e for e in requested_entities if e in MODELS_MAPPER]
else:
requested_entities = list(MODELS_MAPPER.keys())
@@ -292,7 +306,9 @@ class GlobalSearchEndpoint(BaseAPIView):
for entity in requested_entities:
func = MODELS_MAPPER.get(entity)
if func:
results[entity] = func(query or None, slug, project_id, workspace_search)
results[entity] = func(
query or None, slug, project_id, workspace_search
)
return Response({"results": results}, status=status.HTTP_200_OK)
@@ -304,6 +320,7 @@ class SearchEndpoint(BaseAPIView):
query_types = [qt.strip() for qt in query_types]
count = int(request.query_params.get("count", 5))
project_id = request.query_params.get("project_id", None)
issue_id = request.query_params.get("issue_id", None)
response_data = {}
@@ -350,10 +367,14 @@ class SearchEndpoint(BaseAPIView):
.order_by("-created_at")
)
users = users.distinct().values(
"member__avatar_url",
"member__display_name",
"member__id",
users = (
users
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
response_data["user_mention"] = list(users[:count])
@@ -368,12 +389,15 @@ class SearchEndpoint(BaseAPIView):
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user) | Q(network=2),
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
)
response_data["project"] = list(projects)
@@ -432,16 +456,20 @@ class SearchEndpoint(BaseAPIView):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
@@ -559,7 +587,9 @@ class SearchEndpoint(BaseAPIView):
)
)
.order_by("-created_at")
.values("member__avatar_url", "member__display_name", "member__id")[:count]
.values(
"member__avatar_url", "member__display_name", "member__id"
)[:count]
)
response_data["user_mention"] = list(users)
@@ -573,12 +603,15 @@ class SearchEndpoint(BaseAPIView):
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user) | Q(network=2),
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
)
response_data["project"] = list(projects)
@@ -635,16 +668,20 @@ class SearchEndpoint(BaseAPIView):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
+10 -2
View File
@@ -2,6 +2,8 @@
import uuid
import json
import logging
import random
import string
import secrets
# Django imports
@@ -149,7 +151,13 @@ class UserEndpoint(BaseViewSet):
# Include user ID to bind the code to the specific user
cache_key = f"magic_email_update_{user.id}_{new_email}"
## Generate a random token
token = str(secrets.randbelow(900000) + 100000)
token = (
"".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
+ "-"
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
+ "-"
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
)
# Store in cache with 10 minute expiration
cache_data = json.dumps({"token": token})
cache.set(cache_key, cache_data, timeout=600)
@@ -210,7 +218,7 @@ class UserEndpoint(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
except Exception:
except Exception as e:
return Response(
{"error": "Failed to verify code. Please try again."},
status=status.HTTP_400_BAD_REQUEST,
@@ -50,25 +50,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
serializer = WorkSpaceMemberSerializer(workspace_members, fields=("id", "member", "role"), many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def retrieve(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True)
try:
# Get the specific workspace member by pk
member = self.get_queryset().get(pk=pk)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "Workspace member not found"},
status=status.HTTP_404_NOT_FOUND,
)
if workspace_member.role > ROLE.GUEST.value:
serializer = WorkspaceMemberAdminSerializer(member, fields=("id", "member", "role"))
else:
serializer = WorkSpaceMemberSerializer(member, fields=("id", "member", "role"))
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def partial_update(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(
+3 -103
View File
@@ -1,26 +1,22 @@
# Python imports
import os
import uuid
import requests
from io import BytesIO
# Django imports
from django.utils import timezone
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.conf import settings
# Third party imports
from zxcvbn import zxcvbn
# Module imports
from plane.db.models import Profile, User, WorkspaceMemberInvite, FileAsset
from plane.db.models import Profile, User, WorkspaceMemberInvite
from plane.license.utils.instance_value import get_configuration_value
from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
from plane.bgtasks.user_activation_email_task import user_activation_email
from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip
from plane.utils.exception_logger import log_exception
class Adapter:
@@ -105,93 +101,6 @@ class Adapter:
return True
def get_avatar_download_headers(self):
return {}
def download_and_upload_avatar(self, avatar_url, user):
"""
Downloads avatar from OAuth provider and uploads to our storage.
Returns the uploaded file path or None if failed.
"""
if not avatar_url:
return None
try:
headers = self.get_avatar_download_headers()
# Download the avatar image
response = requests.get(avatar_url, timeout=10, headers=headers)
response.raise_for_status()
# Check content length before downloading
content_length = response.headers.get("Content-Length")
max_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE
if content_length and int(content_length) > max_size:
return None
# Get content type and determine file extension
content_type = response.headers.get("Content-Type", "image/jpeg")
extension_map = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
extension = extension_map.get(content_type)
if not extension:
return None
# Download with size limit
chunks = []
total_size = 0
for chunk in response.iter_content(chunk_size=8192):
total_size += len(chunk)
if total_size > max_size:
return None
chunks.append(chunk)
content = b"".join(chunks)
file_size = len(content)
# Generate unique filename
filename = f"{uuid.uuid4().hex}-user-avatar.{extension}"
# Upload to S3/MinIO storage
from plane.settings.storage import S3Storage
storage = S3Storage(request=self.request)
# Create file-like object
file_obj = BytesIO(response.content)
file_obj.seek(0)
# Upload using boto3 directly
upload_success = storage.upload_file(file_obj=file_obj, object_name=filename, content_type=content_type)
if not upload_success:
return None
# Get storage metadata
storage_metadata = storage.get_object_metadata(object_name=filename)
# Create FileAsset record
file_asset = FileAsset.objects.create(
attributes={"name": f"{self.provider}-avatar.{extension}", "type": content_type, "size": file_size},
asset=filename,
size=file_size,
user=user,
created_by=user,
entity_type=FileAsset.EntityTypeContext.USER_AVATAR,
is_uploaded=True,
storage_metadata=storage_metadata,
)
return file_asset
except Exception as e:
log_exception(e)
# Return None if upload fails, so original URL can be used as fallback
return None
def save_user_data(self, user):
# Update user details
user.last_login_medium = self.provider
@@ -242,23 +151,14 @@ class Adapter:
user.is_password_autoset = False
# Set user details
avatar = self.user_data.get("user", {}).get("avatar", "")
first_name = self.user_data.get("user", {}).get("first_name", "")
last_name = self.user_data.get("user", {}).get("last_name", "")
user.avatar = avatar if avatar else ""
user.first_name = first_name if first_name else ""
user.last_name = last_name if last_name else ""
user.save()
# Download and upload avatar
avatar = self.user_data.get("user", {}).get("avatar", "")
if avatar:
avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
if avatar_asset:
user.avatar_asset = avatar_asset
# If avatar upload fails, set the avatar to the original URL
else:
user.avatar = avatar
# Create profile
Profile.objects.create(user=user)
@@ -1,7 +1,8 @@
# Python imports
import json
import os
import secrets
import random
import string
# Module imports
@@ -49,7 +50,13 @@ class MagicCodeProvider(CredentialAdapter):
def initiate(self):
## Generate a random token
token = str(secrets.randbelow(900000) + 100000)
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
@@ -101,7 +101,9 @@ class GiteaOAuthProvider(OauthAdapter):
else None
),
"refresh_token_expired_at": (
datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc)
datetime.fromtimestamp(
token_response.get("refresh_token_expired_at"), tz=pytz.utc
)
if token_response.get("refresh_token_expired_at")
else None
),
@@ -166,4 +168,4 @@ class GiteaOAuthProvider(OauthAdapter):
"is_password_autoset": True,
},
}
)
)
@@ -37,7 +37,9 @@ class GiteaOauthInitiateEndpoint(View):
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host(request=request, is_app=True), "?" + urlencode(params))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
@@ -49,7 +51,9 @@ class GiteaOauthInitiateEndpoint(View):
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host(request=request, is_app=True), "?" + urlencode(params))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -83,7 +87,9 @@ class GiteaCallbackEndpoint(View):
return HttpResponseRedirect(url)
try:
provider = GiteaOAuthProvider(request=request, code=code, callback=post_user_auth_workflow)
provider = GiteaOAuthProvider(
request=request, code=code, callback=post_user_auth_workflow
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
+1
View File
@@ -2,6 +2,7 @@
import io
import zipfile
from typing import List
from collections import defaultdict
import boto3
from botocore.client import Config
from uuid import UUID
-96
View File
@@ -1,96 +0,0 @@
# Python imports
import logging
from typing import Optional, Dict, Any
# Third party imports
from pymongo.collection import Collection
from celery import shared_task
# Django imports
from plane.settings.mongo import MongoConnection
from plane.utils.exception_logger import log_exception
from plane.db.models import APIActivityLog
logger = logging.getLogger("plane.worker")
def get_mongo_collection() -> Optional[Collection]:
"""
Returns the MongoDB collection for external API activity logs.
"""
if not MongoConnection.is_configured():
logger.info("MongoDB not configured")
return None
try:
return MongoConnection.get_collection("api_activity_logs")
except Exception as e:
logger.error(f"Error getting MongoDB collection: {str(e)}")
log_exception(e)
return None
def safe_decode_body(content: bytes) -> Optional[str]:
"""
Safely decodes request/response body content, handling binary data.
Returns "[Binary Content]" if the content is binary, or a string representation of the content.
Returns None if the content is None or empty.
"""
# If the content is None, return None
if content is None:
return None
# If the content is an empty bytes object, return None
if content == b"":
return None
# Check if content is binary by looking for common binary file signatures
if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"):
return "[Binary Content]"
try:
return content.decode("utf-8")
except UnicodeDecodeError:
return "[Could not decode content]"
def log_to_mongo(log_document: Dict[str, Any]) -> bool:
"""
Logs the request to MongoDB if available.
"""
mongo_collection = get_mongo_collection()
if mongo_collection is None:
logger.error("MongoDB not configured")
return False
try:
mongo_collection.insert_one(log_document)
return True
except Exception as e:
log_exception(e)
return False
def log_to_postgres(log_data: Dict[str, Any]) -> bool:
"""
Fallback to logging to PostgreSQL if MongoDB is unavailable.
"""
try:
APIActivityLog.objects.create(**log_data)
return True
except Exception as e:
log_exception(e)
return False
@shared_task
def process_logs(log_data: Dict[str, Any], mongo_log: Dict[str, Any]) -> None:
"""
Process logs to save to MongoDB or Postgres based on the configuration
"""
if MongoConnection.is_configured():
log_to_mongo(mongo_log)
else:
log_to_postgres(log_data)
@@ -88,6 +88,7 @@ def page_transaction(new_description_html, old_description_html, page_id):
has_existing_logs = PageLog.objects.filter(page_id=page_id).exists()
# Extract all components in a single pass (optimized)
old_components = extract_all_components(old_description_html)
new_components = extract_all_components(new_description_html)
@@ -124,9 +125,12 @@ def page_transaction(new_description_html, old_description_html, page_id):
)
)
# Bulk insert and cleanup
if new_transactions:
PageLog.objects.bulk_create(new_transactions, batch_size=50, ignore_conflicts=True)
PageLog.objects.bulk_create(
new_transactions, batch_size=50, ignore_conflicts=True
)
if deleted_transaction_ids:
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
+42 -74
View File
@@ -10,7 +10,6 @@ from datetime import timedelta
# Django imports
from django.conf import settings
from django.utils import timezone
from django.contrib.auth.hashers import make_password
# Third party imports
from celery import shared_task
@@ -35,8 +34,6 @@ from plane.db.models import (
CycleIssue,
ModuleIssue,
IssueView,
User,
BotTypeEnum,
)
logger = logging.getLogger("plane.worker")
@@ -64,7 +61,7 @@ def read_seed_file(filename):
return None
def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int, uuid.UUID]:
def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
"""Creates a project and associated members for a workspace.
Creates a new project using the workspace name and sets up all necessary
@@ -72,7 +69,7 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
Args:
workspace: The workspace to create the project in
bot_user: The bot user to use for creating the project
Returns:
A mapping of seed project IDs to actual project IDs
"""
@@ -99,7 +96,7 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
workspace=workspace,
name=workspace.name, # Use workspace name
identifier=project_identifier,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
# Enable all views in seed data
cycle_view=True,
module_view=True,
@@ -114,7 +111,7 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
member_id=workspace_member["member_id"],
role=workspace_member["role"],
workspace_id=workspace.id,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
for workspace_member in workspace_members
]
@@ -156,7 +153,7 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
"attachment_count": False,
"customer_request_count": True,
},
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
for workspace_member in workspace_members
]
@@ -168,15 +165,13 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
return projects_map
def create_project_states(
workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User
) -> Dict[int, uuid.UUID]:
def create_project_states(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
"""Creates states for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the states
Returns:
A mapping of seed state IDs to actual state IDs
"""
@@ -195,7 +190,7 @@ def create_project_states(
**state_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
state_map[state_id] = state.id
@@ -203,15 +198,13 @@ def create_project_states(
return state_map
def create_project_labels(
workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User
) -> Dict[int, uuid.UUID]:
def create_project_labels(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
"""Creates labels for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the labels
Returns:
A mapping of seed label IDs to actual label IDs
"""
@@ -228,7 +221,7 @@ def create_project_labels(
**label_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
label_map[label_id] = label.id
@@ -243,7 +236,6 @@ def create_project_issues(
labels_map: Dict[int, uuid.UUID],
cycles_map: Dict[int, uuid.UUID],
module_map: Dict[int, uuid.UUID],
bot_user: User,
) -> None:
"""Creates issues and their associated records for each project.
@@ -281,13 +273,13 @@ def create_project_issues(
state_id=states_map[state_id],
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
IssueSequence.objects.create(
issue=issue,
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
IssueActivity.objects.create(
@@ -296,7 +288,7 @@ def create_project_issues(
workspace_id=workspace.id,
comment="created the issue",
verb="created",
actor_id=bot_user.id,
actor_id=workspace.created_by_id,
epoch=time.time(),
)
@@ -307,7 +299,7 @@ def create_project_issues(
label_id=labels_map[label_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
# Create cycle issues
@@ -317,7 +309,7 @@ def create_project_issues(
cycle_id=cycles_map[cycle_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
# Create module issues
@@ -328,20 +320,19 @@ def create_project_issues(
module_id=module_map[module_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created")
return
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates pages for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the pages
"""
page_seeds = read_seed_file("pages.json")
@@ -360,9 +351,9 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us
description_html=page_seed.get("description_html", "<p></p>"),
description_binary=page_seed.get("description_binary", None),
description_stripped=page_seed.get("description_stripped", None),
created_by_id=bot_user.id,
updated_by_id=bot_user.id,
owned_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
updated_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Page {page_id} created")
@@ -371,27 +362,19 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us
workspace_id=workspace.id,
project_id=project_map[page_seed.get("project_id")],
page_id=page.id,
created_by_id=bot_user.id,
updated_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
updated_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Project Page {page_id} created")
return
def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> Dict[int, uuid.UUID]:
"""Creates cycles for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the cycles
Returns:
A mapping of seed cycle IDs to actual cycle IDs
"""
def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
# Create cycles
cycle_seeds = read_seed_file("cycles.json")
if not cycle_seeds:
return {}
return
cycle_map: Dict[int, uuid.UUID] = {}
@@ -420,8 +403,8 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_u
end_date=end_date,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
owned_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
cycle_map[cycle_id] = cycle.id
@@ -429,17 +412,16 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_u
return cycle_map
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates modules for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the modules
"""
module_seeds = read_seed_file("modules.json")
if not module_seeds:
return {}
return
module_map: Dict[int, uuid.UUID] = {}
@@ -456,20 +438,19 @@ def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_
target_date=end_date,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
module_map[module_id] = module.id
logger.info(f"Task: workspace_seed_task -> Module {module_id} created")
return module_map
def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates views for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the views
"""
view_seeds = read_seed_file("views.json")
@@ -482,8 +463,8 @@ def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us
**view_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
owned_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
@@ -505,42 +486,29 @@ def workspace_seed(workspace_id: uuid.UUID) -> None:
# Get the workspace
workspace = Workspace.objects.get(id=workspace_id)
# Create a bot user for creating all the workspace data
bot_user = User.objects.create(
username=f"bot_user_{workspace.id}",
display_name="Plane",
first_name="Plane",
last_name="",
is_bot=True,
bot_type=BotTypeEnum.WORKSPACE_SEED,
email=f"bot_user_{workspace.id}@plane.so",
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
# Create a project with the same name as workspace
project_map = create_project_and_member(workspace, bot_user)
project_map = create_project_and_member(workspace)
# Create project states
state_map = create_project_states(workspace, project_map, bot_user)
state_map = create_project_states(workspace, project_map)
# Create project labels
label_map = create_project_labels(workspace, project_map, bot_user)
label_map = create_project_labels(workspace, project_map)
# Create project cycles
cycle_map = create_cycles(workspace, project_map, bot_user)
cycle_map = create_cycles(workspace, project_map)
# Create project modules
module_map = create_modules(workspace, project_map, bot_user)
module_map = create_modules(workspace, project_map)
# create project issues
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map, bot_user)
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map)
# create project views
create_views(workspace, project_map, bot_user)
create_views(workspace, project_map)
# create project pages
create_pages(workspace, project_map, bot_user)
create_pages(workspace, project_map)
logger.info(f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully")
return
-27
View File
@@ -188,30 +188,3 @@ class ChangeTrackerMixin:
all non-deferred fields).
"""
return self._original_values
def save(self, *args: Any, **kwargs: Any) -> None:
"""
Override save to automatically capture changed fields and reset tracking.
Before saving, the current changed_fields are captured and stored in
_changes_on_save. After saving, the tracked fields are reset so
that subsequent saves correctly detect changes relative to the last
saved state, not the original load-time state.
Models that need to access the changed fields after save (e.g., for
syncing related models) can use self._changes_on_save.
"""
self._changes_on_save = self.changed_fields
super().save(*args, **kwargs)
self._reset_tracked_fields()
def _reset_tracked_fields(self) -> None:
"""
Reset the tracked field values to the current state.
This is called automatically after save() to ensure that subsequent
saves correctly detect changes relative to the last saved state,
rather than the original load-time state.
"""
self._original_values = {}
self._track_fields()
+1 -2
View File
@@ -52,13 +52,12 @@ from .project import (
ProjectIdentifier,
ProjectMember,
ProjectMemberInvite,
ProjectNetwork,
ProjectPublicMember,
)
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State, StateGroup, DEFAULT_STATES
from .user import Account, Profile, User, BotTypeEnum
from .user import Account, Profile, User
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .workspace import (
+27 -25
View File
@@ -207,35 +207,39 @@ class Issue(ProjectBaseModel):
if self._state.adding:
with transaction.atomic():
# Create a lock for this specific project using a transaction-level advisory lock
# Create a lock for this specific project using an advisory lock
# This ensures only one transaction per project can execute this code at a time
# The lock is automatically released when the transaction ends
lock_key = convert_uuid_to_integer(self.project.id)
with connection.cursor() as cursor:
# Get an exclusive transaction-level lock using the project ID as the lock key
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
# Get an exclusive lock using the project ID as the lock key
cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key])
# Get the last sequence for the project
last_sequence = IssueSequence.objects.filter(project=self.project).aggregate(
largest=models.Max("sequence")
)["largest"]
self.sequence_id = last_sequence + 1 if last_sequence else 1
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate(
largest=models.Max("sort_order")
)["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
try:
# Get the last sequence for the project
last_sequence = IssueSequence.objects.filter(project=self.project).aggregate(
largest=models.Max("sequence")
)["largest"]
self.sequence_id = last_sequence + 1 if last_sequence else 1
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate(
largest=models.Max("sort_order")
)["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(Issue, self).save(*args, **kwargs)
super(Issue, self).save(*args, **kwargs)
IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project)
IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project)
finally:
# Release the lock
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
else:
# Strip the html tags using html parser
self.description_stripped = (
@@ -509,12 +513,10 @@ class IssueComment(ChangeTrackerMixin, ProjectBaseModel):
"comment_json": "description_json",
}
# Use _changes_on_save which is captured by ChangeTrackerMixin.save()
# before the tracked fields are reset
changed_fields = {
desc_field: getattr(self, comment_field)
for comment_field, desc_field in field_mapping.items()
if comment_field in self._changes_on_save
if self.has_changed(comment_field)
}
# Update description only if comment fields changed
-4
View File
@@ -35,10 +35,6 @@ def get_mobile_default_onboarding():
}
class BotTypeEnum(models.TextChoices):
WORKSPACE_SEED = "WORKSPACE_SEED", "Workspace Seed"
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
username = models.CharField(max_length=128, unique=True)
+2 -4
View File
@@ -134,10 +134,8 @@ class InstanceAdminSignUpEndpoint(View):
},
)
url = urljoin(
base_host(
request=request,
is_admin=True,
),
base_host(request=request, is_admin=True, ),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
@@ -175,7 +175,6 @@ class InstanceEndpoint(BaseAPIView):
data["app_base_url"] = settings.APP_BASE_URL
data["instance_changelog_url"] = settings.INSTANCE_CHANGELOG_URL
data["is_self_managed"] = settings.IS_SELF_MANAGED
instance_data = serializer.data
instance_data["workspaces_exist"] = Workspace.objects.count() >= 1
+19 -42
View File
@@ -4,15 +4,13 @@ import time
# Django imports
from django.http import HttpRequest
from django.utils import timezone
# Third party imports
from rest_framework.request import Request
# Module imports
from plane.utils.ip_address import get_client_ip
from plane.utils.exception_logger import log_exception
from plane.bgtasks.logger_task import process_logs
from plane.db.models import APIActivityLog
api_logger = logging.getLogger("plane.api.request")
@@ -72,10 +70,6 @@ class RequestLoggerMiddleware:
class APITokenLogMiddleware:
"""
Middleware to log External API requests to MongoDB or PostgreSQL.
"""
def __init__(self, get_response):
self.get_response = get_response
@@ -110,41 +104,24 @@ class APITokenLogMiddleware:
def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
# If the API key is present, log the request
if api_key:
try:
APIActivityLog.objects.create(
token_identifier=api_key,
path=request.path,
method=request.method,
query_params=request.META.get("QUERY_STRING", ""),
headers=str(request.headers),
body=(self._safe_decode_body(request_body) if request_body else None),
response_body=(self._safe_decode_body(response.content) if response.content else None),
response_code=response.status_code,
ip_address=get_client_ip(request=request),
user_agent=request.META.get("HTTP_USER_AGENT", None),
)
# If the API key is not present, return
if not api_key:
return
try:
log_data = {
"token_identifier": api_key,
"path": request.path,
"method": request.method,
"query_params": request.META.get("QUERY_STRING", ""),
"headers": str(request.headers),
"body": self._safe_decode_body(request_body) if request_body else None,
"response_body": self._safe_decode_body(response.content) if response.content else None,
"response_code": response.status_code,
"ip_address": get_client_ip(request=request),
"user_agent": request.META.get("HTTP_USER_AGENT", None),
}
user_id = (
str(request.user.id)
if getattr(request, "user") and getattr(request.user, "is_authenticated", False)
else None
)
# Additional fields for MongoDB
mongo_log = {
**log_data,
"created_at": timezone.now(),
"updated_at": timezone.now(),
"created_by": user_id,
"updated_by": user_id,
}
process_logs.delay(log_data=log_data, mongo_log=mongo_log)
except Exception as e:
log_exception(e)
except Exception as e:
api_logger.exception(e)
# If the token does not exist, you can decide whether to log this as an invalid attempt
return None
+3 -6
View File
@@ -25,9 +25,6 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", "0"))
# Self-hosted mode
IS_SELF_MANAGED = True
# Allowed Hosts
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
@@ -36,7 +33,6 @@ INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.staticfiles",
# Inhouse apps
"plane.analytics",
"plane.app",
@@ -59,7 +55,6 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"plane.authentication.middleware.session.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@@ -74,7 +69,9 @@ MIDDLEWARE = [
# Rest Framework settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",),
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",),
"DEFAULT_THROTTLE_RATES": {
"anon": "30/minute",
+3 -32
View File
@@ -29,8 +29,6 @@ class S3Storage(S3Boto3Storage):
self.aws_region = os.environ.get("AWS_REGION")
# Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL
self.aws_s3_endpoint_url = os.environ.get("AWS_S3_ENDPOINT_URL") or os.environ.get("MINIO_ENDPOINT_URL")
# Use the SIGNED_URL_EXPIRATION environment variable for the expiration time (default: 3600 seconds)
self.signed_url_expiration = int(os.environ.get("SIGNED_URL_EXPIRATION", "3600"))
if os.environ.get("USE_MINIO") == "1":
# Determine protocol based on environment variable
@@ -58,10 +56,8 @@ class S3Storage(S3Boto3Storage):
config=boto3.session.Config(signature_version="s3v4"),
)
def generate_presigned_post(self, object_name, file_type, file_size, expiration=None):
def generate_presigned_post(self, object_name, file_type, file_size, expiration=3600):
"""Generate a presigned URL to upload an S3 object"""
if expiration is None:
expiration = self.signed_url_expiration
fields = {"Content-Type": file_type}
conditions = [
@@ -108,15 +104,13 @@ class S3Storage(S3Boto3Storage):
def generate_presigned_url(
self,
object_name,
expiration=None,
expiration=3600,
http_method="GET",
disposition="inline",
filename=None,
):
"""Generate a presigned URL to share an S3 object"""
if expiration is None:
expiration = self.signed_url_expiration
content_disposition = self._get_content_disposition(disposition, filename)
"""Generate a presigned URL to share an S3 object"""
try:
response = self.s3_client.generate_presigned_url(
"get_object",
@@ -164,26 +158,3 @@ class S3Storage(S3Boto3Storage):
return None
return response
def upload_file(
self,
file_obj,
object_name: str,
content_type: str = None,
extra_args: dict = {},
) -> bool:
"""Upload a file directly to S3"""
try:
if content_type:
extra_args["ContentType"] = content_type
self.s3_client.upload_fileobj(
file_obj,
self.aws_storage_bucket_name,
object_name,
ExtraArgs=extra_args,
)
return True
except ClientError as e:
log_exception(e)
return False
+5 -6
View File
@@ -11,12 +11,11 @@ from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.db.models import DeployBoard, FileAsset
from plane.settings.storage import S3Storage
# Module imports
from .base import BaseAPIView
from plane.db.models import DeployBoard, FileAsset
from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
class EntityAssetEndpoint(BaseAPIView):
@@ -168,7 +167,7 @@ class EntityAssetEndpoint(BaseAPIView):
class AssetRestoreEndpoint(BaseAPIView):
"""Endpoint to restore a deleted assets."""
def post(self, request, anchor, pk):
def post(self, request, anchor, asset_id):
# Get the deploy board
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first()
# Check if the project is published
@@ -176,7 +175,7 @@ class AssetRestoreEndpoint(BaseAPIView):
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
# Get the asset
asset = FileAsset.all_objects.get(id=pk, workspace=deploy_board.workspace)
asset = FileAsset.all_objects.get(id=asset_id, workspace=deploy_board.workspace)
asset.is_deleted = False
asset.deleted_at = None
asset.save(update_fields=["is_deleted", "deleted_at"])
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

@@ -1,7 +1,8 @@
import pytest
from rest_framework import status
from django.db import IntegrityError
from django.utils import timezone
from datetime import timedelta
from datetime import datetime, timedelta
from uuid import uuid4
from plane.db.models import Cycle, Project, ProjectMember
@@ -57,6 +58,8 @@ def create_cycle(db, project, create_user):
)
@pytest.mark.contract
class TestCycleListCreateAPIEndpoint:
"""Test Cycle List and Create API Endpoint"""
@@ -82,6 +85,7 @@ class TestCycleListCreateAPIEndpoint:
assert created_cycle.project == project
assert created_cycle.owned_by_id is not None
@pytest.mark.django_db
def test_create_cycle_invalid_data(self, api_key_client, workspace, project):
"""Test cycle creation with invalid data"""
@@ -193,7 +197,7 @@ class TestCycleListCreateAPIEndpoint:
# Create cycles in different states
now = timezone.now()
# Current cycle (started but not ended)
Cycle.objects.create(
name="Current Cycle",
@@ -203,7 +207,7 @@ class TestCycleListCreateAPIEndpoint:
end_date=now + timedelta(days=6),
owned_by=create_user,
)
# Upcoming cycle
Cycle.objects.create(
name="Upcoming Cycle",
@@ -213,7 +217,7 @@ class TestCycleListCreateAPIEndpoint:
end_date=now + timedelta(days=8),
owned_by=create_user,
)
# Completed cycle
Cycle.objects.create(
name="Completed Cycle",
@@ -223,7 +227,7 @@ class TestCycleListCreateAPIEndpoint:
end_date=now - timedelta(days=3),
owned_by=create_user,
)
# Draft cycle
Cycle.objects.create(
name="Draft Cycle",
@@ -316,9 +320,7 @@ class TestCycleDetailAPIEndpoint:
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]
@pytest.mark.django_db
def test_update_cycle_with_external_id_conflict(
self, api_key_client, workspace, project, create_cycle, create_user
):
def test_update_cycle_with_external_id_conflict(self, api_key_client, workspace, project, create_cycle, create_user ):
"""Test cycle update with conflicting external ID"""
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
@@ -361,7 +363,7 @@ class TestCycleDetailAPIEndpoint:
response = api_key_client.get(url)
assert response.status_code == status.HTTP_200_OK
# Check that metrics are included in response
cycle_data = response.data
assert "total_issues" in cycle_data
@@ -370,11 +372,11 @@ class TestCycleDetailAPIEndpoint:
assert "started_issues" in cycle_data
assert "unstarted_issues" in cycle_data
assert "backlog_issues" in cycle_data
# All should be 0 for a new cycle
assert cycle_data["total_issues"] == 0
assert cycle_data["completed_issues"] == 0
assert cycle_data["cancelled_issues"] == 0
assert cycle_data["started_issues"] == 0
assert cycle_data["unstarted_issues"] == 0
assert cycle_data["backlog_issues"] == 0
assert cycle_data["backlog_issues"] == 0
@@ -10,7 +10,9 @@ class TestLabelSerializer:
@pytest.mark.django_db
def test_label_serializer_create_valid_data(self, db, workspace):
"""Test creating a label with valid data"""
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
project = Project.objects.create(
name="Test Project", identifier="TEST", workspace=workspace
)
serializer = LabelSerializer(
data={"name": "Test Label"},
@@ -28,10 +30,14 @@ class TestLabelSerializer:
@pytest.mark.django_db
def test_label_serializer_create_duplicate_name(self, db, workspace):
"""Test creating a label with a duplicate name"""
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
project = Project.objects.create(
name="Test Project", identifier="TEST", workspace=workspace
)
Label.objects.create(name="Test Label", project=project)
serializer = LabelSerializer(data={"name": "Test Label"}, context={"project_id": project.id})
serializer = LabelSerializer(
data={"name": "Test Label"}, context={"project_id": project.id}
)
assert not serializer.is_valid()
assert serializer.errors == {"name": ["LABEL_NAME_ALREADY_EXISTS"]}
@@ -1,202 +0,0 @@
import os
from unittest.mock import Mock, patch
import pytest
from plane.settings.storage import S3Storage
@pytest.mark.unit
class TestS3StorageSignedURLExpiration:
"""Test the configurable signed URL expiration in S3Storage"""
@patch.dict(os.environ, {}, clear=True)
@patch("plane.settings.storage.boto3")
def test_default_expiration_without_env_variable(self, mock_boto3):
"""Test that default expiration is 3600 seconds when env variable is not set"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance without SIGNED_URL_EXPIRATION env variable
storage = S3Storage()
# Assert default expiration is 3600
assert storage.signed_url_expiration == 3600
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "30"}, clear=True)
@patch("plane.settings.storage.boto3")
def test_custom_expiration_with_env_variable(self, mock_boto3):
"""Test that expiration is read from SIGNED_URL_EXPIRATION env variable"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Assert expiration is 30
assert storage.signed_url_expiration == 30
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "300"}, clear=True)
@patch("plane.settings.storage.boto3")
def test_custom_expiration_multiple_values(self, mock_boto3):
"""Test that expiration works with different custom values"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance with SIGNED_URL_EXPIRATION=300
storage = S3Storage()
# Assert expiration is 300
assert storage.signed_url_expiration == 300
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_post_uses_default_expiration(self, mock_boto3):
"""Test that generate_presigned_post uses the configured default expiration"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_post.return_value = {
"url": "https://test-url.com",
"fields": {},
}
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance
storage = S3Storage()
# Call generate_presigned_post without explicit expiration
storage.generate_presigned_post("test-object", "image/png", 1024)
# Assert that the boto3 method was called with the default expiration (3600)
mock_s3_client.generate_presigned_post.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
assert call_kwargs["ExpiresIn"] == 3600
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
"SIGNED_URL_EXPIRATION": "60",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_post_uses_custom_expiration(self, mock_boto3):
"""Test that generate_presigned_post uses custom expiration from env variable"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_post.return_value = {
"url": "https://test-url.com",
"fields": {},
}
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=60
storage = S3Storage()
# Call generate_presigned_post without explicit expiration
storage.generate_presigned_post("test-object", "image/png", 1024)
# Assert that the boto3 method was called with custom expiration (60)
mock_s3_client.generate_presigned_post.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
assert call_kwargs["ExpiresIn"] == 60
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_url_uses_default_expiration(self, mock_boto3):
"""Test that generate_presigned_url uses the configured default expiration"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance
storage = S3Storage()
# Call generate_presigned_url without explicit expiration
storage.generate_presigned_url("test-object")
# Assert that the boto3 method was called with the default expiration (3600)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 3600
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
"SIGNED_URL_EXPIRATION": "30",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_url_uses_custom_expiration(self, mock_boto3):
"""Test that generate_presigned_url uses custom expiration from env variable"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Call generate_presigned_url without explicit expiration
storage.generate_presigned_url("test-object")
# Assert that the boto3 method was called with custom expiration (30)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 30
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
"SIGNED_URL_EXPIRATION": "30",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_explicit_expiration_overrides_default(self, mock_boto3):
"""Test that explicit expiration parameter overrides the default"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Call generate_presigned_url with explicit expiration=120
storage.generate_presigned_url("test-object", expiration=120)
# Assert that the boto3 method was called with explicit expiration (120)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 120
+3 -1
View File
@@ -56,7 +56,9 @@ def validate_binary_data(data):
# Check for suspicious text patterns (HTML/JS)
try:
decoded_text = binary_data.decode("utf-8", errors="ignore")[:200]
if any(pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS):
if any(
pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS
):
return False, "Binary data contains suspicious content patterns"
except Exception:
pass # Binary data might not be decodable as text, which is fine
+18 -6
View File
@@ -51,7 +51,9 @@ def transfer_cycle_issues(
dict: Response data with success or error message
"""
# Get the new cycle
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
new_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
).first()
# Check if new cycle is already completed
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
@@ -214,7 +216,9 @@ def transfer_cycle_issues(
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar_url": item.get("avatar_url"),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
@@ -306,7 +310,9 @@ def transfer_cycle_issues(
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
)
.annotate(
completed_issues=Count(
"id",
@@ -354,7 +360,9 @@ def transfer_cycle_issues(
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
)
.annotate(
completed_issues=Count(
"id",
@@ -401,7 +409,9 @@ def transfer_cycle_issues(
)
# Get the current cycle and save progress snapshot
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
current_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.total_issues,
@@ -451,7 +461,9 @@ def transfer_cycle_issues(
)
# Bulk update cycle issues
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
)
# Capture Issue Activity
issue_activity.delay(
-2
View File
@@ -140,7 +140,6 @@ from .examples import (
WORKSPACE_MEMBER_EXAMPLE,
PROJECT_MEMBER_EXAMPLE,
CYCLE_ISSUE_EXAMPLE,
STICKY_EXAMPLE,
)
# Helper decorators
@@ -293,7 +292,6 @@ __all__ = [
"WORKSPACE_MEMBER_EXAMPLE",
"PROJECT_MEMBER_EXAMPLE",
"CYCLE_ISSUE_EXAMPLE",
"STICKY_EXAMPLE",
# Decorators
"workspace_docs",
"project_docs",
@@ -262,19 +262,3 @@ def state_docs(**kwargs):
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
def sticky_docs(**kwargs):
"""Decorator for sticky management endpoints"""
defaults = {
"tags": ["Stickies"],
"summary": "Endpoints for sticky create/update/delete and fetch sticky details",
"parameters": [WORKSPACE_SLUG_PARAMETER],
"responses": {
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
-17
View File
@@ -672,15 +672,6 @@ CYCLE_ISSUE_EXAMPLE = OpenApiExample(
},
)
STICKY_EXAMPLE = OpenApiExample(
name="Sticky",
value={
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Sticky 1",
"description_html": "<p>Sticky 1 description</p>",
"created_at": "2024-01-01T10:30:00Z",
},
)
# Sample data for different entity types
SAMPLE_ISSUE = {
@@ -790,13 +781,6 @@ SAMPLE_CYCLE_ISSUE = {
"created_at": "2024-01-01T10:30:00Z",
}
SAMPLE_STICKY = {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Sticky 1",
"description_html": "<p>Sticky 1 description</p>",
"created_at": "2024-01-01T10:30:00Z",
}
# Mapping of schema types to sample data
SCHEMA_EXAMPLES = {
"Issue": SAMPLE_ISSUE,
@@ -811,7 +795,6 @@ SCHEMA_EXAMPLES = {
"Activity": SAMPLE_ACTIVITY,
"Intake": SAMPLE_INTAKE,
"CycleIssue": SAMPLE_CYCLE_ISSUE,
"Sticky": SAMPLE_STICKY,
}
+5 -5
View File
@@ -1,13 +1,13 @@
# base requirements
# django
Django==4.2.27
Django==4.2.26
# rest framework
djangorestframework==3.15.2
# postgres
psycopg==3.3.0
psycopg-binary==3.3.0
psycopg-c==3.3.0
psycopg==3.2.9
psycopg-binary==3.2.9
psycopg-c==3.2.9
dj-database-url==2.1.0
# mongo
pymongo==4.6.3
@@ -21,7 +21,7 @@ celery==5.4.0
django_celery_beat==2.6.0
django-celery-results==2.5.1
# file serve
whitenoise==6.11.0
whitenoise==6.6.0
# fake data
faker==25.0.0
# filters
+4
View File
@@ -0,0 +1,4 @@
.turbo/*
out/*
dist/*
public/*
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/server.js"],
};
+5 -9
View File
@@ -1,10 +1,6 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
.next
.turbo
out/
pnpm-lock.yaml
storybook-static/
dist/
build/
node_modules/
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5",
"plugins": ["@prettier/plugin-oxc"]
}
+3 -5
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.6.3
ARG TURBO_VERSION=2.5.6
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . .
RUN turbo prune --scope=live --docker
@@ -34,13 +34,11 @@ COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
# Copy full directory structure before fetch to ensure all package.json files are available
# Build the project and its dependencies
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
ENV TURBO_TELEMETRY_DISABLED=1
+10 -7
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.2.0",
"version": "1.1.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./dist/start.mjs",
@@ -15,16 +15,16 @@
"build": "tsc --noEmit && tsdown",
"dev": "tsdown --watch --onSuccess \"node --env-file=.env .\"",
"start": "node --env-file=.env .",
"check:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --max-warnings=160",
"check:lint": "eslint . --max-warnings 10",
"check:types": "tsc --noEmit",
"check:format": "prettier . --cache --check",
"fix:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --fix --max-warnings=160",
"fix:format": "prettier . --cache --write",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"author": "Plane Software Inc.",
"dependencies": {
"@dotenvx/dotenvx": "catalog:",
"@dotenvx/dotenvx": "^1.49.0",
"@hocuspocus/extension-database": "2.15.2",
"@hocuspocus/extension-logger": "2.15.2",
"@hocuspocus/extension-redis": "2.15.2",
@@ -41,7 +41,8 @@
"axios": "catalog:",
"compression": "1.8.1",
"cors": "^2.8.5",
"express": "catalog:",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "5.7.0",
@@ -53,7 +54,9 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@prettier/plugin-oxc": "0.0.4",
"@types/compression": "1.8.1",
"@types/cors": "^2.8.17",
"@types/express": "4.17.23",
+4 -25
View File
@@ -20,32 +20,13 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
try {
const service = getPageService(context.documentType, context);
// fetch details
const response = (await service.fetchDescriptionBinary(pageId)) as Buffer;
const response = await service.fetchDescriptionBinary(pageId);
const binaryData = new Uint8Array(response);
// if binary data is empty, convert HTML to binary data
if (binaryData.byteLength === 0) {
const pageDetails = await service.fetchDetails(pageId);
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(
pageDetails.description_html ?? "<p></p>",
pageDetails.name
);
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
if (convertedBinaryData) {
// save the converted binary data back to the database
try {
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
convertedBinaryData,
true
);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await service.updateDescriptionBinary(pageId, payload);
} catch (e) {
const error = new AppError(e);
logger.error("Failed to save binary after first convertion from html:", error);
}
return convertedBinaryData;
}
}
@@ -71,10 +52,8 @@ const storeDocument = async ({
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
pageBinaryData,
true
);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData);
// create payload
const payload = {
description_binary: contentBinaryEncoded,
@@ -12,7 +12,7 @@ export class ForceCloseHandler implements Extension {
priority = 999;
async onConfigure({ instance }: onConfigurePayload) {
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis);
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
if (!redisExt) {
logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found");
@@ -149,7 +149,7 @@ export const forceCloseDocumentAcrossServers = async (
logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`);
// STEP 4: BROADCAST TO OTHER SERVERS
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis);
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
if (redisExt) {
const commandData: ForceCloseCommandData = {
+1 -9
View File
@@ -1,13 +1,5 @@
import { Database } from "./database";
import { ForceCloseHandler } from "./force-close-handler";
import { Logger } from "./logger";
import { Redis } from "./redis";
import { TitleSyncExtension } from "./title-sync";
export const getExtensions = () => [
new Logger(),
new Database(),
new Redis(),
new TitleSyncExtension(),
new ForceCloseHandler(), // Must be after Redis to receive broadcasts
];
export const getExtensions = () => [new Logger(), new Database(), new Redis()];

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