Compare commits

..

1 Commits

Author SHA1 Message Date
Aaryan Khandelwal e689f00fb2 chore: export comment display component props 2025-11-25 19:07:06 +05:30
750 changed files with 9377 additions and 20111 deletions
@@ -31,7 +31,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12.x"
python-version: "3.x"
- name: Install Pylint
run: python -m pip install ruff
- name: Install API Dependencies
@@ -43,9 +43,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Affected
run: pnpm turbo run build --affected
- name: Lint Affected
run: pnpm turbo run check:lint --affected
@@ -54,3 +51,6 @@ jobs:
- name: Check Affected types
run: pnpm turbo run check:types --affected
- name: Build Affected
run: pnpm turbo run build --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.1",
"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 . --max-warnings=485",
"check:lint": "eslint . --max-warnings 19",
"check:types": "react-router typegen && tsc --noEmit",
"check:format": "prettier --check .",
"fix:lint": "eslint . --fix --max-warnings=485",
"fix:format": "prettier --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/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.2.1",
"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,
+1 -47
View File
@@ -1,7 +1,7 @@
# Module imports
from .base import BaseSerializer
from .issue import IssueExpandSerializer
from plane.db.models import IntakeIssue, Issue, State, StateGroup
from plane.db.models import IntakeIssue, Issue
from rest_framework import serializers
@@ -103,52 +103,6 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
"updated_at",
]
def validate(self, attrs):
"""
Validate that if status is being changed to accepted (1),
the project has a default state to transition to.
"""
# Check if status is being updated to accepted
if attrs.get("status") == 1:
intake_issue = self.instance
issue = intake_issue.issue
# Check if issue is in TRIAGE state
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Verify default state exists before allowing the update
default_state = State.objects.filter(
workspace=intake_issue.workspace, project=intake_issue.project, default=True
).first()
if not default_state:
raise serializers.ValidationError(
{"status": "Cannot accept intake issue: No default state found for the project"}
)
return attrs
def update(self, instance, validated_data):
"""
Update intake issue and transition associated issue state if accepted.
"""
# Update the intake issue with validated data
instance = super().update(instance, validated_data)
# If status is accepted (1), update the associated issue state from TRIAGE to default
if validated_data.get("status") == 1:
issue = instance.issue
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Get the default project state
default_state = State.objects.filter(
workspace=instance.workspace, project=instance.project, default=True
).first()
if default_state:
issue.state = default_state
issue.save()
return instance
class IssueDataSerializer(serializers.Serializer):
"""
-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
+2 -2
View File
@@ -17,7 +17,7 @@ from plane.utils.content_validator import (
from .base import BaseSerializer
class ProjectCreateSerializer(BaseSerializer):
class ProjectCreateSerializer(BaseSerializer):
"""
Serializer for creating projects with workspace validation.
@@ -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")
+1 -5
View File
@@ -1,7 +1,6 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import State, StateGroup
from rest_framework import serializers
from plane.db.models import State
class StateSerializer(BaseSerializer):
@@ -16,9 +15,6 @@ class StateSerializer(BaseSerializer):
# If the default is being provided then make all other states default False
if data.get("default", False):
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
if data.get("group", None) == StateGroup.TRIAGE.value:
raise serializers.ValidationError("Cannot create triage state")
return data
class Meta:
-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 -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
+60 -63
View File
@@ -23,7 +23,7 @@ from plane.api.serializers import (
)
from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State, StateGroup
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.db.models.intake import SourceType
@@ -165,20 +165,6 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
# get the triage state
triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project_id=project_id,
workspace_id=project.workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -186,7 +172,6 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
priority=request.data.get("issue", {}).get("priority", "none"),
project_id=project_id,
state_id=triage_state.id,
)
# create an intake issue
@@ -335,10 +320,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
# Get issue data
issue_data = request.data.pop("issue", False)
issue_serializer = None
intake_serializer = None
# Validate issue data if provided
if bool(issue_data):
issue = Issue.objects.annotate(
label_ids=Coalesce(
@@ -362,7 +344,6 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
Value([], output_field=ArrayField(UUIDField())),
),
).get(pk=issue_id, workspace__slug=slug, project_id=project_id)
# Only allow guests to edit name and description
if project_member.role <= 5:
issue_data = {
@@ -373,55 +354,71 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
if not issue_serializer.is_valid():
if issue_serializer.is_valid():
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
intake=(intake_issue.id),
)
issue_serializer.save()
else:
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Only project admins and members can edit intake issue attributes
if project_member.role > 15:
intake_serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
if not intake_serializer.is_valid():
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Both serializers are valid, now save them
if issue_serializer:
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
intake=str(intake_issue.id),
)
issue_serializer.save()
# Save intake issue (state transition happens in serializer's update method)
if intake_serializer:
serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
intake_serializer.save()
# create a activity for status change
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
if serializer.is_valid():
serializer.save()
# Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
if state is not None:
issue.state = state
issue.save()
# Update the issue state if it is accepted
if serializer.data["status"] in [1]:
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
# Update the issue state only if it is in triage state
if issue.state.is_triage:
# Move to default state
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
if state is not None:
issue.state = state
issue.save()
# create a activity for status change
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
serializer = IntakeIssueSerializer(intake_issue)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
+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,
)
+36 -2
View File
@@ -24,7 +24,6 @@ from plane.db.models import (
DeployBoard,
ProjectMember,
State,
DEFAULT_STATES,
Workspace,
UserFavorite,
)
@@ -233,6 +232,41 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
user_id=serializer.instance.project_lead,
)
# Default states
states = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},
]
State.objects.bulk_create(
[
State(
@@ -245,7 +279,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
default=state.get("default", False),
created_by=request.user,
)
for state in DEFAULT_STATES
for state in states
]
)
-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)
@@ -37,7 +37,6 @@ from .project import (
ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer,
ProjectMemberRoleSerializer,
ProjectMemberPreferenceSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, ViewIssueListSerializer
+1 -44
View File
@@ -7,7 +7,7 @@ from .issue import IssueIntakeSerializer, LabelLiteSerializer, IssueDetailSerial
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
from plane.db.models import Intake, IntakeIssue, Issue, StateGroup, State
from plane.db.models import Intake, IntakeIssue, Issue
class IntakeSerializer(BaseSerializer):
@@ -36,49 +36,6 @@ class IntakeIssueSerializer(BaseSerializer):
]
read_only_fields = ["project", "workspace"]
def validate(self, attrs):
"""
Validate that if status is being changed to accepted (1),
the project has a default state to transition to.
"""
# Check if status is being updated to accepted
if attrs.get("status") == 1:
intake_issue = self.instance
issue = intake_issue.issue
# Check if issue is in TRIAGE state
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Verify default state exists before allowing the update
default_state = State.objects.filter(
workspace=intake_issue.workspace, project=intake_issue.project, default=True
).first()
if not default_state:
raise serializers.ValidationError(
{"status": "Cannot accept intake issue: No default state found for the project"}
)
return attrs
def update(self, instance, validated_data):
# Update the intake issue
instance = super().update(instance, validated_data)
# If status is accepted (1), transition the issue state from TRIAGE to default
if validated_data.get("status") == 1:
issue = instance.issue
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Get the default project state
default_state = State.objects.filter(
workspace=instance.workspace, project=instance.project, default=True
).first()
if default_state:
issue.state = default_state
issue.save()
return instance
def to_representation(self, instance):
# Pass the annotated fields to the Issue instance if they exist
if hasattr(instance, "label_ids"):
+2 -13
View File
@@ -78,7 +78,7 @@ class IssueProjectLiteSerializer(BaseSerializer):
class IssueCreateSerializer(BaseSerializer):
# ids
state_id = serializers.PrimaryKeyRelatedField(
source="state", queryset=State.all_state_objects.all(), required=False, allow_null=True
source="state", queryset=State.objects.all(), required=False, allow_null=True
)
parent_id = serializers.PrimaryKeyRelatedField(
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
@@ -117,9 +117,6 @@ class IssueCreateSerializer(BaseSerializer):
return data
def validate(self, attrs):
allow_triage = self.context.get("allow_triage_state", False)
state_manager = State.triage_objects if allow_triage else State.objects
if (
attrs.get("start_date", None) is not None
and attrs.get("target_date", None) is not None
@@ -163,7 +160,7 @@ class IssueCreateSerializer(BaseSerializer):
# Check state is from the project only else raise validation error
if (
attrs.get("state")
and not state_manager.filter(
and not State.objects.filter(
project_id=self.context.get("project_id"),
pk=attrs.get("state").id,
).exists()
@@ -798,14 +795,6 @@ class IssueSerializer(DynamicBaseSerializer):
]
read_only_fields = fields
def validate(self, data):
if (
data.get("state_id")
and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state_id")).exists()
):
raise serializers.ValidationError("State is not valid please pass a valid state_id")
return data
class IssueListDetailSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
-12
View File
@@ -142,18 +142,6 @@ class ProjectMemberSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberPreferenceSerializer(BaseSerializer):
class Meta:
model = ProjectMember
fields = ["preferences", "project_id", "member_id", "workspace_id"]
def validate_preferences(self, value):
preferences = self.instance.preferences
preferences.update(value)
return preferences
class ProjectMemberAdminSerializer(BaseSerializer):
workspace = WorkspaceLiteSerializer(read_only=True)
project = ProjectLiteSerializer(read_only=True)
+1 -6
View File
@@ -2,7 +2,7 @@
from .base import BaseSerializer
from rest_framework import serializers
from plane.db.models import State, StateGroup
from plane.db.models import State
class StateSerializer(BaseSerializer):
@@ -24,11 +24,6 @@ class StateSerializer(BaseSerializer):
]
read_only_fields = ["workspace", "project"]
def validate(self, attrs):
if attrs.get("group") == StateGroup.TRIAGE.value:
raise serializers.ValidationError("Cannot create triage state")
return attrs
class StateLiteSerializer(BaseSerializer):
class Meta:
+6 -6
View File
@@ -11,9 +11,9 @@ from plane.app.views import (
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
UserProjectInvitationsViewset,
ProjectPublicCoverImagesEndpoint,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectMemberPreferenceEndpoint,
)
@@ -105,6 +105,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"}),
@@ -120,9 +125,4 @@ urlpatterns = [
ProjectArchiveUnarchiveEndpoint.as_view(),
name="project-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/preferences/member/<uuid:member_id>/",
ProjectMemberPreferenceEndpoint.as_view(),
name="project-member-preference",
),
]
+1 -6
View File
@@ -1,7 +1,7 @@
from django.urls import path
from plane.app.views import StateViewSet, IntakeStateEndpoint
from plane.app.views import StateViewSet
urlpatterns = [
@@ -15,11 +15,6 @@ urlpatterns = [
StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="project-state",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-state/",
IntakeStateEndpoint.as_view(),
name="intake-state",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/mark-default/",
StateViewSet.as_view({"post": "mark_as_default"}),
+5
View File
@@ -253,4 +253,9 @@ urlpatterns = [
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
path(
"workspaces/<str:slug>/sidebar-preferences/<str:key>/",
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
]
+2 -2
View File
@@ -3,6 +3,7 @@ from .project.base import (
ProjectIdentifierEndpoint,
ProjectUserViewsEndpoint,
ProjectFavoritesViewSet,
ProjectPublicCoverImagesEndpoint,
DeployBoardViewSet,
ProjectArchiveUnarchiveEndpoint,
)
@@ -17,7 +18,6 @@ from .project.member import (
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
UserProjectRolesEndpoint,
ProjectMemberPreferenceEndpoint,
)
from .user.base import (
@@ -79,7 +79,7 @@ from .workspace.cycle import WorkspaceCyclesEndpoint
from .workspace.quick_link import QuickLinkViewSet
from .workspace.sticky import WorkspaceStickyViewSet
from .state.base import StateViewSet, IntakeStateEndpoint
from .state.base import StateViewSet
from .view.base import (
WorkspaceViewViewSet,
WorkspaceViewIssuesViewSet,
+91 -97
View File
@@ -22,7 +22,6 @@ from plane.db.models import (
IntakeIssue,
Issue,
State,
StateGroup,
IssueLink,
FileAsset,
Project,
@@ -229,30 +228,14 @@ class IntakeIssueViewSet(BaseViewSet):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
project = Project.objects.get(pk=project_id)
# get the triage state
triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project_id=project_id,
workspace_id=project.workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
request.data["issue"]["state_id"] = triage_state.id
# create an issue
project = Project.objects.get(pk=project_id)
serializer = IssueCreateSerializer(
data=request.data.get("issue"),
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
"allow_triage_state": True,
},
)
if serializer.is_valid():
@@ -322,9 +305,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,
@@ -364,12 +344,6 @@ class IntakeIssueViewSet(BaseViewSet):
# Get issue data
issue_data = request.data.pop("issue", False)
issue_serializer = None
issue = None
issue_current_instance = None
issue_requested_data = None
# Validate issue data if provided
if bool(issue_data):
issue = Issue.objects.annotate(
label_ids=Coalesce(
@@ -397,43 +371,23 @@ class IntakeIssueViewSet(BaseViewSet):
"description": issue_data.get("description", issue.description),
}
issue_current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
issue_requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True, context={"project_id": project_id, "allow_triage_state": True}
issue, data=issue_data, partial=True, context={"project_id": project_id}
)
if not issue_serializer.is_valid():
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Validate intake issue data if user has permission
intake_serializer = None
intake_current_instance = None
if (project_member and project_member.role > ROLE.MEMBER.value) or is_workspace_admin:
intake_current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
intake_serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
if not intake_serializer.is_valid():
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# 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_serializer.is_valid():
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=issue_requested_data,
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=issue_current_instance,
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
@@ -441,55 +395,95 @@ class IntakeIssueViewSet(BaseViewSet):
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=issue_current_instance,
updated_issue=current_instance,
issue_id=str(pk),
user_id=request.user.id,
)
issue_serializer.save()
else:
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
if intake_serializer:
intake_serializer.save()
# create a activity for status change
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=intake_current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# Only project admins can edit intake issue attributes
if (project_member and project_member.role > ROLE.MEMBER.value) or is_workspace_admin:
serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
if serializer.is_valid():
serializer.save()
# Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get(
pk=intake_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
if state is not None:
issue.state = state
issue.save()
# Fetch and return the updated intake issue
intake_issue = (
IntakeIssue.objects.select_related("issue")
.prefetch_related("issue__labels", "issue__assignees")
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=Q(~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=Q(
~Q(issue__assignees__id__isnull=True) & Q(issue__issue_assignee__deleted_at__isnull=True)
# Update the issue state if it is accepted
if serializer.data["status"] in [1]:
issue = Issue.objects.get(
pk=intake_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
# Update the issue state only if it is in triage state
if issue.state.is_triage:
# Move to default state
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
if state is not None:
issue.state = state
issue.save()
# create a activity for status change
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
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=False,
origin=base_host(request=request, is_app=True),
intake=(intake_issue.id),
)
intake_issue = (
IntakeIssue.objects.select_related("issue")
.prefetch_related("issue__labels", "issue__assignees")
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=Q(
~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
)
serializer = IntakeIssueDetailSerializer(intake_issue).data
return Response(serializer, status=status.HTTP_200_OK)
assignee_ids=Coalesce(
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=Q(
~Q(issue__assignees__id__isnull=True)
& Q(issue__issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
)
serializer = IntakeIssueDetailSerializer(intake_issue).data
return Response(serializer, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
serializer = IntakeIssueDetailSerializer(intake_issue).data
return Response(serializer, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue)
def retrieve(self, request, slug, project_id, pk):
+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)
+21 -74
View File
@@ -149,24 +149,14 @@ class PageViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, page_id):
try:
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
if page.is_locked:
return Response({"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST)
parent = request.data.get("parent", None)
if parent:
_ = Page.objects.get(
pk=parent,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
_ = Page.objects.get(pk=parent, workspace__slug=slug, projects__id=project_id)
# Only update access if the page owner is the requesting user
if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id:
@@ -240,24 +230,14 @@ class PageViewSet(BaseViewSet):
return Response(data, status=status.HTTP_200_OK)
def lock(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
page.is_locked = True
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def unlock(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
page.is_locked = False
page.save()
@@ -266,12 +246,7 @@ class PageViewSet(BaseViewSet):
def access(self, request, slug, project_id, page_id):
access = request.data.get("access", 0)
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
# Only update access if the page owner is the requesting user
if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id:
@@ -302,12 +277,7 @@ class PageViewSet(BaseViewSet):
return Response(pages, status=status.HTTP_200_OK)
def archive(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
# only the owner or admin can archive the page
if (
@@ -333,12 +303,7 @@ class PageViewSet(BaseViewSet):
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
def unarchive(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
# only the owner or admin can un archive the page
if (
@@ -362,12 +327,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
if page.archived_at is None:
return Response(
@@ -390,12 +350,7 @@ class PageViewSet(BaseViewSet):
)
# remove parent from all the children
_ = Page.objects.filter(
parent_id=page_id,
projects__id=project_id,
workspace__slug=slug,
project_pages__deleted_at__isnull=True,
).update(parent=None)
_ = Page.objects.filter(parent_id=page_id, projects__id=project_id, workspace__slug=slug).update(parent=None)
page.delete()
# Delete the user favorite page
@@ -496,14 +451,12 @@ class PagesDescriptionViewSet(BaseViewSet):
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.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
)
if page is None:
return Response({"error": "Page not found"}, status=404)
binary_data = page.description_binary
def stream_data():
@@ -518,15 +471,14 @@ class PagesDescriptionViewSet(BaseViewSet):
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.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
)
if page is None:
return Response({"error": "Page not found"}, status=404)
if page.is_locked:
return Response(
{
@@ -577,12 +529,7 @@ class PageDuplicateEndpoint(BaseAPIView):
permission_classes = [ProjectPagePermission]
def post(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
# check for permission
if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id:
+101 -31
View File
@@ -1,44 +1,42 @@
# Python imports
import boto3
from django.conf import settings
from django.utils import timezone
import json
import boto3
# Django imports
from django.conf import settings
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.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# 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
@@ -211,25 +209,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,
@@ -272,6 +264,41 @@ class ProjectViewSet(BaseViewSet):
user_id=serializer.data["project_lead"],
)
# Default states
states = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},
]
State.objects.bulk_create(
[
State(
@@ -284,7 +311,7 @@ class ProjectViewSet(BaseViewSet):
default=state.get("default", False),
created_by=request.user,
)
for state in DEFAULT_STATES
for state in states
]
)
@@ -526,6 +553,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
@@ -8,7 +8,6 @@ from plane.app.serializers import (
ProjectMemberSerializer,
ProjectMemberAdminSerializer,
ProjectMemberRoleSerializer,
ProjectMemberPreferenceSerializer,
)
from plane.app.permissions import WorkspaceUserPermission
@@ -164,40 +163,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)
@@ -335,32 +300,3 @@ class UserProjectRolesEndpoint(BaseAPIView):
project_members = {str(member["project_id"]): member["role"] for member in project_members}
return Response(project_members, status=status.HTTP_200_OK)
class ProjectMemberPreferenceEndpoint(BaseAPIView):
def get_queryset(self, slug, project_id, member_id):
return ProjectMember.objects.get(
project_id=project_id,
member_id=member_id,
workspace__slug=slug,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def patch(self, request, slug, project_id, member_id):
project_member = self.get_queryset(slug, project_id, member_id)
serializer = ProjectMemberPreferenceSerializer(project_member, {"preferences": request.data}, partial=True)
if serializer.is_valid():
serializer.save()
return Response({"preferences": serializer.data["preferences"]}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, member_id):
project_member = self.get_queryset(slug, project_id, member_id)
serializer = ProjectMemberPreferenceSerializer(project_member)
return Response(serializer.data, status=status.HTTP_200_OK)
+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"),
+1 -14
View File
@@ -10,7 +10,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet, BaseAPIView
from .. import BaseViewSet
from plane.app.serializers import StateSerializer
from plane.app.permissions import ROLE, allow_permission
from plane.db.models import State, Issue
@@ -127,16 +127,3 @@ class StateViewSet(BaseViewSet):
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class IntakeStateEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
state = State.triage_objects.filter(workspace__slug=slug, project_id=project_id).first()
if not state:
return Response(
{"error": "Triage state not found"},
status=status.HTTP_404_NOT_FOUND,
)
return Response(StateSerializer(state).data, status=status.HTTP_200_OK)
+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(
+10 -13
View File
@@ -249,26 +249,23 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
def patch(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
workspace_properties = WorkspaceUserProperties.objects.get(user=request.user, workspace__slug=slug)
(workspace_properties, _) = WorkspaceUserProperties.objects.get_or_create(
user=request.user, workspace_id=workspace.id
workspace_properties.filters = request.data.get("filters", workspace_properties.filters)
workspace_properties.rich_filters = request.data.get("rich_filters", workspace_properties.rich_filters)
workspace_properties.display_filters = request.data.get("display_filters", workspace_properties.display_filters)
workspace_properties.display_properties = request.data.get(
"display_properties", workspace_properties.display_properties
)
workspace_properties.save()
serializer = WorkspaceUserPropertiesSerializer(workspace_properties, 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)
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
(workspace_properties, _) = WorkspaceUserProperties.objects.get_or_create(
user=request.user, workspace=workspace
user=request.user, workspace__slug=slug
)
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -39,16 +39,6 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
user=request.user,
workspace=workspace,
sort_order=(65535 + (i * 10000)),
is_pinned=(
True
if key
in [
WorkspaceUserPreference.UserPreferenceKeys.DRAFTS,
WorkspaceUserPreference.UserPreferenceKeys.YOUR_WORK,
WorkspaceUserPreference.UserPreferenceKeys.STICKIES,
]
else False
),
)
for i, key in enumerate(create_preference_keys)
],
@@ -75,23 +65,15 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def patch(self, request, slug):
for data in request.data:
key = data.pop("key", None)
if not key:
continue
def patch(self, request, slug, key):
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first()
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug).first()
if preference:
serializer = WorkspaceUserPreferenceSerializer(preference, data=request.data, partial=True)
if not preference:
continue
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)
if "is_pinned" in data:
preference.is_pinned = data["is_pinned"]
if "sort_order" in data:
preference.sort_order = data["sort_order"]
preference.save(update_fields=["is_pinned", "sort_order"])
return Response({"message": "Successfully updated"}, status=status.HTTP_200_OK)
return Response({"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND)
+6 -106
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:
@@ -90,9 +86,9 @@ class Adapter:
"""Check if sign up is enabled or not and raise exception if not enabled"""
# Get configuration value
(ENABLE_SIGNUP,) = get_configuration_value([
{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}
])
(ENABLE_SIGNUP,) = get_configuration_value(
[{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}]
)
# Check if sign up is disabled and invite is present or not
if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists():
@@ -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()
+1 -4
View File
@@ -17,7 +17,6 @@ from plane.db.models import (
Project,
ProjectMember,
State,
StateGroup,
Label,
Cycle,
Module,
@@ -265,9 +264,7 @@ def create_issues(workspace, project, user_id, issue_count):
Faker.seed(0)
states = (
State.objects.filter(workspace=workspace, project=project)
.exclude(group=StateGroup.TRIAGE.value)
.values_list("id", flat=True)
State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True)
)
creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=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
+92 -120
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,
@@ -107,56 +104,60 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
)
# Create project members
ProjectMember.objects.bulk_create([
ProjectMember(
project=project,
member_id=workspace_member["member_id"],
role=workspace_member["role"],
workspace_id=workspace.id,
created_by_id=bot_user.id,
)
for workspace_member in workspace_members
])
ProjectMember.objects.bulk_create(
[
ProjectMember(
project=project,
member_id=workspace_member["member_id"],
role=workspace_member["role"],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
)
for workspace_member in workspace_members
]
)
# Create issue user properties
IssueUserProperty.objects.bulk_create([
IssueUserProperty(
project=project,
user_id=workspace_member["member_id"],
workspace_id=workspace.id,
display_filters={
"layout": "list",
"calendar": {"layout": "month", "show_weekends": False},
"group_by": "state",
"order_by": "sort_order",
"sub_issue": True,
"sub_group_by": None,
"show_empty_groups": True,
},
display_properties={
"key": True,
"link": True,
"cycle": False,
"state": True,
"labels": False,
"modules": False,
"assignee": True,
"due_date": False,
"estimate": True,
"priority": True,
"created_on": True,
"issue_type": True,
"start_date": False,
"updated_on": True,
"customer_count": True,
"sub_issue_count": False,
"attachment_count": False,
"customer_request_count": True,
},
created_by_id=bot_user.id,
)
for workspace_member in workspace_members
])
IssueUserProperty.objects.bulk_create(
[
IssueUserProperty(
project=project,
user_id=workspace_member["member_id"],
workspace_id=workspace.id,
display_filters={
"layout": "list",
"calendar": {"layout": "month", "show_weekends": False},
"group_by": "state",
"order_by": "sort_order",
"sub_issue": True,
"sub_group_by": None,
"show_empty_groups": True,
},
display_properties={
"key": True,
"link": True,
"cycle": False,
"state": True,
"labels": False,
"modules": False,
"assignee": True,
"due_date": False,
"estimate": True,
"priority": True,
"created_on": True,
"issue_type": True,
"start_date": False,
"updated_on": True,
"customer_count": True,
"sub_issue_count": False,
"attachment_count": False,
"customer_request_count": True,
},
created_by_id=workspace.created_by_id,
)
for workspace_member in workspace_members
]
)
# update map
projects_map[project_id] = project.id
logger.info(f"Task: workspace_seed_task -> Project {project_id} created")
@@ -164,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
"""
@@ -191,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
@@ -199,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
"""
@@ -224,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
@@ -239,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.
@@ -277,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(
@@ -292,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(),
)
@@ -303,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
@@ -313,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
@@ -324,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")
@@ -356,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")
@@ -367,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] = {}
@@ -416,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
@@ -425,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] = {}
@@ -452,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")
@@ -478,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,
)
@@ -501,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
@@ -1,92 +0,0 @@
# Generated by Django 4.2.25 on 2025-11-24 06:03
from django.db import migrations, transaction
from django.db.models import OuterRef, Subquery
import logging
logger = logging.getLogger("plane.migrations")
BATCH_SIZE = 4000
def create_triage_state(apps, _schema_editor):
Project = apps.get_model("db", "Project")
State = apps.get_model("db", "State")
Issue = apps.get_model("db", "Issue")
# 1) Bulk-update existing triage states
triage_qs = State.objects.filter(group="triage")
projects_with_triage_state = list(triage_qs.values_list("project_id", flat=True))
triage_qs.update(
name="Triage",
color="#4E5355",
sequence=65000,
default=False,
)
logger.info(f"Updated {triage_qs.count()} triage states.")
# 2) Projects that already have a 'Triage' name but not in triage group
projects_to_update_qs = (
State.objects.exclude(group="triage")
.filter(name="Triage")
.values_list("project_id", flat=True)
)
projects_to_update = set(projects_to_update_qs)
logger.info(f"Projects to update: {len(projects_to_update)}")
# 3) Create missing triage states in chunks to avoid memory spike
states_to_create = []
project_iter = Project.objects.all().values_list("id", "workspace_id").iterator()
for proj_id, workspace_id in project_iter:
if proj_id in projects_with_triage_state:
continue
if proj_id in projects_to_update:
name = f"Triage-{str(proj_id)[:5]}"
else:
name = "Triage"
states_to_create.append(
State(
name=name,
group="triage",
project_id=proj_id,
workspace_id=workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
)
if len(states_to_create) >= BATCH_SIZE:
State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
states_to_create = []
if states_to_create:
State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
# 4) Update issues: use deterministic subquery and only update issues that will get a triage state.
with transaction.atomic():
triage_state_subquery = (
State.objects.filter(
group="triage",
project_id=OuterRef("project_id"),
workspace_id=OuterRef("workspace_id"),
)
.values("id")[:1]
)
updated_count = Issue._default_manager.filter(
issue_intake__status__in=[-2, 0],
).update(state_id=Subquery(triage_state_subquery))
logger.info(f"Updated {updated_count} issues.")
class Migration(migrations.Migration):
dependencies = [
('db', '0111_notification_notif_receiver_status_idx_and_more'),
]
operations = [
migrations.RunPython(create_triage_state,
reverse_code=migrations.RunPython.noop),
]
+2 -3
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 .state import State
from .user import Account, Profile, User
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .workspace import (
-2
View File
@@ -19,7 +19,6 @@ from .project import ProjectBaseModel
from plane.utils.uuid import convert_uuid_to_integer
from .description import Description
from plane.db.mixins import ChangeTrackerMixin
from .state import StateGroup
def get_default_properties():
@@ -98,7 +97,6 @@ class IssueManager(SoftDeletionManager):
)
.filter(deleted_at__isnull=True)
.filter(state__is_triage=False)
.exclude(state__group=StateGroup.TRIAGE.value)
.exclude(archived_at__isnull=False)
.exclude(project__archived_at__isnull=False)
.exclude(is_draft=True)
+1 -1
View File
@@ -59,7 +59,7 @@ def get_default_props():
def get_default_preferences():
return {"pages": {"block_display": True}, "navigation": {"default_tab": "work_items", "hide_in_more_menu": []}}
return {"pages": {"block_display": True}}
class Project(BaseModel):
+9 -71
View File
@@ -7,71 +7,6 @@ from django.db.models import Q
from .project import ProjectBaseModel
class StateGroup(models.TextChoices):
BACKLOG = "backlog", "Backlog"
UNSTARTED = "unstarted", "Unstarted"
STARTED = "started", "Started"
COMPLETED = "completed", "Completed"
CANCELLED = "cancelled", "Cancelled"
TRIAGE = "triage", "Triage"
# Default states
DEFAULT_STATES = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": StateGroup.BACKLOG.value,
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": StateGroup.UNSTARTED.value,
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": StateGroup.STARTED.value,
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": StateGroup.COMPLETED.value,
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": StateGroup.CANCELLED.value,
},
{
"name": "Triage",
"color": "#4E5355",
"sequence": 65000,
"group": StateGroup.TRIAGE.value,
},
]
class StateManager(models.Manager):
"""Default manager - excludes triage states"""
def get_queryset(self):
return super().get_queryset().exclude(group=StateGroup.TRIAGE.value)
class TriageStateManager(models.Manager):
"""Manager for triage states only"""
def get_queryset(self):
return super().get_queryset().filter(group=StateGroup.TRIAGE.value)
class State(ProjectBaseModel):
name = models.CharField(max_length=255, verbose_name="State Name")
description = models.TextField(verbose_name="State Description", blank=True)
@@ -79,8 +14,15 @@ class State(ProjectBaseModel):
slug = models.SlugField(max_length=100, blank=True)
sequence = models.FloatField(default=65535)
group = models.CharField(
choices=StateGroup.choices,
default=StateGroup.BACKLOG,
choices=(
("backlog", "Backlog"),
("unstarted", "Unstarted"),
("started", "Started"),
("completed", "Completed"),
("cancelled", "Cancelled"),
("triage", "Triage"),
),
default="backlog",
max_length=20,
)
is_triage = models.BooleanField(default=False)
@@ -88,10 +30,6 @@ class State(ProjectBaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
objects = StateManager()
all_state_objects = models.Manager()
triage_objects = TriageStateManager()
def __str__(self):
"""Return name of the state"""
return f"{self.name} <{self.project.name}>"
-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)
-1
View File
@@ -417,7 +417,6 @@ class WorkspaceUserPreference(BaseModel):
DRAFTS = "drafts", "Drafts"
YOUR_WORK = "your_work", "Your Work"
ARCHIVES = "archives", "Archives"
STICKIES = "stickies", "Stickies"
workspace = models.ForeignKey(
"db.Workspace",
@@ -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
+3 -4
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(",")
@@ -72,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",
-5
View File
@@ -76,10 +76,5 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.migrations": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
-5
View File
@@ -86,10 +86,5 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.migrations": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
+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"])
+2 -19
View File
@@ -12,7 +12,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseViewSet
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard, State, StateGroup
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard
from plane.app.serializers import (
IssueSerializer,
IntakeIssueSerializer,
@@ -121,22 +121,6 @@ class IntakeIssuePublicViewSet(BaseViewSet):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
# get the triage state
triage_state = State.triage_objects.filter(
project_id=project_deploy_board.project_id, workspace_id=project_deploy_board.workspace_id
).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project_id=project_deploy_board.project_id,
workspace_id=project_deploy_board.workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -144,7 +128,6 @@ class IntakeIssuePublicViewSet(BaseViewSet):
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_deploy_board.project_id,
state_id=triage_state.id,
)
# Create an Issue Activity
@@ -208,7 +191,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
issue,
data=issue_data,
partial=True,
context={"project_id": project_deploy_board.project_id, "allow_triage_state": True},
context={"project_id": project_deploy_board.project_id},
)
if issue_serializer.is_valid():
@@ -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
@@ -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
-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,18 +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,
}
+4 -4
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
+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.1",
"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 . --max-warnings=160",
"check:lint": "eslint . --max-warnings 10",
"check:types": "tsc --noEmit",
"check:format": "prettier --check .",
"fix:lint": "eslint . --fix --max-warnings=160",
"fix:format": "prettier --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",
+1 -2
View File
@@ -1,6 +1,5 @@
import { CollaborationController } from "./collaboration.controller";
import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller";
import { PdfExportController } from "./pdf-export.controller";
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController];
@@ -1,135 +0,0 @@
import type { Request, Response } from "express";
import { Effect, Schema, Cause } from "effect";
import { Controller, Post } from "@plane/decorators";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
import {
PdfExportRequestBody,
PdfValidationError,
PdfAuthenticationError,
PdfContentFetchError,
PdfGenerationError,
PdfTimeoutError,
} from "@/schema/pdf-export";
import { PdfExportService, exportToPdf } from "@/services/pdf-export";
import type { PdfExportInput } from "@/services/pdf-export";
type HttpErrorResponse = { status: number; error: string };
@Controller("/pdf-export")
export class PdfExportController {
/**
* Parses and validates the request, returning a typed input object
*/
private parseRequest(
req: Request,
requestId: string
): Effect.Effect<PdfExportInput, PdfValidationError | PdfAuthenticationError> {
return Effect.gen(function* () {
const cookie = req.headers.cookie || "";
if (!cookie) {
return yield* Effect.fail(
new PdfAuthenticationError({
message: "Authentication required",
})
);
}
const body = yield* Schema.decodeUnknown(PdfExportRequestBody)(req.body).pipe(
Effect.mapError(
(cause) =>
new PdfValidationError({
message: "Invalid request body",
cause,
})
)
);
// Get baseUrl from request body or fall back to origin header
const baseUrl = body.baseUrl || req.headers.origin || "";
return {
pageId: body.pageId,
workspaceSlug: body.workspaceSlug,
projectId: body.projectId,
title: body.title,
author: body.author,
subject: body.subject,
pageSize: body.pageSize,
pageOrientation: body.pageOrientation,
fileName: body.fileName,
noAssets: body.noAssets,
baseUrl,
apiBaseUrl: body.apiBaseUrl,
cookie,
requestId,
};
});
}
@Post("/")
async exportToPdf(req: Request, res: Response) {
const requestId = crypto.randomUUID();
const effect = Effect.gen(this, function* () {
// Parse request
const input = yield* this.parseRequest(req, requestId);
// Delegate to service (fat model)
return yield* exportToPdf(input);
}).pipe(
// Log errors before catching them - serialize error properly
Effect.tapError((error) => {
const errorInfo =
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: error;
return Effect.logError("PDF_EXPORT: Export failed", { requestId, error: errorInfo });
}),
// Map tagged errors to HTTP responses using catchTags
Effect.catchTags({
PdfValidationError: (e: PdfValidationError): Effect.Effect<HttpErrorResponse> =>
Effect.succeed({ status: 400, error: e.message }),
PdfAuthenticationError: (e: PdfAuthenticationError): Effect.Effect<HttpErrorResponse> =>
Effect.succeed({ status: 401, error: e.message }),
PdfContentFetchError: (e: PdfContentFetchError): Effect.Effect<HttpErrorResponse> =>
Effect.succeed({ status: e.message.includes("not found") ? 404 : 502, error: e.message }),
PdfTimeoutError: (e: PdfTimeoutError): Effect.Effect<HttpErrorResponse> =>
Effect.succeed({ status: 504, error: e.message }),
PdfGenerationError: (e: PdfGenerationError): Effect.Effect<HttpErrorResponse> =>
Effect.succeed({ status: 500, error: e.message }),
}),
// Handle unexpected defects
Effect.catchAllDefect((defect) => {
const appError = new AppError(Cause.pretty(Cause.die(defect)), {
context: { requestId, operation: "exportToPdf" },
});
logger.error("PDF_EXPORT: Unexpected failure", appError);
return Effect.succeed({ status: 500, error: "Failed to generate PDF" });
})
);
const result = await Effect.runPromise(Effect.provide(effect, PdfExportService.Default));
// Check if result is an error response
if ("error" in result && "status" in result) {
return res.status(result.status).json({ message: result.error });
}
// Success - send PDF
const { pdfBuffer, outputFileName } = result;
// Sanitize filename for Content-Disposition header to prevent header injection
const sanitizedFileName = outputFileName
.replace(/["\\\r\n]/g, "") // Remove quotes, backslashes, and CRLF
.replace(/[^\x20-\x7E]/g, "_"); // Replace non-ASCII with underscore
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
`attachment; filename="${sanitizedFileName}"; filename*=UTF-8''${encodeURIComponent(outputFileName)}`
);
res.setHeader("Content-Length", pdfBuffer.length);
return res.send(pdfBuffer);
}
}

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