Compare commits

..
Author SHA1 Message Date
Aaryan Khandelwal 540cafb13a fix: editor tsconfig 2025-07-18 15:47:47 +05:30
418 changed files with 3888 additions and 12415 deletions
@@ -0,0 +1,95 @@
name: Build and Lint on Pull Request
on:
workflow_dispatch:
pull_request:
types: ["opened", "synchronize", "ready_for_review"]
jobs:
lint-server:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x" # Specify the Python version you need
- name: Install Pylint
run: python -m pip install ruff
- name: Install Server Dependencies
run: cd apps/server && pip install -r requirements.txt
- name: Lint apps/server
run: ruff check --fix apps/server
lint-admin:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn lint --filter=admin
lint-space:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn lint --filter=space
lint-web:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn lint --filter=web
build-admin:
needs: lint-admin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn build --filter=admin
build-space:
needs: lint-space
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn build --filter=space
build-web:
needs: lint-web
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn build --filter=web
@@ -1,30 +0,0 @@
name: Build and lint API
on:
workflow_dispatch:
pull_request:
branches: ["preview"]
types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"]
paths:
- "apps/api/**"
jobs:
lint-api:
name: Lint API
runs-on: ubuntu-latest
timeout-minutes: 25
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install Pylint
run: python -m pip install ruff
- name: Install API Dependencies
run: cd apps/api && pip install -r requirements.txt
- name: Lint apps/api
run: ruff check --fix apps/api
@@ -1,43 +0,0 @@
name: Build and lint web apps
on:
workflow_dispatch:
pull_request:
branches: ["preview"]
types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"]
paths:
- "**.tsx?"
- "**.jsx?"
- "**.css"
- "**.json"
- "!apps/api/**"
jobs:
build-and-lint:
name: Build and lint web apps
runs-on: ubuntu-latest
timeout-minutes: 25
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Build web apps
run: yarn run build
- name: Lint web apps
run: yarn run ci:lint
-1
View File
@@ -1 +0,0 @@
lts/jod
+25 -23
View File
@@ -186,39 +186,41 @@ Adding a new language involves several steps to ensure it integrates seamlessly
1. **Update type definitions**
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";
```
```typescript
// types/language.ts
export type TLanguage = "en" | "fr" | "your-lang";
```
1. **Add language configuration**
2. **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" }
];
```
2. **Create translation files**
```typescript
// constants/language.ts
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "English", value: "en" },
{ label: "Your Language", value: "your-lang" }
];
```
3. **Create translation files**
1. Create a new folder for your language under locales (e.g., `locales/your-lang/`).
2. Add a `translations.json` file inside the folder.
3. Copy the structure from an existing translation file and translate all keys.
3. **Update import logic**
4. **Update import logic**
Modify the language import logic to include your new language:
```ts
private importLanguageFile(language: TLanguage): Promise<any> {
switch (language) {
case "your-lang":
return import("../locales/your-lang/translations.json");
// ...
}
}
```
```typescript
private importLanguageFile(language: TLanguage): Promise<any> {
switch (language) {
case "your-lang":
return import("../locales/your-lang/translations.json");
// ...
}
}
```
### Quality checklist
@@ -66,11 +66,9 @@ const InstanceGitlabAuthenticationPage = observer(() => {
<ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
if (Boolean(parseInt(enableGitlabConfig)) === true) {
updateConfig("IS_GITLAB_ENABLED", "0");
} else {
updateConfig("IS_GITLAB_ENABLED", "1");
}
Boolean(parseInt(enableGitlabConfig)) === true
? updateConfig("IS_GITLAB_ENABLED", "0")
: updateConfig("IS_GITLAB_ENABLED", "1");
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
@@ -67,11 +67,9 @@ const InstanceGoogleAuthenticationPage = observer(() => {
<ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => {
if (Boolean(parseInt(enableGoogleConfig)) === true) {
updateConfig("IS_GOOGLE_ENABLED", "0");
} else {
updateConfig("IS_GOOGLE_ENABLED", "1");
}
Boolean(parseInt(enableGoogleConfig)) === true
? updateConfig("IS_GOOGLE_ENABLED", "0")
: updateConfig("IS_GOOGLE_ENABLED", "1");
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
@@ -0,0 +1,89 @@
"use client";
import { FC, useMemo } from "react";
// plane internal packages
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { cn, getPasswordStrength } from "@plane/utils";
type TPasswordStrengthMeter = {
password: string;
isFocused?: boolean;
};
export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => {
const { password, isFocused = false } = props;
// derived values
const strength = useMemo(() => getPasswordStrength(password), [password]);
const strengthBars = useMemo(() => {
switch (strength) {
case E_PASSWORD_STRENGTH.EMPTY: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
textColor: "text-custom-text-100",
};
}
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password length should me more than 8 characters.",
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password is weak.",
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_VALID: {
return {
bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`],
text: "Password is strong.",
textColor: "text-green-500",
};
}
default: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
textColor: "text-custom-text-100",
};
}
}
}, [strength]);
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
if (!isPasswordMeterVisible) return <></>;
return (
<div className="w-full space-y-2 pt-2">
<div className="space-y-1.5">
<div className="relative flex items-center gap-2">
{strengthBars?.bars.map((color, index) => (
<div key={`${color}-${index}`} className={cn("w-full h-1 rounded-full", color)} />
))}
</div>
<div className={cn(`text-xs font-medium text-custom-text-100`, strengthBars?.textColor)}>
{strengthBars?.text}
</div>
</div>
{/* <div className="relative flex flex-wrap gap-x-4 gap-y-2">
{PASSWORD_CRITERIA.map((criteria) => (
<div
key={criteria.key}
className={cn(
"relative flex items-center gap-1 text-xs",
criteria.isCriteriaValid(password) ? `text-green-500/70` : "text-custom-text-300"
)}
>
<CircleCheck width={14} height={14} />
{criteria.label}
</div>
))}
</div> */}
</div>
);
};
@@ -7,10 +7,11 @@ import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
import { Button, Checkbox, Input, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { Banner } from "@/components/common/banner";
import { PasswordStrengthMeter } from "@/components/common/password-strength-meter";
// service initialization
const authService = new AuthService();
@@ -273,7 +274,7 @@ export const InstanceSetupForm: FC = (props) => {
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
)}
<PasswordStrengthIndicator password={formData.password} isFocused={isPasswordInputFocused} />
<PasswordStrengthMeter password={formData.password} isFocused={isPasswordInputFocused} />
</div>
<div className="w-full space-y-1">
+6 -4
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.28.0",
"version": "0.27.1",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -10,7 +10,7 @@
"preview": "next build && next start",
"start": "next start",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
"check:lint": "eslint . --max-warnings 19",
"check:lint": "eslint . --max-warnings 0",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
@@ -28,7 +28,7 @@
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "1.11.0",
"axios": "^1.8.3",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
@@ -40,7 +40,8 @@
"react-dom": "^18.3.1",
"react-hook-form": "7.51.5",
"swr": "^2.2.4",
"uuid": "^9.0.1"
"uuid": "^9.0.1",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@plane/eslint-config": "*",
@@ -50,6 +51,7 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"typescript": "5.8.3"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "0.28.0",
"version": "0.27.1",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
-7
View File
@@ -3,10 +3,3 @@ from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "plane.api"
def ready(self):
# Import authentication extensions to register them with drf-spectacular
try:
import plane.utils.openapi.auth # noqa
except ImportError:
pass
+4 -41
View File
@@ -1,14 +1,8 @@
from .user import UserLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .project import (
ProjectSerializer,
ProjectLiteSerializer,
ProjectCreateSerializer,
ProjectUpdateSerializer,
)
from .project import ProjectSerializer, ProjectLiteSerializer
from .issue import (
IssueSerializer,
LabelCreateUpdateSerializer,
LabelSerializer,
IssueLinkSerializer,
IssueCommentSerializer,
@@ -16,40 +10,9 @@ from .issue import (
IssueActivitySerializer,
IssueExpandSerializer,
IssueLiteSerializer,
IssueAttachmentUploadSerializer,
IssueSearchSerializer,
IssueCommentCreateSerializer,
IssueLinkCreateSerializer,
IssueLinkUpdateSerializer,
)
from .state import StateLiteSerializer, StateSerializer
from .cycle import (
CycleSerializer,
CycleIssueSerializer,
CycleLiteSerializer,
CycleIssueRequestSerializer,
TransferCycleIssueRequestSerializer,
CycleCreateSerializer,
CycleUpdateSerializer,
)
from .module import (
ModuleSerializer,
ModuleIssueSerializer,
ModuleLiteSerializer,
ModuleIssueRequestSerializer,
ModuleCreateSerializer,
ModuleUpdateSerializer,
)
from .intake import (
IntakeIssueSerializer,
IntakeIssueCreateSerializer,
IntakeIssueUpdateSerializer,
)
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
from .intake import IntakeIssueSerializer
from .estimate import EstimatePointSerializer
from .asset import (
UserAssetUploadSerializer,
AssetUpdateSerializer,
GenericAssetUploadSerializer,
GenericAssetUpdateSerializer,
FileAssetSerializer,
)
-123
View File
@@ -1,123 +0,0 @@
# Third party imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import FileAsset
class UserAssetUploadSerializer(serializers.Serializer):
"""
Serializer for user asset upload requests.
This serializer validates the metadata required to generate a presigned URL
for uploading user profile assets (avatar or cover image) directly to S3 storage.
Supports JPEG, PNG, WebP, JPG, and GIF image formats with size validation.
"""
name = serializers.CharField(help_text="Original filename of the asset")
type = serializers.ChoiceField(
choices=[
("image/jpeg", "JPEG"),
("image/png", "PNG"),
("image/webp", "WebP"),
("image/jpg", "JPG"),
("image/gif", "GIF"),
],
default="image/jpeg",
help_text="MIME type of the file",
style={"placeholder": "image/jpeg"},
)
size = serializers.IntegerField(help_text="File size in bytes")
entity_type = serializers.ChoiceField(
choices=[
(FileAsset.EntityTypeContext.USER_AVATAR, "User Avatar"),
(FileAsset.EntityTypeContext.USER_COVER, "User Cover"),
],
help_text="Type of user asset",
)
class AssetUpdateSerializer(serializers.Serializer):
"""
Serializer for asset status updates after successful upload completion.
Handles post-upload asset metadata updates including attribute modifications
and upload confirmation for S3-based file storage workflows.
"""
attributes = serializers.JSONField(
required=False, help_text="Additional attributes to update for the asset"
)
class GenericAssetUploadSerializer(serializers.Serializer):
"""
Serializer for generic asset upload requests with project association.
Validates metadata for generating presigned URLs for workspace assets including
project association, external system tracking, and file validation for
document management and content storage workflows.
"""
name = serializers.CharField(help_text="Original filename of the asset")
type = serializers.CharField(required=False, help_text="MIME type of the file")
size = serializers.IntegerField(help_text="File size in bytes")
project_id = serializers.UUIDField(
required=False,
help_text="UUID of the project to associate with the asset",
style={"placeholder": "123e4567-e89b-12d3-a456-426614174000"},
)
external_id = serializers.CharField(
required=False,
help_text="External identifier for the asset (for integration tracking)",
)
external_source = serializers.CharField(
required=False, help_text="External source system (for integration tracking)"
)
class GenericAssetUpdateSerializer(serializers.Serializer):
"""
Serializer for generic asset upload confirmation and status management.
Handles post-upload status updates for workspace assets including
upload completion marking and metadata finalization.
"""
is_uploaded = serializers.BooleanField(
default=True, help_text="Whether the asset has been successfully uploaded"
)
class FileAssetSerializer(BaseSerializer):
"""
Comprehensive file asset serializer with complete metadata and URL generation.
Provides full file asset information including storage metadata, access URLs,
relationship data, and upload status for complete asset management workflows.
"""
asset_url = serializers.CharField(read_only=True)
class Meta:
model = FileAsset
fields = "__all__"
read_only_fields = [
"id",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
"issue",
"comment",
"page",
"draft_issue",
"user",
"is_deleted",
"deleted_at",
"storage_metadata",
"asset_url",
]
-7
View File
@@ -3,13 +3,6 @@ from rest_framework import serializers
class BaseSerializer(serializers.ModelSerializer):
"""
Base serializer providing common functionality for all model serializers.
Features field filtering, dynamic expansion of related fields, and standardized
primary key handling for consistent API responses across the application.
"""
id = serializers.PrimaryKeyRelatedField(read_only=True)
def __init__(self, *args, **kwargs):
+10 -104
View File
@@ -8,13 +8,16 @@ from plane.db.models import Cycle, CycleIssue
from plane.utils.timezone_converter import convert_to_utc
class CycleCreateSerializer(BaseSerializer):
"""
Serializer for creating cycles with timezone handling and date validation.
Manages cycle creation including project timezone conversion, date range validation,
and UTC normalization for time-bound iteration planning and sprint management.
"""
class CycleSerializer(BaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
total_estimates = serializers.FloatField(read_only=True)
completed_estimates = serializers.FloatField(read_only=True)
started_estimates = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -24,29 +27,6 @@ class CycleCreateSerializer(BaseSerializer):
self.fields["start_date"].timezone = project_timezone
self.fields["end_date"].timezone = project_timezone
class Meta:
model = Cycle
fields = [
"name",
"description",
"start_date",
"end_date",
"owned_by",
"external_source",
"external_id",
"timezone",
]
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
def validate(self, data):
if (
data.get("start_date", None) is not None
@@ -79,40 +59,6 @@ class CycleCreateSerializer(BaseSerializer):
)
return data
class CycleUpdateSerializer(CycleCreateSerializer):
"""
Serializer for updating cycles with enhanced ownership management.
Extends cycle creation with update-specific features including ownership
assignment and modification tracking for cycle lifecycle management.
"""
class Meta(CycleCreateSerializer.Meta):
model = Cycle
fields = CycleCreateSerializer.Meta.fields + [
"owned_by",
]
class CycleSerializer(BaseSerializer):
"""
Cycle serializer with comprehensive project metrics and time tracking.
Provides cycle details including work item counts by status, progress estimates,
and time-bound iteration data for project management and sprint planning.
"""
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
total_estimates = serializers.FloatField(read_only=True)
completed_estimates = serializers.FloatField(read_only=True)
started_estimates = serializers.FloatField(read_only=True)
class Meta:
model = Cycle
fields = "__all__"
@@ -130,13 +76,6 @@ class CycleSerializer(BaseSerializer):
class CycleIssueSerializer(BaseSerializer):
"""
Serializer for cycle-issue relationships with sub-issue counting.
Manages the association between cycles and work items, including
hierarchical issue tracking for nested work item structures.
"""
sub_issues_count = serializers.IntegerField(read_only=True)
class Meta:
@@ -146,39 +85,6 @@ class CycleIssueSerializer(BaseSerializer):
class CycleLiteSerializer(BaseSerializer):
"""
Lightweight cycle serializer for minimal data transfer.
Provides essential cycle information without computed metrics,
optimized for list views and reference lookups.
"""
class Meta:
model = Cycle
fields = "__all__"
class CycleIssueRequestSerializer(serializers.Serializer):
"""
Serializer for bulk work item assignment to cycles.
Validates work item ID lists for batch operations including
cycle assignment and sprint planning workflows.
"""
issues = serializers.ListField(
child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle"
)
class TransferCycleIssueRequestSerializer(serializers.Serializer):
"""
Serializer for transferring work items between cycles.
Handles work item migration between cycles including validation
and relationship updates for sprint reallocation workflows.
"""
new_cycle_id = serializers.UUIDField(
help_text="ID of the target cycle to transfer issues to"
)
@@ -4,13 +4,6 @@ from .base import BaseSerializer
class EstimatePointSerializer(BaseSerializer):
"""
Serializer for project estimation points and story point values.
Handles numeric estimation data for work item sizing and sprint planning,
providing standardized point values for project velocity calculations.
"""
class Meta:
model = EstimatePoint
fields = ["id", "value"]
+1 -117
View File
@@ -1,77 +1,11 @@
# Module improts
from .base import BaseSerializer
from .issue import IssueExpandSerializer
from plane.db.models import IntakeIssue, Issue
from plane.db.models import IntakeIssue
from rest_framework import serializers
class IssueForIntakeSerializer(BaseSerializer):
"""
Serializer for work item data within intake submissions.
Handles essential work item fields for intake processing including
content validation and priority assignment for triage workflows.
"""
class Meta:
model = Issue
fields = [
"name",
"description",
"description_html",
"priority",
]
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IntakeIssueCreateSerializer(BaseSerializer):
"""
Serializer for creating intake work items with embedded issue data.
Manages intake work item creation including nested issue creation,
status assignment, and source tracking for issue queue management.
"""
issue = IssueForIntakeSerializer(help_text="Issue data for the intake issue")
class Meta:
model = IntakeIssue
fields = [
"issue",
"intake",
"status",
"snoozed_till",
"duplicate_to",
"source",
"source_email",
]
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IntakeIssueSerializer(BaseSerializer):
"""
Comprehensive serializer for intake work items with expanded issue details.
Provides full intake work item data including embedded issue information,
status tracking, and triage metadata for issue queue management.
"""
issue_detail = IssueExpandSerializer(read_only=True, source="issue")
inbox = serializers.UUIDField(source="intake.id", read_only=True)
@@ -88,53 +22,3 @@ class IntakeIssueSerializer(BaseSerializer):
"created_at",
"updated_at",
]
class IntakeIssueUpdateSerializer(BaseSerializer):
"""
Serializer for updating intake work items and their associated issues.
Handles intake work item modifications including status changes, triage decisions,
and embedded issue updates for issue queue processing workflows.
"""
issue = IssueForIntakeSerializer(
required=False, help_text="Issue data to update in the intake issue"
)
class Meta:
model = IntakeIssue
fields = [
"status",
"snoozed_till",
"duplicate_to",
"source",
"source_email",
"issue",
]
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueDataSerializer(serializers.Serializer):
"""
Serializer for nested work item data in intake request payloads.
Validates core work item fields within intake requests including
content formatting, priority levels, and metadata for issue creation.
"""
name = serializers.CharField(max_length=255, help_text="Issue name")
description_html = serializers.CharField(
required=False, allow_null=True, help_text="Issue description HTML"
)
priority = serializers.ChoiceField(
choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority"
)
+5 -277
View File
@@ -20,12 +20,6 @@ from plane.db.models import (
ProjectMember,
State,
User,
EstimatePoint,
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
from .base import BaseSerializer
@@ -40,13 +34,6 @@ from django.core.validators import URLValidator
class IssueSerializer(BaseSerializer):
"""
Comprehensive work item serializer with full relationship management.
Handles complete work item lifecycle including assignees, labels, validation,
and related model updates. Supports dynamic field expansion and HTML content processing.
"""
assignees = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(
queryset=User.objects.values_list("id", flat=True)
@@ -88,22 +75,6 @@ class IssueSerializer(BaseSerializer):
except Exception:
raise serializers.ValidationError("Invalid HTML passed")
# Validate description content for security
if data.get("description"):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if data.get("description_html"):
is_valid, error_msg = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if data.get("description_binary"):
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if data.get("assignees", []):
data["assignees"] = ProjectMember.objects.filter(
@@ -134,27 +105,13 @@ class IssueSerializer(BaseSerializer):
if (
data.get("parent")
and not Issue.objects.filter(
workspace_id=self.context.get("workspace_id"),
project_id=self.context.get("project_id"),
pk=data.get("parent").id,
workspace_id=self.context.get("workspace_id"), pk=data.get("parent").id
).exists()
):
raise serializers.ValidationError(
"Parent is not valid issue_id please pass a valid issue_id"
)
if (
data.get("estimate_point")
and not EstimatePoint.objects.filter(
workspace_id=self.context.get("workspace_id"),
project_id=self.context.get("project_id"),
pk=data.get("estimate_point").id,
).exists()
):
raise serializers.ValidationError(
"Estimate point is not valid please pass a valid estimate_point_id"
)
return data
def create(self, validated_data):
@@ -343,58 +300,13 @@ class IssueSerializer(BaseSerializer):
class IssueLiteSerializer(BaseSerializer):
"""
Lightweight work item serializer for minimal data transfer.
Provides essential work item identifiers optimized for list views,
references, and performance-critical operations.
"""
class Meta:
model = Issue
fields = ["id", "sequence_id", "project_id"]
read_only_fields = fields
class LabelCreateUpdateSerializer(BaseSerializer):
"""
Serializer for creating and updating work item labels.
Manages label metadata including colors, descriptions, hierarchy,
and sorting for work item categorization and filtering.
"""
class Meta:
model = Label
fields = [
"name",
"color",
"description",
"external_source",
"external_id",
"parent",
"sort_order",
]
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
class LabelSerializer(BaseSerializer):
"""
Full serializer for work item labels with complete metadata.
Provides comprehensive label information including hierarchical relationships,
visual properties, and organizational data for work item tagging.
"""
class Meta:
model = Label
fields = "__all__"
@@ -410,17 +322,10 @@ class LabelSerializer(BaseSerializer):
]
class IssueLinkCreateSerializer(BaseSerializer):
"""
Serializer for creating work item external links with validation.
Handles URL validation, format checking, and duplicate prevention
for attaching external resources to work items.
"""
class IssueLinkSerializer(BaseSerializer):
class Meta:
model = IssueLink
fields = ["url", "issue_id"]
fields = "__all__"
read_only_fields = [
"id",
"workspace",
@@ -456,22 +361,6 @@ class IssueLinkCreateSerializer(BaseSerializer):
)
return IssueLink.objects.create(**validated_data)
class IssueLinkUpdateSerializer(IssueLinkCreateSerializer):
"""
Serializer for updating work item external links.
Extends link creation with update-specific validation to prevent
URL conflicts and maintain link integrity during modifications.
"""
class Meta(IssueLinkCreateSerializer.Meta):
model = IssueLink
fields = IssueLinkCreateSerializer.Meta.fields + [
"issue_id",
]
read_only_fields = IssueLinkCreateSerializer.Meta.read_only_fields
def update(self, instance, validated_data):
if (
IssueLink.objects.filter(
@@ -487,37 +376,7 @@ class IssueLinkUpdateSerializer(IssueLinkCreateSerializer):
return super().update(instance, validated_data)
class IssueLinkSerializer(BaseSerializer):
"""
Full serializer for work item external links.
Provides complete link information including metadata and timestamps
for managing external resource associations with work items.
"""
class Meta:
model = IssueLink
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueAttachmentSerializer(BaseSerializer):
"""
Serializer for work item file attachments.
Manages file asset associations with work items including metadata,
storage information, and access control for document management.
"""
class Meta:
model = FileAsset
fields = "__all__"
@@ -531,47 +390,7 @@ class IssueAttachmentSerializer(BaseSerializer):
]
class IssueCommentCreateSerializer(BaseSerializer):
"""
Serializer for creating work item comments.
Handles comment creation with JSON and HTML content support,
access control, and external integration tracking.
"""
class Meta:
model = IssueComment
fields = [
"comment_json",
"comment_html",
"access",
"external_source",
"external_id",
]
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"deleted_at",
"actor",
"comment_stripped",
"edited_at",
]
class IssueCommentSerializer(BaseSerializer):
"""
Full serializer for work item comments with membership context.
Provides complete comment data including member status, content formatting,
and edit tracking for collaborative work item discussions.
"""
is_member = serializers.BooleanField(read_only=True)
class Meta:
@@ -601,26 +420,12 @@ class IssueCommentSerializer(BaseSerializer):
class IssueActivitySerializer(BaseSerializer):
"""
Serializer for work item activity and change history.
Tracks and represents work item modifications, state changes,
and user interactions for audit trails and activity feeds.
"""
class Meta:
model = IssueActivity
exclude = ["created_by", "updated_by"]
class CycleIssueSerializer(BaseSerializer):
"""
Serializer for work items within cycles.
Provides cycle context for work items including cycle metadata
and timing information for sprint and iteration management.
"""
cycle = CycleSerializer(read_only=True)
class Meta:
@@ -628,13 +433,6 @@ class CycleIssueSerializer(BaseSerializer):
class ModuleIssueSerializer(BaseSerializer):
"""
Serializer for work items within modules.
Provides module context for work items including module metadata
and organizational information for feature-based work grouping.
"""
module = ModuleSerializer(read_only=True)
class Meta:
@@ -642,50 +440,18 @@ class ModuleIssueSerializer(BaseSerializer):
class LabelLiteSerializer(BaseSerializer):
"""
Lightweight label serializer for minimal data transfer.
Provides essential label information with visual properties,
optimized for UI display and performance-critical operations.
"""
class Meta:
model = Label
fields = ["id", "name", "color"]
class IssueExpandSerializer(BaseSerializer):
"""
Extended work item serializer with full relationship expansion.
Provides work items with expanded related data including cycles, modules,
labels, assignees, and states for comprehensive data representation.
"""
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
module = ModuleLiteSerializer(source="issue_module.module", read_only=True)
labels = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
labels = LabelLiteSerializer(read_only=True, many=True)
assignees = UserLiteSerializer(read_only=True, many=True)
state = StateLiteSerializer(read_only=True)
def get_labels(self, obj):
expand = self.context.get("expand", [])
if "labels" in expand:
# Use prefetched data
return LabelLiteSerializer(
[il.label for il in obj.label_issue.all()], many=True
).data
return [il.label_id for il in obj.label_issue.all()]
def get_assignees(self, obj):
expand = self.context.get("expand", [])
if "assignees" in expand:
return UserLiteSerializer(
[ia.assignee for ia in obj.issue_assignee.all()], many=True
).data
return [ia.assignee_id for ia in obj.issue_assignee.all()]
class Meta:
model = Issue
fields = "__all__"
@@ -698,41 +464,3 @@ class IssueExpandSerializer(BaseSerializer):
"created_at",
"updated_at",
]
class IssueAttachmentUploadSerializer(serializers.Serializer):
"""
Serializer for work item attachment upload request validation.
Handles file upload metadata validation including size, type, and external
integration tracking for secure work item document attachment workflows.
"""
name = serializers.CharField(help_text="Original filename of the asset")
type = serializers.CharField(required=False, help_text="MIME type of the file")
size = serializers.IntegerField(help_text="File size in bytes")
external_id = serializers.CharField(
required=False,
help_text="External identifier for the asset (for integration tracking)",
)
external_source = serializers.CharField(
required=False, help_text="External source system (for integration tracking)"
)
class IssueSearchSerializer(serializers.Serializer):
"""
Serializer for work item search result data formatting.
Provides standardized search result structure including work item identifiers,
project context, and workspace information for search API responses.
"""
id = serializers.CharField(required=True, help_text="Issue ID")
name = serializers.CharField(required=True, help_text="Issue name")
sequence_id = serializers.CharField(required=True, help_text="Issue sequence ID")
project__identifier = serializers.CharField(
required=True, help_text="Project identifier"
)
project_id = serializers.CharField(required=True, help_text="Project ID")
workspace__slug = serializers.CharField(required=True, help_text="Workspace slug")
+16 -111
View File
@@ -13,33 +13,24 @@ from plane.db.models import (
)
class ModuleCreateSerializer(BaseSerializer):
"""
Serializer for creating modules with member validation and date checking.
Handles module creation including member assignment validation, date range verification,
and duplicate name prevention for feature-based project organization setup.
"""
class ModuleSerializer(BaseSerializer):
members = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
child=serializers.PrimaryKeyRelatedField(
queryset=User.objects.values_list("id", flat=True)
),
write_only=True,
required=False,
)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
class Meta:
model = Module
fields = [
"name",
"description",
"start_date",
"target_date",
"status",
"lead",
"members",
"external_source",
"external_id",
]
fields = "__all__"
read_only_fields = [
"id",
"workspace",
@@ -51,6 +42,11 @@ class ModuleCreateSerializer(BaseSerializer):
"deleted_at",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data["members"] = [str(member.id) for member in instance.members.all()]
return data
def validate(self, data):
if (
data.get("start_date", None) is not None
@@ -100,22 +96,6 @@ class ModuleCreateSerializer(BaseSerializer):
return module
class ModuleUpdateSerializer(ModuleCreateSerializer):
"""
Serializer for updating modules with enhanced validation and member management.
Extends module creation with update-specific validations including member reassignment,
name conflict checking, and relationship management for module modifications.
"""
class Meta(ModuleCreateSerializer.Meta):
model = Module
fields = ModuleCreateSerializer.Meta.fields + [
"members",
]
read_only_fields = ModuleCreateSerializer.Meta.read_only_fields
def update(self, instance, validated_data):
members = validated_data.pop("members", None)
module_name = validated_data.get("name")
@@ -151,54 +131,7 @@ class ModuleUpdateSerializer(ModuleCreateSerializer):
return super().update(instance, validated_data)
class ModuleSerializer(BaseSerializer):
"""
Comprehensive module serializer with work item metrics and member management.
Provides complete module data including work item counts by status, member relationships,
and progress tracking for feature-based project organization.
"""
members = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
class Meta:
model = Module
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data["members"] = [str(member.id) for member in instance.members.all()]
return data
class ModuleIssueSerializer(BaseSerializer):
"""
Serializer for module-work item relationships with sub-item counting.
Manages the association between modules and work items, including
hierarchical issue tracking for nested work item structures.
"""
sub_issues_count = serializers.IntegerField(read_only=True)
class Meta:
@@ -216,13 +149,6 @@ class ModuleIssueSerializer(BaseSerializer):
class ModuleLinkSerializer(BaseSerializer):
"""
Serializer for module external links with URL validation.
Handles external resource associations with modules including
URL validation and duplicate prevention for reference management.
"""
class Meta:
model = ModuleLink
fields = "__all__"
@@ -248,27 +174,6 @@ class ModuleLinkSerializer(BaseSerializer):
class ModuleLiteSerializer(BaseSerializer):
"""
Lightweight module serializer for minimal data transfer.
Provides essential module information without computed metrics,
optimized for list views and reference lookups.
"""
class Meta:
model = Module
fields = "__all__"
class ModuleIssueRequestSerializer(serializers.Serializer):
"""
Serializer for bulk work item assignment to modules.
Validates work item ID lists for batch operations including
module assignment and work item organization workflows.
"""
issues = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs to add to the module",
)
+1 -169
View File
@@ -2,150 +2,12 @@
from rest_framework import serializers
# Module imports
from plane.db.models import (
Project,
ProjectIdentifier,
WorkspaceMember,
State,
Estimate,
)
from plane.db.models import Project, ProjectIdentifier, WorkspaceMember
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
)
from .base import BaseSerializer
class ProjectCreateSerializer(BaseSerializer):
"""
Serializer for creating projects with workspace validation.
Handles project creation including identifier validation, member verification,
and workspace association for new project initialization.
"""
class Meta:
model = Project
fields = [
"name",
"description",
"project_lead",
"default_assignee",
"identifier",
"icon_prop",
"emoji",
"cover_image",
"module_view",
"cycle_view",
"issue_views_view",
"page_view",
"intake_view",
"guest_view_all_features",
"archive_in",
"close_in",
"timezone",
]
read_only_fields = [
"id",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
def validate(self, data):
if data.get("project_lead", None) is not None:
# Check if the project lead is a member of the workspace
if not WorkspaceMember.objects.filter(
workspace_id=self.context["workspace_id"],
member_id=data.get("project_lead"),
).exists():
raise serializers.ValidationError(
"Project lead should be a user in the workspace"
)
if data.get("default_assignee", None) is not None:
# Check if the default assignee is a member of the workspace
if not WorkspaceMember.objects.filter(
workspace_id=self.context["workspace_id"],
member_id=data.get("default_assignee"),
).exists():
raise serializers.ValidationError(
"Default assignee should be a user in the workspace"
)
return data
def create(self, validated_data):
identifier = validated_data.get("identifier", "").strip().upper()
if identifier == "":
raise serializers.ValidationError(detail="Project Identifier is required")
if ProjectIdentifier.objects.filter(
name=identifier, workspace_id=self.context["workspace_id"]
).exists():
raise serializers.ValidationError(detail="Project Identifier is taken")
project = Project.objects.create(
**validated_data, workspace_id=self.context["workspace_id"]
)
return project
class ProjectUpdateSerializer(ProjectCreateSerializer):
"""
Serializer for updating projects with enhanced state and estimation management.
Extends project creation with update-specific validations including default state
assignment, estimation configuration, and project setting modifications.
"""
class Meta(ProjectCreateSerializer.Meta):
model = Project
fields = ProjectCreateSerializer.Meta.fields + [
"default_state",
"estimate",
]
read_only_fields = ProjectCreateSerializer.Meta.read_only_fields
def update(self, instance, validated_data):
"""Update a project"""
if (
validated_data.get("default_state", None) is not None
and not State.objects.filter(
project=instance, id=validated_data.get("default_state")
).exists()
):
# Check if the default state is a state in the project
raise serializers.ValidationError(
"Default state should be a state in the project"
)
if (
validated_data.get("estimate", None) is not None
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"
)
return super().update(instance, validated_data)
class ProjectSerializer(BaseSerializer):
"""
Comprehensive project serializer with metrics and member context.
Provides complete project data including member counts, cycle/module totals,
deployment status, and user-specific context for project management.
"""
total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True)
total_modules = serializers.IntegerField(read_only=True)
@@ -195,29 +57,6 @@ class ProjectSerializer(BaseSerializer):
"Default assignee should be a user in the workspace"
)
# Validate description content for security
if "description" in data and data["description"]:
# For Project, description might be text field, not JSON
if isinstance(data["description"], dict):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_text" in data and data["description_text"]:
is_valid, error_msg = validate_json_content(data["description_text"])
if not is_valid:
raise serializers.ValidationError({"description_text": error_msg})
if "description_html" in data and data["description_html"]:
if isinstance(data["description_html"], dict):
is_valid, error_msg = validate_json_content(data["description_html"])
else:
is_valid, error_msg = validate_html_content(
str(data["description_html"])
)
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
return data
def create(self, validated_data):
@@ -242,13 +81,6 @@ class ProjectSerializer(BaseSerializer):
class ProjectLiteSerializer(BaseSerializer):
"""
Lightweight project serializer for minimal data transfer.
Provides essential project information including identifiers, visual properties,
and basic metadata optimized for list views and references.
"""
cover_image_url = serializers.CharField(read_only=True)
class Meta:
-15
View File
@@ -4,13 +4,6 @@ from plane.db.models import State
class StateSerializer(BaseSerializer):
"""
Serializer for work item states with default state management.
Handles state creation and updates including default state validation
and automatic default state switching for workflow management.
"""
def validate(self, data):
# If the default is being provided then make all other states default False
if data.get("default", False):
@@ -31,18 +24,10 @@ class StateSerializer(BaseSerializer):
"workspace",
"project",
"deleted_at",
"slug",
]
class StateLiteSerializer(BaseSerializer):
"""
Lightweight state serializer for minimal data transfer.
Provides essential state information including visual properties
and grouping data optimized for UI display and filtering.
"""
class Meta:
model = State
fields = ["id", "name", "color", "group"]
-14
View File
@@ -1,5 +1,3 @@
from rest_framework import serializers
# Module imports
from plane.db.models import User
@@ -7,18 +5,6 @@ from .base import BaseSerializer
class UserLiteSerializer(BaseSerializer):
"""
Lightweight user serializer for minimal data transfer.
Provides essential user information including names, avatar, and contact details
optimized for member lists, assignee displays, and user references.
"""
avatar_url = serializers.CharField(
help_text="Avatar URL",
read_only=True,
)
class Meta:
model = User
fields = [
+1 -6
View File
@@ -4,12 +4,7 @@ from .base import BaseSerializer
class WorkspaceLiteSerializer(BaseSerializer):
"""
Lightweight workspace serializer for minimal data transfer.
Provides essential workspace identifiers including name, slug, and ID
optimized for navigation, references, and performance-critical operations.
"""
"""Lite serializer with only required fields"""
class Meta:
model = Workspace
-4
View File
@@ -5,11 +5,8 @@ from .cycle import urlpatterns as cycle_patterns
from .module import urlpatterns as module_patterns
from .intake import urlpatterns as intake_patterns
from .member import urlpatterns as member_patterns
from .asset import urlpatterns as asset_patterns
from .user import urlpatterns as user_patterns
urlpatterns = [
*asset_patterns,
*project_patterns,
*state_patterns,
*issue_patterns,
@@ -17,5 +14,4 @@ urlpatterns = [
*module_patterns,
*intake_patterns,
*member_patterns,
*user_patterns,
]
-40
View File
@@ -1,40 +0,0 @@
from django.urls import path
from plane.api.views import (
UserAssetEndpoint,
UserServerAssetEndpoint,
GenericAssetEndpoint,
)
urlpatterns = [
path(
"assets/user-assets/",
UserAssetEndpoint.as_view(http_method_names=["post"]),
name="user-assets",
),
path(
"assets/user-assets/<uuid:asset_id>/",
UserAssetEndpoint.as_view(http_method_names=["patch", "delete"]),
name="user-assets-detail",
),
path(
"assets/user-assets/server/",
UserServerAssetEndpoint.as_view(http_method_names=["post"]),
name="user-server-assets",
),
path(
"assets/user-assets/<uuid:asset_id>/server/",
UserServerAssetEndpoint.as_view(http_method_names=["patch", "delete"]),
name="user-server-assets-detail",
),
path(
"workspaces/<str:slug>/assets/",
GenericAssetEndpoint.as_view(http_method_names=["post"]),
name="generic-asset",
),
path(
"workspaces/<str:slug>/assets/<uuid:asset_id>/",
GenericAssetEndpoint.as_view(http_method_names=["get", "patch"]),
name="generic-asset-detail",
),
]
+9 -16
View File
@@ -1,10 +1,8 @@
from django.urls import path
from plane.api.views.cycle import (
CycleListCreateAPIEndpoint,
CycleDetailAPIEndpoint,
CycleIssueListCreateAPIEndpoint,
CycleIssueDetailAPIEndpoint,
CycleAPIEndpoint,
CycleIssueAPIEndpoint,
TransferCycleIssueAPIEndpoint,
CycleArchiveUnarchiveAPIEndpoint,
)
@@ -12,42 +10,37 @@ from plane.api.views.cycle import (
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/",
CycleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
CycleAPIEndpoint.as_view(),
name="cycles",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/",
CycleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
CycleAPIEndpoint.as_view(),
name="cycles",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/",
CycleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
CycleIssueAPIEndpoint.as_view(),
name="cycle-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:issue_id>/",
CycleIssueDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]),
CycleIssueAPIEndpoint.as_view(),
name="cycle-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/transfer-issues/",
TransferCycleIssueAPIEndpoint.as_view(http_method_names=["post"]),
TransferCycleIssueAPIEndpoint.as_view(),
name="transfer-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/archive/",
CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]),
CycleArchiveUnarchiveAPIEndpoint.as_view(),
name="cycle-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/",
CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]),
name="cycle-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/<uuid:pk>/unarchive/",
CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]),
CycleArchiveUnarchiveAPIEndpoint.as_view(),
name="cycle-archive-unarchive",
),
]
+3 -8
View File
@@ -1,22 +1,17 @@
from django.urls import path
from plane.api.views import (
IntakeIssueListCreateAPIEndpoint,
IntakeIssueDetailAPIEndpoint,
)
from plane.api.views import IntakeIssueAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/",
IntakeIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
IntakeIssueAPIEndpoint.as_view(),
name="intake-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/<uuid:issue_id>/",
IntakeIssueDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
IntakeIssueAPIEndpoint.as_view(),
name="intake-issue",
),
]
+20 -36
View File
@@ -1,95 +1,79 @@
from django.urls import path
from plane.api.views import (
IssueListCreateAPIEndpoint,
IssueDetailAPIEndpoint,
LabelListCreateAPIEndpoint,
LabelDetailAPIEndpoint,
IssueLinkListCreateAPIEndpoint,
IssueLinkDetailAPIEndpoint,
IssueCommentListCreateAPIEndpoint,
IssueCommentDetailAPIEndpoint,
IssueActivityListAPIEndpoint,
IssueActivityDetailAPIEndpoint,
IssueAttachmentListCreateAPIEndpoint,
IssueAttachmentDetailAPIEndpoint,
IssueAPIEndpoint,
LabelAPIEndpoint,
IssueLinkAPIEndpoint,
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
WorkspaceIssueAPIEndpoint,
IssueSearchEndpoint,
IssueAttachmentEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/issues/search/",
IssueSearchEndpoint.as_view(http_method_names=["get"]),
name="issue-search",
),
path(
"workspaces/<str:slug>/issues/<str:project_identifier>-<str:issue_identifier>/",
WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
"workspaces/<str:slug>/issues/<str:project__identifier>-<str:issue__identifier>/",
WorkspaceIssueAPIEndpoint.as_view(),
name="issue-by-identifier",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
IssueAPIEndpoint.as_view(),
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
IssueAPIEndpoint.as_view(),
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
LabelAPIEndpoint.as_view(),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
LabelAPIEndpoint.as_view(),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
IssueLinkAPIEndpoint.as_view(),
name="link",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
IssueLinkDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
IssueLinkAPIEndpoint.as_view(),
name="link",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
IssueCommentAPIEndpoint.as_view(),
name="comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
IssueCommentAPIEndpoint.as_view(),
name="comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]),
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]),
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
IssueAttachmentEndpoint.as_view(),
name="attachment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]),
IssueAttachmentEndpoint.as_view(),
name="issue-attachment",
),
]
+4 -9
View File
@@ -1,16 +1,11 @@
from django.urls import path
from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
from plane.api.views import ProjectMemberAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<str:project_id>/members/",
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
name="project-members",
),
path(
"workspaces/<str:slug>/members/",
WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]),
name="workspace-members",
),
ProjectMemberAPIEndpoint.as_view(),
name="users",
)
]
+12 -19
View File
@@ -1,47 +1,40 @@
from django.urls import path
from plane.api.views import (
ModuleListCreateAPIEndpoint,
ModuleDetailAPIEndpoint,
ModuleIssueListCreateAPIEndpoint,
ModuleIssueDetailAPIEndpoint,
ModuleAPIEndpoint,
ModuleIssueAPIEndpoint,
ModuleArchiveUnarchiveAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/",
ModuleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
ModuleAPIEndpoint.as_view(),
name="modules",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/",
ModuleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="modules-detail",
ModuleAPIEndpoint.as_view(),
name="modules",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
ModuleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
ModuleIssueAPIEndpoint.as_view(),
name="module-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
ModuleIssueDetailAPIEndpoint.as_view(http_method_names=["delete"]),
name="module-issues-detail",
ModuleIssueAPIEndpoint.as_view(),
name="module-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/archive/",
ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]),
name="module-archive",
ModuleArchiveUnarchiveAPIEndpoint.as_view(),
name="module-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/",
ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]),
name="module-archive-list",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/<uuid:pk>/unarchive/",
ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]),
name="module-unarchive",
ModuleArchiveUnarchiveAPIEndpoint.as_view(),
name="module-archive-unarchive",
),
]
+4 -12
View File
@@ -1,27 +1,19 @@
from django.urls import path
from plane.api.views import (
ProjectListCreateAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
)
from plane.api.views import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/",
ProjectListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project",
"workspaces/<str:slug>/projects/", ProjectAPIEndpoint.as_view(), name="project"
),
path(
"workspaces/<str:slug>/projects/<uuid:pk>/",
ProjectDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
ProjectAPIEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archive/",
ProjectArchiveUnarchiveAPIEndpoint.as_view(
http_method_names=["post", "delete"]
),
ProjectArchiveUnarchiveAPIEndpoint.as_view(),
name="project-archive-unarchive",
),
]
-20
View File
@@ -1,20 +0,0 @@
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
from django.urls import path
urlpatterns = [
path("schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"schema/swagger-ui/",
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path(
"schema/redoc/",
SpectacularRedocView.as_view(url_name="schema"),
name="redoc",
),
]
+3 -6
View File
@@ -1,19 +1,16 @@
from django.urls import path
from plane.api.views import (
StateListCreateAPIEndpoint,
StateDetailAPIEndpoint,
)
from plane.api.views import StateAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/",
StateListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
StateAPIEndpoint.as_view(),
name="states",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:state_id>/",
StateDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
StateAPIEndpoint.as_view(),
name="states",
),
]
-11
View File
@@ -1,11 +0,0 @@
from django.urls import path
from plane.api.views import UserEndpoint
urlpatterns = [
path(
"users/me/",
UserEndpoint.as_view(http_method_names=["get"]),
name="users",
),
]
+14 -39
View File
@@ -1,55 +1,30 @@
from .project import (
ProjectListCreateAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
)
from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint
from .state import (
StateListCreateAPIEndpoint,
StateDetailAPIEndpoint,
)
from .state import StateAPIEndpoint
from .issue import (
WorkspaceIssueAPIEndpoint,
IssueListCreateAPIEndpoint,
IssueDetailAPIEndpoint,
LabelListCreateAPIEndpoint,
LabelDetailAPIEndpoint,
IssueLinkListCreateAPIEndpoint,
IssueLinkDetailAPIEndpoint,
IssueCommentListCreateAPIEndpoint,
IssueCommentDetailAPIEndpoint,
IssueActivityListAPIEndpoint,
IssueActivityDetailAPIEndpoint,
IssueAttachmentListCreateAPIEndpoint,
IssueAttachmentDetailAPIEndpoint,
IssueSearchEndpoint,
IssueAPIEndpoint,
LabelAPIEndpoint,
IssueLinkAPIEndpoint,
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
IssueAttachmentEndpoint,
)
from .cycle import (
CycleListCreateAPIEndpoint,
CycleDetailAPIEndpoint,
CycleIssueListCreateAPIEndpoint,
CycleIssueDetailAPIEndpoint,
CycleAPIEndpoint,
CycleIssueAPIEndpoint,
TransferCycleIssueAPIEndpoint,
CycleArchiveUnarchiveAPIEndpoint,
)
from .module import (
ModuleListCreateAPIEndpoint,
ModuleDetailAPIEndpoint,
ModuleIssueListCreateAPIEndpoint,
ModuleIssueDetailAPIEndpoint,
ModuleAPIEndpoint,
ModuleIssueAPIEndpoint,
ModuleArchiveUnarchiveAPIEndpoint,
)
from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
from .member import ProjectMemberAPIEndpoint
from .intake import (
IntakeIssueListCreateAPIEndpoint,
IntakeIssueDetailAPIEndpoint,
)
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
from .user import UserEndpoint
from .intake import IntakeIssueAPIEndpoint
-631
View File
@@ -1,631 +0,0 @@
# Python Imports
import uuid
# Django Imports
from django.utils import timezone
from django.conf import settings
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiExample, OpenApiRequest, OpenApiTypes
# Module Imports
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.settings.storage import S3Storage
from plane.db.models import FileAsset, User, Workspace
from plane.api.views.base import BaseAPIView
from plane.api.serializers import (
UserAssetUploadSerializer,
AssetUpdateSerializer,
GenericAssetUploadSerializer,
GenericAssetUpdateSerializer,
)
from plane.utils.openapi import (
ASSET_ID_PARAMETER,
WORKSPACE_SLUG_PARAMETER,
PRESIGNED_URL_SUCCESS_RESPONSE,
GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE,
GENERIC_ASSET_VALIDATION_ERROR_RESPONSE,
ASSET_CONFLICT_RESPONSE,
ASSET_DOWNLOAD_SUCCESS_RESPONSE,
ASSET_DOWNLOAD_ERROR_RESPONSE,
ASSET_UPDATED_RESPONSE,
ASSET_DELETED_RESPONSE,
VALIDATION_ERROR_RESPONSE,
ASSET_NOT_FOUND_RESPONSE,
NOT_FOUND_RESPONSE,
UNAUTHORIZED_RESPONSE,
asset_docs,
)
from plane.utils.exception_logger import log_exception
class UserAssetEndpoint(BaseAPIView):
"""This endpoint is used to upload user profile images."""
def asset_delete(self, asset_id):
asset = FileAsset.objects.filter(id=asset_id).first()
if asset is None:
return
asset.is_deleted = True
asset.deleted_at = timezone.now()
asset.save(update_fields=["is_deleted", "deleted_at"])
return
def entity_asset_delete(self, entity_type, asset, request):
# User Avatar
if entity_type == FileAsset.EntityTypeContext.USER_AVATAR:
user = User.objects.get(id=asset.user_id)
user.avatar_asset_id = None
user.save()
return
# User Cover
if entity_type == FileAsset.EntityTypeContext.USER_COVER:
user = User.objects.get(id=asset.user_id)
user.cover_image_asset_id = None
user.save()
return
return
@asset_docs(
operation_id="create_user_asset_upload",
summary="Generate presigned URL for user asset upload",
description="Generate presigned URL for user asset upload",
request=OpenApiRequest(
request=UserAssetUploadSerializer,
examples=[
OpenApiExample(
"User Avatar Upload",
value={
"name": "profile.jpg",
"type": "image/jpeg",
"size": 1024000,
"entity_type": "USER_AVATAR",
},
description="Example request for uploading a user avatar",
),
OpenApiExample(
"User Cover Upload",
value={
"name": "cover.jpg",
"type": "image/jpeg",
"size": 1024000,
"entity_type": "USER_COVER",
},
description="Example request for uploading a user cover",
),
],
),
responses={
200: PRESIGNED_URL_SUCCESS_RESPONSE,
400: VALIDATION_ERROR_RESPONSE,
401: UNAUTHORIZED_RESPONSE,
},
)
def post(self, request):
"""Generate presigned URL for user asset upload.
Create a presigned URL for uploading user profile assets (avatar or cover image).
This endpoint generates the necessary credentials for direct S3 upload.
"""
# get the asset key
name = request.data.get("name")
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", False)
# Check if the file size is within the limit
size_limit = min(size, settings.FILE_SIZE_LIMIT)
# Check if the entity type is allowed
if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]:
return Response(
{"error": "Invalid entity type.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the file type is allowed
allowed_types = [
"image/jpeg",
"image/png",
"image/webp",
"image/jpg",
"image/gif",
]
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
# asset key
asset_key = f"{uuid.uuid4().hex}-{name}"
# Create a File Asset
asset = FileAsset.objects.create(
attributes={"name": name, "type": type, "size": size_limit},
asset=asset_key,
size=size_limit,
user=request.user,
created_by=request.user,
entity_type=entity_type,
)
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
"upload_data": presigned_url,
"asset_id": str(asset.id),
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
@asset_docs(
operation_id="update_user_asset",
summary="Mark user asset as uploaded",
description="Mark user asset as uploaded",
parameters=[ASSET_ID_PARAMETER],
request=OpenApiRequest(
request=AssetUpdateSerializer,
examples=[
OpenApiExample(
"Update Asset Attributes",
value={
"attributes": {
"name": "updated_profile.jpg",
"type": "image/jpeg",
"size": 1024000,
},
"entity_type": "USER_AVATAR",
},
description="Example request for updating asset attributes",
),
],
),
responses={
204: ASSET_UPDATED_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
)
def patch(self, request, asset_id):
"""Update user asset after upload completion.
Update the asset status and attributes after the file has been uploaded to S3.
This endpoint should be called after completing the S3 upload to mark the asset as uploaded.
"""
# get the asset id
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
# save the asset
asset.save(update_fields=["is_uploaded", "attributes"])
return Response(status=status.HTTP_204_NO_CONTENT)
@asset_docs(
operation_id="delete_user_asset",
summary="Delete user asset",
parameters=[ASSET_ID_PARAMETER],
responses={
204: ASSET_DELETED_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
)
def delete(self, request, asset_id):
"""Delete user asset.
Delete a user profile asset (avatar or cover image) and remove its reference from the user profile.
This performs a soft delete by marking the asset as deleted and updating the user's profile.
"""
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
class UserServerAssetEndpoint(BaseAPIView):
"""This endpoint is used to upload user profile images."""
def asset_delete(self, asset_id):
asset = FileAsset.objects.filter(id=asset_id).first()
if asset is None:
return
asset.is_deleted = True
asset.deleted_at = timezone.now()
asset.save(update_fields=["is_deleted", "deleted_at"])
return
def entity_asset_delete(self, entity_type, asset, request):
# User Avatar
if entity_type == FileAsset.EntityTypeContext.USER_AVATAR:
user = User.objects.get(id=asset.user_id)
user.avatar_asset_id = None
user.save()
return
# User Cover
if entity_type == FileAsset.EntityTypeContext.USER_COVER:
user = User.objects.get(id=asset.user_id)
user.cover_image_asset_id = None
user.save()
return
return
@asset_docs(
operation_id="create_user_server_asset_upload",
summary="Generate presigned URL for user server asset upload",
request=UserAssetUploadSerializer,
responses={
200: PRESIGNED_URL_SUCCESS_RESPONSE,
400: VALIDATION_ERROR_RESPONSE,
},
)
def post(self, request):
"""Generate presigned URL for user server asset upload.
Create a presigned URL for uploading user profile assets (avatar or cover image) using server credentials.
This endpoint generates the necessary credentials for direct S3 upload with server-side authentication.
"""
# get the asset key
name = request.data.get("name")
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", False)
# Check if the file size is within the limit
size_limit = min(size, settings.FILE_SIZE_LIMIT)
# Check if the entity type is allowed
if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]:
return Response(
{"error": "Invalid entity type.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the file type is allowed
allowed_types = [
"image/jpeg",
"image/png",
"image/webp",
"image/jpg",
"image/gif",
]
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
# asset key
asset_key = f"{uuid.uuid4().hex}-{name}"
# Create a File Asset
asset = FileAsset.objects.create(
attributes={"name": name, "type": type, "size": size_limit},
asset=asset_key,
size=size_limit,
user=request.user,
created_by=request.user,
entity_type=entity_type,
)
# Get the presigned URL
storage = S3Storage(request=request, is_server=True)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
"upload_data": presigned_url,
"asset_id": str(asset.id),
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
@asset_docs(
operation_id="update_user_server_asset",
summary="Mark user server asset as uploaded",
parameters=[ASSET_ID_PARAMETER],
request=AssetUpdateSerializer,
responses={
204: ASSET_UPDATED_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
)
def patch(self, request, asset_id):
"""Update user server asset after upload completion.
Update the asset status and attributes after the file has been uploaded to S3 using server credentials.
This endpoint should be called after completing the S3 upload to mark the asset as uploaded.
"""
# get the asset id
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
# save the asset
asset.save(update_fields=["is_uploaded", "attributes"])
return Response(status=status.HTTP_204_NO_CONTENT)
@asset_docs(
operation_id="delete_user_server_asset",
summary="Delete user server asset",
parameters=[ASSET_ID_PARAMETER],
responses={
204: ASSET_DELETED_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
)
def delete(self, request, asset_id):
"""Delete user server asset.
Delete a user profile asset (avatar or cover image) using server credentials and remove its reference from the user profile.
This performs a soft delete by marking the asset as deleted and updating the user's profile.
"""
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
class GenericAssetEndpoint(BaseAPIView):
"""This endpoint is used to upload generic assets that can be later bound to entities."""
use_read_replica = True
@asset_docs(
operation_id="get_generic_asset",
summary="Get presigned URL for asset download",
description="Get presigned URL for asset download",
parameters=[WORKSPACE_SLUG_PARAMETER],
responses={
200: ASSET_DOWNLOAD_SUCCESS_RESPONSE,
400: ASSET_DOWNLOAD_ERROR_RESPONSE,
404: ASSET_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug, asset_id):
"""Get presigned URL for asset download.
Generate a presigned URL for downloading a generic asset.
The asset must be uploaded and associated with the specified workspace.
"""
try:
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
# Get the asset
asset = FileAsset.objects.get(
id=asset_id, workspace_id=workspace.id, is_deleted=False
)
# Check if the asset exists and is uploaded
if not asset.is_uploaded:
return Response(
{"error": "Asset not yet uploaded"},
status=status.HTTP_400_BAD_REQUEST,
)
# Generate presigned URL for GET
storage = S3Storage(request=request, is_server=True)
presigned_url = storage.generate_presigned_url(
object_name=asset.asset.name, filename=asset.attributes.get("name")
)
return Response(
{
"asset_id": str(asset.id),
"asset_url": presigned_url,
"asset_name": asset.attributes.get("name", ""),
"asset_type": asset.attributes.get("type", ""),
},
status=status.HTTP_200_OK,
)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND
)
except FileAsset.DoesNotExist:
return Response(
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
log_exception(e)
return Response(
{"error": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@asset_docs(
operation_id="create_generic_asset_upload",
summary="Generate presigned URL for generic asset upload",
description="Generate presigned URL for generic asset upload",
parameters=[WORKSPACE_SLUG_PARAMETER],
request=OpenApiRequest(
request=GenericAssetUploadSerializer,
examples=[
OpenApiExample(
"GenericAssetUploadSerializer",
value={
"name": "image.jpg",
"type": "image/jpeg",
"size": 1024000,
"project_id": "123e4567-e89b-12d3-a456-426614174000",
"external_id": "1234567890",
"external_source": "github",
},
description="Example request for uploading a generic asset",
),
],
),
responses={
200: GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE,
400: GENERIC_ASSET_VALIDATION_ERROR_RESPONSE,
404: NOT_FOUND_RESPONSE,
409: ASSET_CONFLICT_RESPONSE,
},
)
def post(self, request, slug):
"""Generate presigned URL for generic asset upload.
Create a presigned URL for uploading generic assets that can be bound to entities like work items.
Supports various file types and includes external source tracking for integrations.
"""
name = request.data.get("name")
type = request.data.get("type")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
project_id = request.data.get("project_id")
external_id = request.data.get("external_id")
external_source = request.data.get("external_source")
# Check if the request is valid
if not name or not size:
return Response(
{"error": "Name and size are required fields.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the file size is within the limit
size_limit = min(size, settings.FILE_SIZE_LIMIT)
# Check if the file type is allowed
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response(
{"error": "Invalid file type.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
# Check for existing asset with same external details if provided
if external_id and external_source:
existing_asset = FileAsset.objects.filter(
workspace__slug=slug,
external_source=external_source,
external_id=external_id,
is_deleted=False,
).first()
if existing_asset:
return Response(
{
"message": "Asset with same external id and source already exists",
"asset_id": str(existing_asset.id),
"asset_url": existing_asset.asset_url,
},
status=status.HTTP_409_CONFLICT,
)
# Create a File Asset
asset = FileAsset.objects.create(
attributes={"name": name, "type": type, "size": size_limit},
asset=asset_key,
size=size_limit,
workspace_id=workspace.id,
project_id=project_id,
created_by=request.user,
external_id=external_id,
external_source=external_source,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues
)
# Get the presigned URL
storage = S3Storage(request=request, is_server=True)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
return Response(
{
"upload_data": presigned_url,
"asset_id": str(asset.id),
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
@asset_docs(
operation_id="update_generic_asset",
summary="Update generic asset after upload completion",
description="Update generic asset after upload completion",
parameters=[WORKSPACE_SLUG_PARAMETER, ASSET_ID_PARAMETER],
request=OpenApiRequest(
request=GenericAssetUpdateSerializer,
examples=[
OpenApiExample(
"GenericAssetUpdateSerializer",
value={"is_uploaded": True},
description="Example request for updating a generic asset",
)
],
),
responses={
204: ASSET_UPDATED_RESPONSE,
404: ASSET_NOT_FOUND_RESPONSE,
},
)
def patch(self, request, slug, asset_id):
"""Update generic asset after upload completion.
Update the asset status after the file has been uploaded to S3.
This endpoint should be called after completing the S3 upload to mark the asset as uploaded
and trigger metadata extraction.
"""
try:
asset = FileAsset.objects.get(
id=asset_id, workspace__slug=slug, is_deleted=False
)
# Update is_uploaded status
asset.is_uploaded = request.data.get("is_uploaded", asset.is_uploaded)
# Update storage metadata if not present
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
asset.save(update_fields=["is_uploaded"])
return Response(status=status.HTTP_204_NO_CONTENT)
except FileAsset.DoesNotExist:
return Response(
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
)
+2 -7
View File
@@ -13,14 +13,13 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
# Third party imports
from rest_framework.generics import GenericAPIView
from rest_framework.views import APIView
# Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
from plane.utils.core.mixins import ReadReplicaControlMixin
class TimezoneMixin:
@@ -37,15 +36,11 @@ class TimezoneMixin:
timezone.deactivate()
class BaseAPIView(
TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator
):
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
authentication_classes = [APIKeyAuthentication]
permission_classes = [IsAuthenticated]
use_read_replica = False
def filter_queryset(self, queryset):
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self)
+43 -477
View File
@@ -23,18 +23,9 @@ from django.db import models
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
# Module imports
from plane.api.serializers import (
CycleIssueSerializer,
CycleSerializer,
CycleIssueRequestSerializer,
TransferCycleIssueRequestSerializer,
CycleCreateSerializer,
CycleUpdateSerializer,
IssueSerializer,
)
from plane.api.serializers import CycleIssueSerializer, CycleSerializer, IssueSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import (
@@ -51,42 +42,19 @@ from plane.utils.analytics_plot import burndown_plot
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
from plane.utils.openapi.decorators import cycle_docs
from plane.utils.openapi import (
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
CYCLE_VIEW_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
create_paginated_response,
# Request Examples
CYCLE_CREATE_EXAMPLE,
CYCLE_UPDATE_EXAMPLE,
CYCLE_ISSUE_REQUEST_EXAMPLE,
TRANSFER_CYCLE_ISSUE_EXAMPLE,
# Response Examples
CYCLE_EXAMPLE,
CYCLE_ISSUE_EXAMPLE,
TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE,
TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE,
TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE,
DELETED_RESPONSE,
ARCHIVED_RESPONSE,
CYCLE_CANNOT_ARCHIVE_RESPONSE,
UNARCHIVED_RESPONSE,
REQUIRED_FIELDS_RESPONSE,
)
class CycleListCreateAPIEndpoint(BaseAPIView):
"""Cycle List and Create Endpoint"""
class CycleAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle.
"""
serializer_class = CycleSerializer
model = Cycle
webhook_event = "cycle"
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
@@ -168,34 +136,17 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
.distinct()
)
@cycle_docs(
operation_id="list_cycles",
summary="List cycles",
description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.",
parameters=[
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
CYCLE_VIEW_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: create_paginated_response(
CycleSerializer,
"PaginatedCycleResponse",
"Paginated list of cycles",
"Paginated Cycles",
),
},
)
def get(self, request, slug, project_id):
"""List cycles
Retrieve all cycles in a project.
Supports filtering by cycle status like current, upcoming, completed, or draft.
"""
def get(self, request, slug, project_id, pk=None):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
if pk:
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
data = CycleSerializer(
queryset,
fields=self.fields,
expand=self.expand,
context={"project": project},
).data
return Response(data, status=status.HTTP_200_OK)
queryset = self.get_queryset().filter(archived_at__isnull=True)
cycle_view = request.GET.get("cycle_view", "all")
@@ -286,28 +237,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
).data,
)
@cycle_docs(
operation_id="create_cycle",
summary="Create cycle",
description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.",
request=OpenApiRequest(
request=CycleCreateSerializer,
examples=[CYCLE_CREATE_EXAMPLE],
),
responses={
201: OpenApiResponse(
description="Cycle created",
response=CycleSerializer,
examples=[CYCLE_EXAMPLE],
),
},
)
def post(self, request, slug, project_id):
"""Create cycle
Create a new development cycle with specified name, description, and date range.
Supports external ID tracking for integration purposes.
"""
if (
request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None
@@ -315,7 +245,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
):
serializer = CycleCreateSerializer(data=request.data)
serializer = CycleSerializer(data=request.data)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -344,16 +274,13 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
# Send the model activity
model_activity.delay(
model_name="cycle",
model_id=str(serializer.instance.id),
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
cycle = Cycle.objects.get(pk=serializer.instance.id)
serializer = CycleSerializer(cycle)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
@@ -364,148 +291,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
class CycleDetailAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `retrieve`, `update` and `destroy` actions related to cycle.
"""
serializer_class = CycleSerializer
model = Cycle
webhook_event = "cycle"
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
@cycle_docs(
operation_id="retrieve_cycle",
summary="Retrieve cycle",
description="Retrieve details of a specific cycle by its ID. Supports cycle status filtering.",
responses={
200: OpenApiResponse(
description="Cycles",
response=CycleSerializer,
examples=[CYCLE_EXAMPLE],
),
},
)
def get(self, request, slug, project_id, pk):
"""List or retrieve cycles
Retrieve all cycles in a project or get details of a specific cycle.
Supports filtering by cycle status like current, upcoming, completed, or draft.
"""
project = Project.objects.get(workspace__slug=slug, pk=project_id)
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
data = CycleSerializer(
queryset,
fields=self.fields,
expand=self.expand,
context={"project": project},
).data
return Response(data, status=status.HTTP_200_OK)
@cycle_docs(
operation_id="update_cycle",
summary="Update cycle",
description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.",
request=OpenApiRequest(
request=CycleUpdateSerializer,
examples=[CYCLE_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Cycle updated",
response=CycleSerializer,
examples=[CYCLE_EXAMPLE],
),
},
)
def patch(self, request, slug, project_id, pk):
"""Update cycle
Modify an existing cycle's properties like name, description, or date range.
Completed cycles can only have their sort order changed.
"""
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
current_instance = json.dumps(
@@ -534,7 +320,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True)
serializer = CycleSerializer(cycle, data=request.data, partial=True)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -560,32 +346,17 @@ class CycleDetailAPIEndpoint(BaseAPIView):
# Send the model activity
model_activity.delay(
model_name="cycle",
model_id=str(serializer.instance.id),
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
cycle = Cycle.objects.get(pk=serializer.instance.id)
serializer = CycleSerializer(cycle)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@cycle_docs(
operation_id="delete_cycle",
summary="Delete cycle",
description="Permanently remove a cycle and all its associated issue relationships",
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, project_id, pk):
"""Delete cycle
Permanently remove a cycle and all its associated issue relationships.
Only admins or the cycle creator can perform this action.
"""
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
if cycle.owned_by_id != request.user.id and (
not ProjectMember.objects.filter(
@@ -632,10 +403,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
"""Cycle Archive and Unarchive Endpoint"""
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
@@ -741,27 +509,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
.distinct()
)
@cycle_docs(
operation_id="list_archived_cycles",
description="Retrieve all cycles that have been archived in the project.",
summary="List archived cycles",
parameters=[CURSOR_PARAMETER, PER_PAGE_PARAMETER],
request={},
responses={
200: create_paginated_response(
CycleSerializer,
"PaginatedArchivedCycleResponse",
"Paginated list of archived cycles",
"Paginated Archived Cycles",
),
},
)
def get(self, request, slug, project_id):
"""List archived cycles
Retrieve all cycles that have been archived in the project.
Returns paginated results with cycle statistics and completion data.
"""
return self.paginate(
request=request,
queryset=(self.get_queryset()),
@@ -770,22 +518,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).data,
)
@cycle_docs(
operation_id="archive_cycle",
summary="Archive cycle",
description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.",
request={},
responses={
204: ARCHIVED_RESPONSE,
400: CYCLE_CANNOT_ARCHIVE_RESPONSE,
},
)
def post(self, request, slug, project_id, cycle_id):
"""Archive cycle
Move a completed cycle to archived status for historical tracking.
Only cycles that have ended can be archived.
"""
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
@@ -804,21 +537,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@cycle_docs(
operation_id="unarchive_cycle",
summary="Unarchive cycle",
description="Restore an archived cycle to active status, making it available for regular use.",
request={},
responses={
204: UNARCHIVED_RESPONSE,
},
)
def delete(self, request, slug, project_id, cycle_id):
"""Unarchive cycle
Restore an archived cycle to active status, making it available for regular use.
The cycle will reappear in active cycle lists.
"""
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
@@ -827,14 +546,18 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleIssueListCreateAPIEndpoint(BaseAPIView):
"""Cycle Issue List and Create Endpoint"""
class CycleIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
"""
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle_issue"
bulk = True
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
@@ -860,27 +583,20 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
.distinct()
)
@cycle_docs(
operation_id="list_cycle_work_items",
summary="List cycle work items",
description="Retrieve all work items assigned to a cycle.",
parameters=[CURSOR_PARAMETER, PER_PAGE_PARAMETER],
request={},
responses={
200: create_paginated_response(
CycleIssueSerializer,
"PaginatedCycleIssueResponse",
"Paginated list of cycle work items",
"Paginated Cycle Work Items",
),
},
)
def get(self, request, slug, project_id, cycle_id):
"""List or retrieve cycle work items
def get(self, request, slug, project_id, cycle_id, issue_id=None):
# Get
if issue_id:
cycle_issue = CycleIssue.objects.get(
workspace__slug=slug,
project_id=project_id,
cycle_id=cycle_id,
issue_id=issue_id,
)
serializer = CycleIssueSerializer(
cycle_issue, fields=self.fields, expand=self.expand
)
return Response(serializer.data, status=status.HTTP_200_OK)
Retrieve all work items assigned to a cycle or get details of a specific cycle work item.
Returns paginated results with work item details, assignees, and labels.
"""
# List
order_by = request.GET.get("order_by", "created_at")
issues = (
@@ -928,41 +644,19 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
).data,
)
@cycle_docs(
operation_id="add_cycle_work_items",
summary="Add Work Items to Cycle",
description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.",
request=OpenApiRequest(
request=CycleIssueRequestSerializer,
examples=[CYCLE_ISSUE_REQUEST_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Cycle work items added",
response=CycleIssueSerializer,
examples=[CYCLE_ISSUE_EXAMPLE],
),
400: REQUIRED_FIELDS_RESPONSE,
},
)
def post(self, request, slug, project_id, cycle_id):
"""Add cycle issues
Assign multiple work items to a cycle or move them from another cycle.
Automatically handles bulk creation and updates with activity tracking.
"""
issues = request.data.get("issues", [])
if not issues:
return Response(
{"error": "Work items are required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST
)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
# Get all CycleWorkItems already created
# Get all CycleIssues already created
cycle_issues = list(
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
)
@@ -1036,88 +730,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
class CycleIssueDetailAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
"""
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle_issue"
bulk = True
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
CycleIssue.objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(cycle_id=self.kwargs.get("cycle_id"))
.select_related("project")
.select_related("workspace")
.select_related("cycle")
.select_related("issue", "issue__state", "issue__project")
.prefetch_related("issue__assignees", "issue__labels")
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
@cycle_docs(
operation_id="retrieve_cycle_work_item",
summary="Retrieve cycle work item",
description="Retrieve details of a specific cycle work item.",
responses={
200: OpenApiResponse(
description="Cycle work items",
response=CycleIssueSerializer,
examples=[CYCLE_ISSUE_EXAMPLE],
),
},
)
def get(self, request, slug, project_id, cycle_id, issue_id):
"""Retrieve cycle work item
Retrieve details of a specific cycle work item.
Returns paginated results with work item details, assignees, and labels.
"""
cycle_issue = CycleIssue.objects.get(
workspace__slug=slug,
project_id=project_id,
cycle_id=cycle_id,
issue_id=issue_id,
)
serializer = CycleIssueSerializer(
cycle_issue, fields=self.fields, expand=self.expand
)
return Response(serializer.data, status=status.HTTP_200_OK)
@cycle_docs(
operation_id="delete_cycle_work_item",
summary="Delete cycle work item",
description="Remove a work item from a cycle while keeping the work item in the project.",
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, project_id, cycle_id, issue_id):
"""Remove cycle work item
Remove a work item from a cycle while keeping the work item in the project.
Records the removal activity for tracking purposes.
"""
cycle_issue = CycleIssue.objects.get(
issue_id=issue_id,
workspace__slug=slug,
@@ -1151,54 +764,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission]
@cycle_docs(
operation_id="transfer_cycle_work_items",
summary="Transfer cycle work items",
description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.",
request=OpenApiRequest(
request=TransferCycleIssueRequestSerializer,
examples=[TRANSFER_CYCLE_ISSUE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Work items transferred successfully",
response={
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Success message",
"example": "Success",
},
},
},
examples=[TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE],
),
400: OpenApiResponse(
description="Bad request",
response={
"type": "object",
"properties": {
"error": {
"type": "string",
"description": "Error message",
"example": "New Cycle Id is required",
},
},
},
examples=[
TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE,
TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE,
],
),
},
)
def post(self, request, slug, project_id, cycle_id):
"""Transfer cycle issues
Move incomplete issues from the current cycle to a new target cycle.
Captures progress snapshot and transfers only unfinished work items.
"""
new_cycle_id = request.data.get("new_cycle_id", False)
if not new_cycle_id:
+23 -197
View File
@@ -12,49 +12,30 @@ from django.contrib.postgres.fields import ArrayField
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
# Module imports
from plane.api.serializers import (
IntakeIssueSerializer,
IssueSerializer,
IntakeIssueCreateSerializer,
IntakeIssueUpdateSerializer,
)
from plane.api.serializers import IntakeIssueSerializer, IssueSerializer
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
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.db.models.intake import SourceType
from plane.utils.openapi import (
intake_docs,
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ISSUE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
create_paginated_response,
# Request Examples
INTAKE_ISSUE_CREATE_EXAMPLE,
INTAKE_ISSUE_UPDATE_EXAMPLE,
# Response Examples
INTAKE_ISSUE_EXAMPLE,
INVALID_REQUEST_RESPONSE,
DELETED_RESPONSE,
)
class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
"""Intake Work Item List and Create Endpoint"""
class IntakeIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to intake issues.
"""
permission_classes = [ProjectLitePermission]
serializer_class = IntakeIssueSerializer
model = IntakeIssue
model = Intake
permission_classes = [ProjectLitePermission]
use_read_replica = True
filterset_fields = ["status"]
def get_queryset(self):
intake = Intake.objects.filter(
@@ -80,33 +61,13 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
)
@intake_docs(
operation_id="get_intake_work_items_list",
summary="List intake work items",
description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: create_paginated_response(
IntakeIssueSerializer,
"PaginatedIntakeIssueResponse",
"Paginated list of intake work items",
"Paginated Intake Work Items",
),
},
)
def get(self, request, slug, project_id):
"""List intake work items
Retrieve all work items in the project's intake queue.
Returns paginated results when listing all intake work items.
"""
def get(self, request, slug, project_id, issue_id=None):
if issue_id:
intake_issue_queryset = self.get_queryset().get(issue_id=issue_id)
intake_issue_data = IntakeIssueSerializer(
intake_issue_queryset, fields=self.fields, expand=self.expand
).data
return Response(intake_issue_data, status=status.HTTP_200_OK)
issue_queryset = self.get_queryset()
return self.paginate(
request=request,
@@ -116,33 +77,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
).data,
)
@intake_docs(
operation_id="create_intake_work_item",
summary="Create intake work item",
description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
],
request=OpenApiRequest(
request=IntakeIssueCreateSerializer,
examples=[INTAKE_ISSUE_CREATE_EXAMPLE],
),
responses={
201: OpenApiResponse(
description="Intake work item created",
response=IntakeIssueSerializer,
examples=[INTAKE_ISSUE_EXAMPLE],
),
400: INVALID_REQUEST_RESPONSE,
},
)
def post(self, request, slug, project_id):
"""Create intake work item
Submit a new work item to the project's intake queue for review and triage.
Automatically creates the work item with default triage state and tracks activity.
"""
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
@@ -206,100 +141,9 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
)
serializer = IntakeIssueSerializer(intake_issue)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.data, status=status.HTTP_200_OK)
class IntakeIssueDetailAPIEndpoint(BaseAPIView):
"""Intake Issue API Endpoint"""
permission_classes = [ProjectLitePermission]
serializer_class = IntakeIssueSerializer
model = IntakeIssue
use_read_replica = True
filterset_fields = ["status"]
def get_queryset(self):
intake = Intake.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
).first()
project = Project.objects.get(
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
)
if intake is None and not project.intake_view:
return IntakeIssue.objects.none()
return (
IntakeIssue.objects.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
intake_id=intake.id,
)
.select_related("issue", "workspace", "project")
.order_by(self.kwargs.get("order_by", "-created_at"))
)
@intake_docs(
operation_id="retrieve_intake_work_item",
summary="Retrieve intake work item",
description="Retrieve details of a specific intake work item.",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ISSUE_ID_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Intake work item",
response=IntakeIssueSerializer,
examples=[INTAKE_ISSUE_EXAMPLE],
),
},
)
def get(self, request, slug, project_id, issue_id):
"""Retrieve intake work item
Retrieve details of a specific intake work item.
"""
intake_issue_queryset = self.get_queryset().get(issue_id=issue_id)
intake_issue_data = IntakeIssueSerializer(
intake_issue_queryset, fields=self.fields, expand=self.expand
).data
return Response(intake_issue_data, status=status.HTTP_200_OK)
@intake_docs(
operation_id="update_intake_work_item",
summary="Update intake work item",
description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ISSUE_ID_PARAMETER,
],
request=OpenApiRequest(
request=IntakeIssueUpdateSerializer,
examples=[INTAKE_ISSUE_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Intake work item updated",
response=IntakeIssueSerializer,
examples=[INTAKE_ISSUE_EXAMPLE],
),
400: INVALID_REQUEST_RESPONSE,
},
)
def patch(self, request, slug, project_id, issue_id):
"""Update intake work item
Modify an existing intake work item's properties or status for triage processing.
Supports status changes like accept, reject, or mark as duplicate.
"""
intake = Intake.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
@@ -336,7 +180,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
request.user.id
):
return Response(
{"error": "You cannot edit intake work items"},
{"error": "You cannot edit intake issues"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -407,7 +251,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
# Only project admins and members can edit intake issue attributes
if project_member.role > 15:
serializer = IntakeIssueUpdateSerializer(
serializer = IntakeIssueSerializer(
intake_issue, data=request.data, partial=True
)
current_instance = json.dumps(
@@ -457,7 +301,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
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:
@@ -465,25 +309,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK
)
@intake_docs(
operation_id="delete_intake_work_item",
summary="Delete intake work item",
description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ISSUE_ID_PARAMETER,
],
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, project_id, issue_id):
"""Delete intake work item
Permanently remove an intake work item from the triage queue.
Also deletes the underlying work item if it hasn't been accepted yet.
"""
intake = Intake.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
@@ -523,7 +349,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
).exists()
):
return Response(
{"error": "Only admin or creator can delete the work item"},
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
issue.delete()
File diff suppressed because it is too large Load Diff
+98 -103
View File
@@ -1,122 +1,29 @@
# Python imports
import uuid
# Django imports
from django.contrib.auth.hashers import make_password
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from drf_spectacular.utils import (
extend_schema,
OpenApiResponse,
)
# Module imports
from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer
from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember
from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission
from plane.utils.openapi import (
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
UNAUTHORIZED_RESPONSE,
FORBIDDEN_RESPONSE,
WORKSPACE_NOT_FOUND_RESPONSE,
PROJECT_NOT_FOUND_RESPONSE,
WORKSPACE_MEMBER_EXAMPLE,
PROJECT_MEMBER_EXAMPLE,
)
from plane.db.models import User, Workspace, Project, WorkspaceMember, ProjectMember
class WorkspaceMemberAPIEndpoint(BaseAPIView):
permission_classes = [WorkSpaceAdminPermission]
use_read_replica = True
@extend_schema(
operation_id="get_workspace_members",
summary="List workspace members",
description="Retrieve all users who are members of the specified workspace.",
tags=["Members"],
parameters=[WORKSPACE_SLUG_PARAMETER],
responses={
200: OpenApiResponse(
description="List of workspace members with their roles",
response={
"type": "array",
"items": {
"allOf": [
{"$ref": "#/components/schemas/UserLite"},
{
"type": "object",
"properties": {
"role": {
"type": "integer",
"description": "Member role in the workspace",
}
},
},
]
},
},
examples=[WORKSPACE_MEMBER_EXAMPLE],
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: WORKSPACE_NOT_FOUND_RESPONSE,
},
)
# Get all the users that are present inside the workspace
def get(self, request, slug):
"""List workspace members
Retrieve all users who are members of the specified workspace.
Returns user profiles with their respective workspace roles and permissions.
"""
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"error": "Provided workspace does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug
).select_related("member")
# Get all the users with their roles
users_with_roles = []
for workspace_member in workspace_members:
user_data = UserLiteSerializer(workspace_member.member).data
user_data["role"] = workspace_member.role
users_with_roles.append(user_data)
return Response(users_with_roles, status=status.HTTP_200_OK)
from plane.app.permissions import ProjectMemberPermission
# API endpoint to get and insert users inside the workspace
class ProjectMemberAPIEndpoint(BaseAPIView):
permission_classes = [ProjectMemberPermission]
use_read_replica = True
@extend_schema(
operation_id="get_project_members",
summary="List project members",
description="Retrieve all users who are members of the specified project.",
tags=["Members"],
parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
responses={
200: OpenApiResponse(
description="List of project members with their roles",
response=UserLiteSerializer,
examples=[PROJECT_MEMBER_EXAMPLE],
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: PROJECT_NOT_FOUND_RESPONSE,
},
)
# Get all the users that are present inside the workspace
def get(self, request, slug, project_id):
"""List project members
Retrieve all users who are members of the specified project.
Returns user profiles with their project-specific roles and access levels.
"""
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
@@ -135,3 +42,91 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
).data
return Response(users, status=status.HTTP_200_OK)
# Insert a new user inside the workspace, and assign the user to the project
def post(self, request, slug, project_id):
# Check if user with email already exists, and send bad request if it's
# not present, check for workspace and valid project mandat
# ------------------- Validation -------------------
if (
request.data.get("email") is None
or request.data.get("display_name") is None
):
return Response(
{
"error": "Expected email, display_name, workspace_slug, project_id, one or more of the fields are missing."
},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email")
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Invalid email provided"}, status=status.HTTP_400_BAD_REQUEST
)
workspace = Workspace.objects.filter(slug=slug).first()
project = Project.objects.filter(pk=project_id).first()
if not all([workspace, project]):
return Response(
{"error": "Provided workspace or project does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if user exists
user = User.objects.filter(email=email).first()
workspace_member = None
project_member = None
if user:
# Check if user is part of the workspace
workspace_member = WorkspaceMember.objects.filter(
workspace=workspace, member=user
).first()
if workspace_member:
# Check if user is part of the project
project_member = ProjectMember.objects.filter(
project=project, member=user
).first()
if project_member:
return Response(
{"error": "User is already part of the workspace and project"},
status=status.HTTP_400_BAD_REQUEST,
)
# If user does not exist, create the user
if not user:
user = User.objects.create(
email=email,
display_name=request.data.get("display_name"),
first_name=request.data.get("first_name", ""),
last_name=request.data.get("last_name", ""),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
is_active=False,
)
user.save()
# Create a workspace member for the user if not already a member
if not workspace_member:
workspace_member = WorkspaceMember.objects.create(
workspace=workspace, member=user, role=request.data.get("role", 5)
)
workspace_member.save()
# Create a project member for the user if not already a member
if not project_member:
project_member = ProjectMember.objects.create(
project=project, member=user, role=request.data.get("role", 5)
)
project_member.save()
# Serialize the user and return the response
user_data = UserLiteSerializer(user).data
return Response(user_data, status=status.HTTP_201_CREATED)
+35 -536
View File
@@ -10,16 +10,12 @@ from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
# Module imports
from plane.api.serializers import (
IssueSerializer,
ModuleIssueSerializer,
ModuleSerializer,
ModuleIssueRequestSerializer,
ModuleCreateSerializer,
ModuleUpdateSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activities_task import issue_activity
@@ -38,49 +34,19 @@ from plane.db.models import (
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
from plane.utils.host import base_host
from plane.utils.openapi import (
module_docs,
module_issue_docs,
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
MODULE_ID_PARAMETER,
MODULE_PK_PARAMETER,
ISSUE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
create_paginated_response,
# Request Examples
MODULE_CREATE_EXAMPLE,
MODULE_UPDATE_EXAMPLE,
MODULE_ISSUE_REQUEST_EXAMPLE,
# Response Examples
MODULE_EXAMPLE,
MODULE_ISSUE_EXAMPLE,
INVALID_REQUEST_RESPONSE,
PROJECT_NOT_FOUND_RESPONSE,
EXTERNAL_ID_EXISTS_RESPONSE,
MODULE_NOT_FOUND_RESPONSE,
DELETED_RESPONSE,
ADMIN_ONLY_RESPONSE,
REQUIRED_FIELDS_RESPONSE,
MODULE_ISSUE_NOT_FOUND_RESPONSE,
ARCHIVED_RESPONSE,
CANNOT_ARCHIVE_RESPONSE,
UNARCHIVED_RESPONSE,
)
class ModuleListCreateAPIEndpoint(BaseAPIView):
"""Module List and Create Endpoint"""
class ModuleAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module.
"""
serializer_class = ModuleSerializer
model = Module
webhook_event = "module"
permission_classes = [ProjectEntityPermission]
use_read_replica = True
serializer_class = ModuleSerializer
webhook_event = "module"
def get_queryset(self):
return (
@@ -170,33 +136,9 @@ class ModuleListCreateAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
)
@module_docs(
operation_id="create_module",
summary="Create module",
description="Create a new project module with specified name, description, and timeline.",
request=OpenApiRequest(
request=ModuleCreateSerializer,
examples=[MODULE_CREATE_EXAMPLE],
),
responses={
201: OpenApiResponse(
description="Module created",
response=ModuleSerializer,
examples=[MODULE_EXAMPLE],
),
400: INVALID_REQUEST_RESPONSE,
404: PROJECT_NOT_FOUND_RESPONSE,
409: EXTERNAL_ID_EXISTS_RESPONSE,
},
)
def post(self, request, slug, project_id):
"""Create module
Create a new project module with specified name, description, and timeline.
Automatically assigns the creator as module lead and tracks activity.
"""
project = Project.objects.get(pk=project_id, workspace__slug=slug)
serializer = ModuleCreateSerializer(
serializer = ModuleSerializer(
data=request.data,
context={"project_id": project_id, "workspace_id": project.workspace_id},
)
@@ -228,185 +170,19 @@ class ModuleListCreateAPIEndpoint(BaseAPIView):
# Send the model activity
model_activity.delay(
model_name="module",
model_id=str(serializer.instance.id),
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
module = Module.objects.get(pk=serializer.instance.id)
module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@module_docs(
operation_id="list_modules",
summary="List modules",
description="Retrieve all modules in a project.",
parameters=[
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: create_paginated_response(
ModuleSerializer,
"PaginatedModuleResponse",
"Paginated list of modules",
"Paginated Modules",
),
404: OpenApiResponse(description="Module not found"),
},
)
def get(self, request, slug, project_id):
"""List or retrieve modules
Retrieve all modules in a project or get details of a specific module.
Returns paginated results with module statistics and member information.
"""
return self.paginate(
request=request,
queryset=(self.get_queryset().filter(archived_at__isnull=True)),
on_results=lambda modules: ModuleSerializer(
modules, many=True, fields=self.fields, expand=self.expand
).data,
)
class ModuleDetailAPIEndpoint(BaseAPIView):
"""Module Detail Endpoint"""
model = Module
permission_classes = [ProjectEntityPermission]
serializer_class = ModuleSerializer
webhook_event = "module"
use_read_replica = True
def get_queryset(self):
return (
Module.objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related("module", "created_by"),
)
)
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
)
@module_docs(
operation_id="update_module",
summary="Update module",
description="Modify an existing module's properties like name, description, status, or timeline.",
parameters=[
MODULE_PK_PARAMETER,
],
request=OpenApiRequest(
request=ModuleUpdateSerializer,
examples=[MODULE_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Module updated successfully",
response=ModuleSerializer,
examples=[MODULE_EXAMPLE],
),
400: OpenApiResponse(
description="Invalid request data",
response=ModuleSerializer,
examples=[MODULE_UPDATE_EXAMPLE],
),
404: OpenApiResponse(description="Module not found"),
409: OpenApiResponse(
description="Module with same external ID already exists"
),
},
)
def patch(self, request, slug, project_id, pk):
"""Update module
Modify an existing module's properties like name, description, status, or timeline.
Tracks all changes in model activity logs for audit purposes.
"""
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
current_instance = json.dumps(
@@ -446,7 +222,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
# Send the model activity
model_activity.delay(
model_name="module",
model_id=str(serializer.instance.id),
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
@@ -457,50 +233,22 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@module_docs(
operation_id="retrieve_module",
summary="Retrieve module",
description="Retrieve details of a specific module.",
parameters=[
MODULE_PK_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Module",
response=ModuleSerializer,
examples=[MODULE_EXAMPLE],
),
404: OpenApiResponse(description="Module not found"),
},
)
def get(self, request, slug, project_id, pk):
"""Retrieve module
def get(self, request, slug, project_id, pk=None):
if pk:
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
data = ModuleSerializer(
queryset, fields=self.fields, expand=self.expand
).data
return Response(data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=(self.get_queryset().filter(archived_at__isnull=True)),
on_results=lambda modules: ModuleSerializer(
modules, many=True, fields=self.fields, expand=self.expand
).data,
)
Retrieve details of a specific module.
"""
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
data = ModuleSerializer(queryset, fields=self.fields, expand=self.expand).data
return Response(data, status=status.HTTP_200_OK)
@module_docs(
operation_id="delete_module",
summary="Delete module",
description="Permanently remove a module and all its associated issue relationships.",
parameters=[
MODULE_PK_PARAMETER,
],
responses={
204: DELETED_RESPONSE,
403: ADMIN_ONLY_RESPONSE,
404: MODULE_NOT_FOUND_RESPONSE,
},
)
def delete(self, request, slug, project_id, pk):
"""Delete module
Permanently remove a module and all its associated issue relationships.
Only admins or the module creator can perform this action.
"""
module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
if module.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
@@ -545,14 +293,19 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
"""Module Work Item List and Create Endpoint"""
class ModuleIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module issues.
"""
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
@@ -580,35 +333,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
.distinct()
)
@module_issue_docs(
operation_id="list_module_work_items",
summary="List module work items",
description="Retrieve all work items assigned to a module with detailed information.",
parameters=[
MODULE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
request={},
responses={
200: create_paginated_response(
IssueSerializer,
"PaginatedModuleIssueResponse",
"Paginated list of module work items",
"Paginated Module Work Items",
),
404: OpenApiResponse(description="Module not found"),
},
)
def get(self, request, slug, project_id, module_id):
"""List module work items
Retrieve all work items assigned to a module with detailed information.
Returns paginated results including assignees, labels, and attachments.
"""
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(
@@ -654,33 +379,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
).data,
)
@module_issue_docs(
operation_id="add_module_work_items",
summary="Add Work Items to Module",
description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.",
parameters=[
MODULE_ID_PARAMETER,
],
request=OpenApiRequest(
request=ModuleIssueRequestSerializer,
examples=[MODULE_ISSUE_REQUEST_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Module issues added",
response=ModuleIssueSerializer,
examples=[MODULE_ISSUE_EXAMPLE],
),
400: REQUIRED_FIELDS_RESPONSE,
404: MODULE_NOT_FOUND_RESPONSE,
},
)
def post(self, request, slug, project_id, module_id):
"""Add module work items
Assign multiple work items to a module or move them from another module.
Automatically handles bulk creation and updates with activity tracking.
"""
issues = request.data.get("issues", [])
if not len(issues):
return Response(
@@ -760,143 +459,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
class ModuleIssueDetailAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module work items.
"""
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
use_read_replica = True
permission_classes = [ProjectEntityPermission]
def get_queryset(self):
return (
ModuleIssue.objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(module_id=self.kwargs.get("module_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("project")
.select_related("workspace")
.select_related("module")
.select_related("issue", "issue__state", "issue__project")
.prefetch_related("issue__assignees", "issue__labels")
.prefetch_related("module__members")
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
@module_issue_docs(
operation_id="retrieve_module_work_item",
summary="Retrieve module work item",
description="Retrieve details of a specific module work item.",
parameters=[
MODULE_ID_PARAMETER,
ISSUE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: create_paginated_response(
IssueSerializer,
"PaginatedModuleIssueDetailResponse",
"Paginated list of module work item details",
"Module Work Item Details",
),
404: OpenApiResponse(description="Module not found"),
},
)
def get(self, request, slug, project_id, module_id, issue_id):
"""List module work items
Retrieve all work items assigned to a module with detailed information.
Returns paginated results including assignees, labels, and attachments.
"""
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(
issue_module__module_id=module_id,
issue_module__deleted_at__isnull=True,
pk=issue_id,
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(bridge_id=F("issue_module__id"))
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by(order_by)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
return self.paginate(
request=request,
queryset=(issues),
on_results=lambda issues: IssueSerializer(
issues, many=True, fields=self.fields, expand=self.expand
).data,
)
@module_issue_docs(
operation_id="delete_module_work_item",
summary="Delete module work item",
description="Remove a work item from a module while keeping the work item in the project.",
parameters=[
MODULE_ID_PARAMETER,
ISSUE_ID_PARAMETER,
],
responses={
204: DELETED_RESPONSE,
404: MODULE_ISSUE_NOT_FOUND_RESPONSE,
},
)
def delete(self, request, slug, project_id, module_id, issue_id):
"""Remove module work item
Remove a work item from a module while keeping the work item in the project.
Records the removal activity for tracking purposes.
"""
module_issue = ModuleIssue.objects.get(
workspace__slug=slug,
project_id=project_id,
@@ -920,7 +483,6 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
@@ -1011,34 +573,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
)
@module_docs(
operation_id="list_archived_modules",
summary="List archived modules",
description="Retrieve all modules that have been archived in the project.",
parameters=[
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
request={},
responses={
200: create_paginated_response(
ModuleSerializer,
"PaginatedArchivedModuleResponse",
"Paginated list of archived modules",
"Paginated Archived Modules",
),
404: OpenApiResponse(description="Project not found"),
},
)
def get(self, request, slug, project_id):
"""List archived modules
Retrieve all modules that have been archived in the project.
Returns paginated results with module statistics.
"""
def get(self, request, slug, project_id, pk):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
@@ -1047,26 +582,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).data,
)
@module_docs(
operation_id="archive_module",
summary="Archive module",
description="Move a module to archived status for historical tracking.",
parameters=[
MODULE_PK_PARAMETER,
],
request={},
responses={
204: ARCHIVED_RESPONSE,
400: CANNOT_ARCHIVE_RESPONSE,
404: MODULE_NOT_FOUND_RESPONSE,
},
)
def post(self, request, slug, project_id, pk):
"""Archive module
Move a completed module to archived status for historical tracking.
Only modules with completed status can be archived.
"""
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
if module.status not in ["completed", "cancelled"]:
return Response(
@@ -1083,24 +599,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@module_docs(
operation_id="unarchive_module",
summary="Unarchive module",
description="Restore an archived module to active status, making it available for regular use.",
parameters=[
MODULE_PK_PARAMETER,
],
responses={
204: UNARCHIVED_RESPONSE,
404: MODULE_NOT_FOUND_RESPONSE,
},
)
def delete(self, request, slug, project_id, pk):
"""Unarchive module
Restore an archived module to active status, making it available for regular use.
The module will reappear in active module lists and become fully functional.
"""
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
module.archived_at = None
module.save()
+46 -294
View File
@@ -11,8 +11,9 @@ from django.core.serializers.json import DjangoJSONEncoder
from rest_framework import status
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
from plane.api.serializers import ProjectSerializer
from plane.app.permissions import ProjectBasePermission
# Module imports
from plane.db.models import (
@@ -30,44 +31,16 @@ from plane.db.models import (
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.api.serializers import (
ProjectSerializer,
ProjectCreateSerializer,
ProjectUpdateSerializer,
)
from plane.app.permissions import ProjectBasePermission
from plane.utils.openapi import (
project_docs,
PROJECT_ID_PARAMETER,
PROJECT_PK_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
create_paginated_response,
# Request Examples
PROJECT_CREATE_EXAMPLE,
PROJECT_UPDATE_EXAMPLE,
# Response Examples
PROJECT_EXAMPLE,
PROJECT_NOT_FOUND_RESPONSE,
WORKSPACE_NOT_FOUND_RESPONSE,
PROJECT_NAME_TAKEN_RESPONSE,
DELETED_RESPONSE,
ARCHIVED_RESPONSE,
UNARCHIVED_RESPONSE,
)
class ProjectListCreateAPIEndpoint(BaseAPIView):
"""Project List and Create Endpoint"""
class ProjectAPIEndpoint(BaseAPIView):
"""Project Endpoints to create, update, list, retrieve and delete endpoint"""
serializer_class = ProjectSerializer
model = Project
webhook_event = "project"
permission_classes = [ProjectBasePermission]
use_read_replica = True
def get_queryset(self):
return (
@@ -131,87 +104,42 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
.distinct()
)
@project_docs(
operation_id="list_projects",
summary="List or retrieve projects",
description="Retrieve all projects in a workspace or get details of a specific project.",
parameters=[
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: create_paginated_response(
ProjectSerializer,
"PaginatedProjectResponse",
"Paginated list of projects",
"Paginated Projects",
),
404: PROJECT_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug):
"""List projects
Retrieve all projects in a workspace or get details of a specific project.
Returns projects ordered by user's custom sort order with member information.
"""
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
).values("sort_order")
projects = (
self.get_queryset()
.annotate(sort_order=Subquery(sort_order_query))
.prefetch_related(
Prefetch(
"project_projectmember",
queryset=ProjectMember.objects.filter(
workspace__slug=slug, is_active=True
).select_related("member"),
def get(self, request, slug, pk=None):
if pk is None:
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
).values("sort_order")
projects = (
self.get_queryset()
.annotate(sort_order=Subquery(sort_order_query))
.prefetch_related(
Prefetch(
"project_projectmember",
queryset=ProjectMember.objects.filter(
workspace__slug=slug, is_active=True
).select_related("member"),
)
)
.order_by(request.GET.get("order_by", "sort_order"))
)
.order_by(request.GET.get("order_by", "sort_order"))
)
return self.paginate(
request=request,
queryset=(projects),
on_results=lambda projects: ProjectSerializer(
projects, many=True, fields=self.fields, expand=self.expand
).data,
)
return self.paginate(
request=request,
queryset=(projects),
on_results=lambda projects: ProjectSerializer(
projects, many=True, fields=self.fields, expand=self.expand
).data,
)
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@project_docs(
operation_id="create_project",
summary="Create project",
description="Create a new project in the workspace with default states and member assignments.",
request=OpenApiRequest(
request=ProjectCreateSerializer,
examples=[PROJECT_CREATE_EXAMPLE],
),
responses={
201: OpenApiResponse(
description="Project created successfully",
response=ProjectSerializer,
examples=[PROJECT_EXAMPLE],
),
404: WORKSPACE_NOT_FOUND_RESPONSE,
409: PROJECT_NAME_TAKEN_RESPONSE,
},
)
def post(self, request, slug):
"""Create project
Create a new project in the workspace with default states and member assignments.
Automatically adds the creator as admin and sets up default workflow states.
"""
try:
workspace = Workspace.objects.get(slug=slug)
serializer = ProjectCreateSerializer(
serializer = ProjectSerializer(
data={**request.data}, context={"workspace_id": workspace.id}
)
if serializer.is_valid():
@@ -219,25 +147,25 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
# Add the user as Administrator to the project
_ = ProjectMember.objects.create(
project_id=serializer.instance.id, member=request.user, role=20
project_id=serializer.data["id"], member=request.user, role=20
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(
project_id=serializer.instance.id, user=request.user
project_id=serializer.data["id"], user=request.user
)
if serializer.instance.project_lead is not None and str(
serializer.instance.project_lead
if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
) != str(request.user.id):
ProjectMember.objects.create(
project_id=serializer.instance.id,
member_id=serializer.instance.project_lead,
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
role=20,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
project_id=serializer.instance.id,
user_id=serializer.instance.project_lead,
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)
# Default states
@@ -291,7 +219,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
]
)
project = self.get_queryset().filter(pk=serializer.instance.id).first()
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
# Model activity
model_activity.delay(
@@ -323,131 +251,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
class ProjectDetailAPIEndpoint(BaseAPIView):
"""Project Endpoints to update, retrieve and delete endpoint"""
serializer_class = ProjectSerializer
model = Project
webhook_event = "project"
permission_classes = [ProjectBasePermission]
use_read_replica = True
def get_queryset(self):
return (
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(
Q(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
| Q(network=2)
)
.select_related(
"workspace", "workspace__owner", "default_assignee", "project_lead"
)
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
member=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
)
)
)
.annotate(
total_members=ProjectMember.objects.filter(
project_id=OuterRef("id"), member__is_bot=False, is_active=True
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_modules=Module.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
member_role=ProjectMember.objects.filter(
project_id=OuterRef("pk"),
member_id=self.request.user.id,
is_active=True,
).values("role")
)
.annotate(
is_deployed=Exists(
DeployBoard.objects.filter(
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
@project_docs(
operation_id="retrieve_project",
summary="Retrieve project",
description="Retrieve details of a specific project.",
parameters=[
PROJECT_PK_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Project details",
response=ProjectSerializer,
examples=[PROJECT_EXAMPLE],
),
404: PROJECT_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug, pk):
"""Retrieve project
Retrieve details of a specific project.
"""
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@project_docs(
operation_id="update_project",
summary="Update project",
description="Partially update an existing project's properties like name, description, or settings.",
parameters=[
PROJECT_PK_PARAMETER,
],
request=OpenApiRequest(
request=ProjectUpdateSerializer,
examples=[PROJECT_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Project updated successfully",
response=ProjectSerializer,
examples=[PROJECT_EXAMPLE],
),
404: PROJECT_NOT_FOUND_RESPONSE,
409: PROJECT_NAME_TAKEN_RESPONSE,
},
)
def patch(self, request, slug, pk):
"""Update project
Partially update an existing project's properties like name, description, or settings.
Tracks changes in model activity logs for audit purposes.
"""
try:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
@@ -463,7 +267,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ProjectUpdateSerializer(
serializer = ProjectSerializer(
project,
data={**request.data, "intake_view": intake_view},
context={"workspace_id": workspace.id},
@@ -483,7 +287,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
is_default=True,
)
project = self.get_queryset().filter(pk=serializer.instance.id).first()
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
model_activity.delay(
model_name="project",
@@ -514,23 +318,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
@project_docs(
operation_id="delete_project",
summary="Delete project",
description="Permanently remove a project and all its associated data from the workspace.",
parameters=[
PROJECT_PK_PARAMETER,
],
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, pk):
"""Delete project
Permanently remove a project and all its associated data from the workspace.
Only admins can delete projects and the action cannot be undone.
"""
project = Project.objects.get(pk=pk, workspace__slug=slug)
# Delete the user favorite cycle
UserFavorite.objects.filter(
@@ -554,52 +342,16 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
"""Project Archive and Unarchive Endpoint"""
permission_classes = [ProjectBasePermission]
@project_docs(
operation_id="archive_project",
summary="Archive project",
description="Move a project to archived status, hiding it from active project lists.",
parameters=[
PROJECT_ID_PARAMETER,
],
request={},
responses={
204: ARCHIVED_RESPONSE,
},
)
def post(self, request, slug, project_id):
"""Archive project
Move a project to archived status, hiding it from active project lists.
Archived projects remain accessible but are excluded from regular workflows.
"""
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now()
project.save()
UserFavorite.objects.filter(workspace__slug=slug, project=project_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@project_docs(
operation_id="unarchive_project",
summary="Unarchive project",
description="Restore an archived project to active status, making it available in regular workflows.",
parameters=[
PROJECT_ID_PARAMETER,
],
request={},
responses={
204: UNARCHIVED_RESPONSE,
},
)
def delete(self, request, slug, project_id):
"""Unarchive project
Restore an archived project to active status, making it available in regular workflows.
The project will reappear in active project lists and become fully functional.
"""
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = None
project.save()
+12 -169
View File
@@ -4,41 +4,19 @@ from django.db import IntegrityError
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
# Module imports
from plane.api.serializers import StateSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Issue, State
# Module imports
from .base import BaseAPIView
from plane.utils.openapi import (
state_docs,
STATE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
create_paginated_response,
# Request Examples
STATE_CREATE_EXAMPLE,
STATE_UPDATE_EXAMPLE,
# Response Examples
STATE_EXAMPLE,
INVALID_REQUEST_RESPONSE,
STATE_NAME_EXISTS_RESPONSE,
DELETED_RESPONSE,
STATE_CANNOT_DELETE_RESPONSE,
EXTERNAL_ID_EXISTS_RESPONSE,
)
class StateListCreateAPIEndpoint(BaseAPIView):
"""State List and Create Endpoint"""
class StateAPIEndpoint(BaseAPIView):
serializer_class = StateSerializer
model = State
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
@@ -55,30 +33,7 @@ class StateListCreateAPIEndpoint(BaseAPIView):
.distinct()
)
@state_docs(
operation_id="create_state",
summary="Create state",
description="Create a new workflow state for a project with specified name, color, and group.",
request=OpenApiRequest(
request=StateSerializer,
examples=[STATE_CREATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="State created",
response=StateSerializer,
examples=[STATE_EXAMPLE],
),
400: INVALID_REQUEST_RESPONSE,
409: STATE_NAME_EXISTS_RESPONSE,
},
)
def post(self, request, slug, project_id):
"""Create state
Create a new workflow state for a project with specified name, color, and group.
Supports external ID tracking for integration purposes.
"""
try:
serializer = StateSerializer(
data=request.data, context={"project_id": project_id}
@@ -125,31 +80,14 @@ class StateListCreateAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
@state_docs(
operation_id="list_states",
summary="List states",
description="Retrieve all workflow states for a project.",
parameters=[
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: create_paginated_response(
StateSerializer,
"PaginatedStateResponse",
"Paginated list of states",
"Paginated States",
),
},
)
def get(self, request, slug, project_id):
"""List states
Retrieve all workflow states for a project.
Returns paginated results when listing all states.
"""
def get(self, request, slug, project_id, state_id=None):
if state_id:
serializer = StateSerializer(
self.get_queryset().get(pk=state_id),
fields=self.fields,
expand=self.expand,
)
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
@@ -158,76 +96,7 @@ class StateListCreateAPIEndpoint(BaseAPIView):
).data,
)
class StateDetailAPIEndpoint(BaseAPIView):
"""State Detail Endpoint"""
serializer_class = StateSerializer
model = State
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(is_triage=False)
.filter(project__archived_at__isnull=True)
.select_related("project")
.select_related("workspace")
.distinct()
)
@state_docs(
operation_id="retrieve_state",
summary="Retrieve state",
description="Retrieve details of a specific state.",
parameters=[
STATE_ID_PARAMETER,
],
responses={
200: OpenApiResponse(
description="State retrieved",
response=StateSerializer,
examples=[STATE_EXAMPLE],
),
},
)
def get(self, request, slug, project_id, state_id):
"""Retrieve state
Retrieve details of a specific state.
Returns paginated results when listing all states.
"""
serializer = StateSerializer(
self.get_queryset().get(pk=state_id),
fields=self.fields,
expand=self.expand,
)
return Response(serializer.data, status=status.HTTP_200_OK)
@state_docs(
operation_id="delete_state",
summary="Delete state",
description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.",
parameters=[
STATE_ID_PARAMETER,
],
responses={
204: DELETED_RESPONSE,
400: STATE_CANNOT_DELETE_RESPONSE,
},
)
def delete(self, request, slug, project_id, state_id):
"""Delete state
Permanently remove a workflow state from a project.
Default states and states with existing work items cannot be deleted.
"""
state = State.objects.get(
is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug
)
@@ -250,33 +119,7 @@ class StateDetailAPIEndpoint(BaseAPIView):
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@state_docs(
operation_id="update_state",
summary="Update state",
description="Partially update an existing workflow state's properties like name, color, or group.",
parameters=[
STATE_ID_PARAMETER,
],
request=OpenApiRequest(
request=StateSerializer,
examples=[STATE_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="State updated",
response=StateSerializer,
examples=[STATE_EXAMPLE],
),
400: INVALID_REQUEST_RESPONSE,
409: EXTERNAL_ID_EXISTS_RESPONSE,
},
)
def patch(self, request, slug, project_id, state_id):
"""Update state
Partially update an existing workflow state's properties like name, color, or group.
Validates external ID uniqueness if provided.
"""
def patch(self, request, slug, project_id, state_id=None):
state = State.objects.get(
workspace__slug=slug, project_id=project_id, pk=state_id
)
-37
View File
@@ -1,37 +0,0 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse
# Module imports
from plane.api.serializers import UserLiteSerializer
from plane.api.views.base import BaseAPIView
from plane.db.models import User
from plane.utils.openapi.decorators import user_docs
from plane.utils.openapi import USER_EXAMPLE
class UserEndpoint(BaseAPIView):
serializer_class = UserLiteSerializer
model = User
@user_docs(
operation_id="get_current_user",
summary="Get current user",
description="Retrieve the authenticated user's profile information including basic details.",
responses={
200: OpenApiResponse(
description="Current user profile",
response=UserLiteSerializer,
examples=[USER_EXAMPLE],
),
},
)
def get(self, request):
"""Get current user
Retrieve the authenticated user's profile information including basic details.
Returns user data based on the current authentication context.
"""
serializer = UserLiteSerializer(request.user)
return Response(serializer.data, status=status.HTTP_200_OK)
+2 -2
View File
@@ -75,12 +75,12 @@ class ProjectEntityPermission(BasePermission):
return False
# Handle requests based on project__identifier
if hasattr(view, "project_identifier") and view.project_identifier:
if hasattr(view, "project__identifier") and view.project__identifier:
if request.method in SAFE_METHODS:
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
project__identifier=view.project_identifier,
project__identifier=view.project__identifier,
is_active=True,
).exists()
@@ -96,7 +96,6 @@ from .page import (
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
PageBinaryUpdateSerializer,
PageVersionDetailSerializer,
)
+11 -91
View File
@@ -1,5 +1,3 @@
from lxml import html
# Django imports
from django.utils import timezone
@@ -18,15 +16,7 @@ from plane.db.models import (
DraftIssueLabel,
DraftIssueCycle,
DraftIssueModule,
ProjectMember,
EstimatePoint,
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
from plane.app.permissions import ROLE
class DraftIssueCreateSerializer(BaseSerializer):
@@ -67,84 +57,14 @@ class DraftIssueCreateSerializer(BaseSerializer):
data["label_ids"] = label_ids if label_ids else []
return data
def validate(self, attrs):
def validate(self, data):
if (
attrs.get("start_date", None) is not None
and attrs.get("target_date", None) is not None
and attrs.get("start_date", None) > attrs.get("target_date", None)
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg = validate_html_content(attrs["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
attrs["assignee_ids"] = ProjectMember.objects.filter(
project_id=self.context["project_id"],
role__gte=ROLE.MEMBER.value,
is_active=True,
member_id__in=attrs["assignee_ids"],
).values_list("member_id", flat=True)
# Validate labels are from project
if attrs.get("label_ids"):
label_ids = [label.id for label in attrs["label_ids"]]
attrs["label_ids"] = list(
Label.objects.filter(
project_id=self.context.get("project_id"), id__in=label_ids
).values_list("id", flat=True)
)
# # Check state is from the project only else raise validation error
if (
attrs.get("state")
and not State.objects.filter(
project_id=self.context.get("project_id"),
pk=attrs.get("state").id,
).exists()
):
raise serializers.ValidationError(
"State is not valid please pass a valid state_id"
)
# # Check parent issue is from workspace as it can be cross workspace
if (
attrs.get("parent")
and not Issue.objects.filter(
project_id=self.context.get("project_id"),
pk=attrs.get("parent").id,
).exists()
):
raise serializers.ValidationError(
"Parent is not valid issue_id please pass a valid issue_id"
)
if (
attrs.get("estimate_point")
and not EstimatePoint.objects.filter(
project_id=self.context.get("project_id"),
pk=attrs.get("estimate_point").id,
).exists()
):
raise serializers.ValidationError(
"Estimate point is not valid please pass a valid estimate_point_id"
)
return attrs
return data
def create(self, validated_data):
assignees = validated_data.pop("assignee_ids", None)
@@ -169,14 +89,14 @@ class DraftIssueCreateSerializer(BaseSerializer):
DraftIssueAssignee.objects.bulk_create(
[
DraftIssueAssignee(
assignee_id=assignee_id,
assignee=user,
draft_issue=issue,
workspace_id=workspace_id,
project_id=project_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
for user in assignees
],
batch_size=10,
)
@@ -185,14 +105,14 @@ class DraftIssueCreateSerializer(BaseSerializer):
DraftIssueLabel.objects.bulk_create(
[
DraftIssueLabel(
label_id=label_id,
label=label,
draft_issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
for label in labels
],
batch_size=10,
)
@@ -243,14 +163,14 @@ class DraftIssueCreateSerializer(BaseSerializer):
DraftIssueAssignee.objects.bulk_create(
[
DraftIssueAssignee(
assignee_id=assignee_id,
assignee=user,
draft_issue=instance,
workspace_id=workspace_id,
project_id=project_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
for user in assignees
],
batch_size=10,
)
+4 -74
View File
@@ -1,5 +1,3 @@
from lxml import html
# Django imports
from django.utils import timezone
from django.core.validators import URLValidator
@@ -39,12 +37,6 @@ from plane.db.models import (
IssueVersion,
IssueDescriptionVersion,
ProjectMember,
EstimatePoint,
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -127,23 +119,6 @@ class IssueCreateSerializer(BaseSerializer):
):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg = validate_html_content(attrs["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
attrs["assignee_ids"] = ProjectMember.objects.filter(
project_id=self.context["project_id"],
@@ -152,51 +127,6 @@ class IssueCreateSerializer(BaseSerializer):
member_id__in=attrs["assignee_ids"],
).values_list("member_id", flat=True)
# Validate labels are from project
if attrs.get("label_ids"):
label_ids = [label.id for label in attrs["label_ids"]]
attrs["label_ids"] = list(
Label.objects.filter(
project_id=self.context.get("project_id"),
id__in=label_ids,
).values_list("id", flat=True)
)
# Check state is from the project only else raise validation error
if (
attrs.get("state")
and not State.objects.filter(
project_id=self.context.get("project_id"),
pk=attrs.get("state").id,
).exists()
):
raise serializers.ValidationError(
"State is not valid please pass a valid state_id"
)
# Check parent issue is from workspace as it can be cross workspace
if (
attrs.get("parent")
and not Issue.objects.filter(
project_id=self.context.get("project_id"),
pk=attrs.get("parent").id,
).exists()
):
raise serializers.ValidationError(
"Parent is not valid issue_id please pass a valid issue_id"
)
if (
attrs.get("estimate_point")
and not EstimatePoint.objects.filter(
project_id=self.context.get("project_id"),
pk=attrs.get("estimate_point").id,
).exists()
):
raise serializers.ValidationError(
"Estimate point is not valid please pass a valid estimate_point_id"
)
return attrs
def create(self, validated_data):
@@ -260,14 +190,14 @@ class IssueCreateSerializer(BaseSerializer):
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
label=label,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
for label in labels
],
batch_size=10,
)
@@ -313,14 +243,14 @@ class IssueCreateSerializer(BaseSerializer):
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
label=label,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
for label in labels
],
batch_size=10,
ignore_conflicts=True,
-74
View File
@@ -1,14 +1,8 @@
# Third party imports
from rest_framework import serializers
import base64
# Module imports
from .base import BaseSerializer
from plane.utils.content_validator import (
validate_binary_data,
validate_html_content,
validate_json_content,
)
from plane.db.models import (
Page,
PageLog,
@@ -192,71 +186,3 @@ class PageVersionDetailSerializer(BaseSerializer):
"updated_by",
]
read_only_fields = ["workspace", "page"]
class PageBinaryUpdateSerializer(serializers.Serializer):
"""Serializer for updating page binary description with validation"""
description_binary = serializers.CharField(required=False, allow_blank=True)
description_html = serializers.CharField(required=False, allow_blank=True)
description = serializers.JSONField(required=False, allow_null=True)
def validate_description_binary(self, value):
"""Validate the base64-encoded binary data"""
if not value:
return value
try:
# Decode the base64 data
binary_data = base64.b64decode(value)
# Validate the binary data
is_valid, error_message = validate_binary_data(binary_data)
if not is_valid:
raise serializers.ValidationError(
f"Invalid binary data: {error_message}"
)
return binary_data
except Exception as e:
if isinstance(e, serializers.ValidationError):
raise
raise serializers.ValidationError("Failed to decode base64 data")
def validate_description_html(self, value):
"""Validate the HTML content"""
if not value:
return value
# Use the validation function from utils
is_valid, error_message = validate_html_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
return value
def validate_description(self, value):
"""Validate the JSON description"""
if not value:
return value
# Use the validation function from utils
is_valid, error_message = validate_json_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
return value
def update(self, instance, validated_data):
"""Update the page instance with validated data"""
if "description_binary" in validated_data:
instance.description_binary = validated_data.get("description_binary")
if "description_html" in validated_data:
instance.description_html = validated_data.get("description_html")
if "description" in validated_data:
instance.description = validated_data.get("description")
instance.save()
return instance
-31
View File
@@ -13,11 +13,6 @@ from plane.db.models import (
DeployBoard,
ProjectPublicMember,
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
class ProjectSerializer(BaseSerializer):
@@ -63,32 +58,6 @@ class ProjectSerializer(BaseSerializer):
return identifier
def validate(self, data):
# Validate description content for security
if "description" in data and data["description"]:
# For Project, description might be text field, not JSON
if isinstance(data["description"], dict):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_text" in data and data["description_text"]:
is_valid, error_msg = validate_json_content(data["description_text"])
if not is_valid:
raise serializers.ValidationError({"description_text": error_msg})
if "description_html" in data and data["description_html"]:
if isinstance(data["description_html"], dict):
is_valid, error_msg = validate_json_content(data["description_html"])
else:
is_valid, error_msg = validate_html_content(
str(data["description_html"])
)
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
return data
def create(self, validated_data):
workspace_id = self.context["workspace_id"]
@@ -24,11 +24,6 @@ from plane.db.models import (
)
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.url import contains_url
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
# Django imports
from django.core.validators import URLValidator
@@ -317,25 +312,6 @@ class StickySerializer(BaseSerializer):
read_only_fields = ["workspace", "owner"]
extra_kwargs = {"name": {"required": False}}
def validate(self, data):
# Validate description content for security
if "description" in data and data["description"]:
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_html" in data and data["description_html"]:
is_valid, error_msg = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
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": error_msg})
return data
class WorkspaceUserPreferenceSerializer(BaseSerializer):
class Meta:
@@ -16,6 +16,8 @@ from plane.db.models import (
IssueView,
ProjectPage,
Workspace,
CycleIssue,
ModuleIssue,
ProjectMember,
)
from plane.utils.build_chart import build_analytics_chart
+2 -7
View File
@@ -24,7 +24,6 @@ from rest_framework.viewsets import ModelViewSet
from plane.authentication.session import BaseSessionAuthentication
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
from plane.utils.core.mixins import ReadReplicaControlMixin
class TimezoneMixin:
@@ -41,7 +40,7 @@ class TimezoneMixin:
timezone.deactivate()
class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePaginator):
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
model = None
permission_classes = [IsAuthenticated]
@@ -54,8 +53,6 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi
search_fields = []
use_read_replica = False
def get_queryset(self):
try:
return self.model.objects.all()
@@ -152,7 +149,7 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi
return expand if expand else None
class BaseAPIView(TimezoneMixin, ReadReplicaControlMixin, APIView, BasePaginator):
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
permission_classes = [IsAuthenticated]
filter_backends = (DjangoFilterBackend, SearchFilter)
@@ -163,8 +160,6 @@ class BaseAPIView(TimezoneMixin, ReadReplicaControlMixin, APIView, BasePaginator
search_fields = []
use_read_replica = False
def filter_queryset(self, queryset):
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self)
@@ -19,7 +19,6 @@ from plane.db.models import IssueActivity, IssueComment, CommentReaction, Intake
class IssueActivityEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission]
use_read_replica = True
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@@ -232,8 +232,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
class UnreadNotificationEndpoint(BaseAPIView):
use_read_replica = True
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
+16 -12
View File
@@ -25,7 +25,6 @@ from plane.app.serializers import (
PageSerializer,
SubPageSerializer,
PageDetailSerializer,
PageBinaryUpdateSerializer,
)
from plane.db.models import (
Page,
@@ -539,27 +538,32 @@ class PagesDescriptionViewSet(BaseViewSet):
{"description_html": page.description_html}, cls=DjangoJSONEncoder
)
# Use serializer for validation and update
serializer = PageBinaryUpdateSerializer(page, data=request.data, partial=True)
if serializer.is_valid():
# Capture the page transaction
# Get the base64 data from the request
base64_data = request.data.get("description_binary")
# If base64 data is provided
if base64_data:
# Decode the base64 data to bytes
new_binary_data = base64.b64decode(base64_data)
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
# Update the page using serializer
updated_page = serializer.save()
# Run background tasks
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.description = request.data.get("description")
page.save()
# Return a success response
page_version.delay(
page_id=updated_page.id,
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)
return Response({"message": "Updated successfully"})
else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response({"error": "No binary data provided"})
class PageDuplicateEndpoint(BaseAPIView):
-1
View File
@@ -46,7 +46,6 @@ class ProjectViewSet(BaseViewSet):
serializer_class = ProjectListSerializer
model = Project
webhook_event = "project"
use_read_replica = True
def get_queryset(self):
sort_order = ProjectMember.objects.filter(
@@ -312,7 +312,6 @@ class ProjectMemberUserEndpoint(BaseAPIView):
class UserProjectRolesEndpoint(BaseAPIView):
permission_classes = [WorkspaceUserPermission]
use_read_replica = True
def get(self, request, slug):
project_members = ProjectMember.objects.filter(
-1
View File
@@ -44,7 +44,6 @@ from django.views.decorators.vary import vary_on_cookie
class UserEndpoint(BaseViewSet):
serializer_class = UserSerializer
model = User
use_read_replica = True
def get_object(self):
return self.request.user
@@ -177,7 +177,6 @@ class WorkSpaceViewSet(BaseViewSet):
class UserWorkSpacesEndpoint(BaseAPIView):
search_fields = ["name"]
filterset_fields = ["owner"]
use_read_replica = True
def get(self, request):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
@@ -10,6 +10,7 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import Cycle
from plane.app.permissions import WorkspaceViewerPermission
from plane.app.serializers.cycle import CycleSerializer
from plane.utils.timezone_converter import user_timezone_converter
class WorkspaceCyclesEndpoint(BaseAPIView):
@@ -12,7 +12,6 @@ from plane.utils.cache import cache_response
class WorkspaceEstimatesEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
use_read_replica = True
@cache_response(60 * 60 * 2)
def get(self, request, slug):
@@ -14,8 +14,6 @@ from plane.app.permissions import allow_permission, ROLE
class WorkspaceFavoriteEndpoint(BaseAPIView):
use_read_replica = True
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug):
# the second filter is to check if the user is a member of the project
@@ -12,7 +12,6 @@ from plane.utils.cache import cache_response
class WorkspaceLabelsEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
use_read_replica = True
@cache_response(60 * 60 * 2)
def get(self, request, slug):
+1 -4
View File
@@ -28,13 +28,12 @@ class WorkSpaceMemberViewSet(BaseViewSet):
model = WorkspaceMember
search_fields = ["member__display_name", "member__first_name"]
use_read_replica = True
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(workspace__slug=self.kwargs.get("slug"), is_active=True)
.select_related("member", "member__avatar_asset")
)
@@ -215,8 +214,6 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
class WorkspaceMemberUserEndpoint(BaseAPIView):
use_read_replica = True
def get(self, request, slug):
draft_issue_count = (
DraftIssue.objects.filter(
@@ -11,7 +11,6 @@ from plane.app.permissions import allow_permission, ROLE
class QuickLinkViewSet(BaseViewSet):
model = WorkspaceUserLink
use_read_replica = True
def get_serializer_class(self):
return WorkspaceUserLinkSerializer
@@ -12,7 +12,6 @@ from plane.app.permissions import allow_permission, ROLE
class UserRecentVisitViewSet(BaseViewSet):
model = UserRecentVisit
use_read_replica = True
def get_serializer_class(self):
return WorkspaceRecentVisitSerializer
@@ -13,7 +13,6 @@ from collections import defaultdict
class WorkspaceStatesEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
use_read_replica = True
@cache_response(60 * 60 * 2)
def get(self, request, slug):
@@ -12,7 +12,6 @@ from plane.app.serializers import StickySerializer
class WorkspaceStickyViewSet(BaseViewSet):
serializer_class = StickySerializer
model = Sticky
use_read_replica = True
def get_queryset(self):
return self.filter_queryset(
@@ -13,7 +13,6 @@ from rest_framework import status
class WorkspaceUserPreferenceViewSet(BaseAPIView):
model = WorkspaceUserPreference
use_read_replica = True
def get_serializer_class(self):
return WorkspaceUserPreferenceSerializer
@@ -18,7 +18,7 @@ from plane.authentication.adapter.error import (
class GitHubOAuthProvider(OauthAdapter):
token_url = "https://github.com/login/oauth/access_token"
userinfo_url = "https://api.github.com/user"
org_membership_url = "https://api.github.com/orgs"
org_membership_url = f"https://api.github.com/orgs"
provider = "github"
scope = "read:user user:email"
@@ -30,6 +30,7 @@ from plane.db.models import (
)
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
from plane.bgtasks.webhook_task import webhook_activity
from plane.utils.issue_relation_mapper import get_inverse_relation
from plane.utils.uuid import is_valid_uuid
+1 -30
View File
@@ -12,7 +12,6 @@ from celery import shared_task
# Django imports
from django.conf import settings
from django.db.models import Prefetch
from django.core.mail import EmailMultiAlternatives, get_connection
from django.core.serializers.json import DjangoJSONEncoder
from django.template.loader import render_to_string
@@ -43,8 +42,6 @@ from plane.db.models import (
Webhook,
WebhookLog,
IntakeIssue,
IssueLabel,
IssueAssignee,
)
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@@ -77,15 +74,6 @@ MODEL_MAPPER = {
logger = logging.getLogger("plane.worker")
def get_issue_prefetches():
return [
Prefetch("label_issue", queryset=IssueLabel.objects.select_related("label")),
Prefetch(
"issue_assignee", queryset=IssueAssignee.objects.select_related("assignee")
),
]
def get_model_data(
event: str, event_id: Union[str, List[str]], many: bool = False
) -> Dict[str, Any]:
@@ -115,27 +103,10 @@ def get_model_data(
queryset = model.objects.get(pk=event_id)
serializer = SERIALIZER_MAPPER.get(event)
if serializer is None:
raise ValueError(f"Serializer not found for event: {event}")
issue_prefetches = get_issue_prefetches()
if event == "issue":
if many:
queryset = queryset.prefetch_related(*issue_prefetches)
else:
issue_id = queryset.id
queryset = (
model.objects.filter(pk=issue_id)
.prefetch_related(*issue_prefetches)
.first()
)
return serializer(
queryset, many=many, context={"expand": ["labels", "assignees"]}
).data
else:
return serializer(queryset, many=many).data
return serializer(queryset, many=many).data
except ObjectDoesNotExist:
raise ObjectDoesNotExist(f"No {event} found with id: {event_id}")
@@ -1,3 +1,4 @@
import time
from django.core.management.base import BaseCommand
from django.db import transaction
from plane.db.models import Workspace
@@ -1,34 +0,0 @@
# Generated by Django 4.2.22 on 2025-07-27 16:01
from django.db import migrations, models
import plane.utils.color
class Migration(migrations.Migration):
dependencies = [
("db", "0098_profile_is_app_rail_docked_and_more"),
]
operations = [
migrations.AddField(
model_name="profile",
name="background_color",
field=models.CharField(
default=plane.utils.color.get_random_color, max_length=255
),
),
migrations.AddField(
model_name="profile",
name="goals",
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name="workspace",
name="background_color",
field=models.CharField(
default=plane.utils.color.get_random_color, max_length=255
),
),
]
+1 -1
View File
@@ -71,7 +71,7 @@ class Cycle(ProjectBaseModel):
archived_at = models.DateTimeField(null=True)
logo_props = models.JSONField(default=dict)
# timezone
TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
version = models.IntegerField(default=1)
-8
View File
@@ -35,14 +35,6 @@ class SourceType(models.TextChoices):
IN_APP = "IN_APP"
class IntakeIssueStatus(models.IntegerChoices):
PENDING = -2
REJECTED = -1
SNOOZED = 0
ACCEPTED = 1
DUPLICATE = 2
class IntakeIssue(ProjectBaseModel):
intake = models.ForeignKey(
"db.Intake", related_name="issue_intake", on_delete=models.CASCADE
-9
View File
@@ -51,15 +51,6 @@ def get_default_display_properties():
}
class ModuleStatus(models.TextChoices):
BACKLOG = "backlog"
PLANNED = "planned"
IN_PROGRESS = "in-progress"
PAUSED = "paused"
COMPLETED = "completed"
CANCELLED = "cancelled"
class Module(ProjectBaseModel):
name = models.CharField(max_length=255, verbose_name="Module Name")
description = models.TextField(verbose_name="Module Description", blank=True)
+1 -1
View File
@@ -120,7 +120,7 @@ class Project(BaseModel):
)
archived_at = models.DateTimeField(null=True)
# timezone
TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
# external_id for imports
external_source = models.CharField(max_length=255, null=True, blank=True)
+1 -4
View File
@@ -15,7 +15,6 @@ from django.utils import timezone
# Module imports
from plane.db.models import FileAsset
from ..mixins import TimeAuditModel
from plane.utils.color import get_random_color
def get_default_onboarding():
@@ -102,7 +101,7 @@ class User(AbstractBaseUser, PermissionsMixin):
)
# timezone
USER_TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
user_timezone = models.CharField(
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
)
@@ -223,8 +222,6 @@ class Profile(TimeAuditModel):
start_of_the_week = models.PositiveSmallIntegerField(
choices=START_OF_THE_WEEK_CHOICES, default=SUNDAY
)
goals = models.JSONField(default=dict)
background_color = models.CharField(max_length=255, default=get_random_color)
class Meta:
verbose_name = "Profile"
+5 -4
View File
@@ -1,6 +1,9 @@
# Python imports
from django.db.models.functions import Ln
import pytz
from typing import Optional, Any
import time
from django.utils import timezone
from typing import Optional, Any, Tuple, Dict
# Django imports
from django.conf import settings
@@ -10,7 +13,6 @@ from django.db import models
# Module imports
from .base import BaseModel
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.color import get_random_color
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
@@ -113,7 +115,7 @@ def slug_validator(value):
class Workspace(BaseModel):
TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.TextField(verbose_name="Logo", blank=True, null=True)
@@ -134,7 +136,6 @@ class Workspace(BaseModel):
)
organization_size = models.CharField(max_length=20, blank=True, null=True)
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
background_color = models.CharField(max_length=255, default=get_random_color)
def __str__(self):
"""Return name of the Workspace"""
-162
View File
@@ -1,162 +0,0 @@
"""
Database routing middleware for read replica selection.
This middleware determines whether database queries should be routed to
read replicas or the primary database based on HTTP method and view configuration.
"""
import logging
from typing import Callable, Optional
from django.http import HttpRequest, HttpResponse
from plane.utils.core import (
set_use_read_replica,
clear_read_replica_context,
)
logger = logging.getLogger("plane.api")
class ReadReplicaRoutingMiddleware:
"""
Middleware for intelligent database routing to read replicas.
Routing Logic:
• Non-GET requests (POST, PUT, DELETE, PATCH) ➜ Primary database
• GET requests:
- View has use_read_replica=False ➜ Primary database
- View has use_read_replica=True ➜ Read replica
- View has no use_read_replica attribute ➜ Primary database (safe default)
The middleware supports both Django CBVs and DRF APIViews/ViewSets.
Context is properly isolated per request to prevent data leakage.
"""
# HTTP methods that are considered read-only by default
READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
def __init__(self, get_response):
"""
Initialize the middleware with the next middleware/view in the chain.
Args:
get_response: The next middleware or view function
"""
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
"""
Process the request and determine database routing.
Args:
request: The HTTP request object
Returns:
HttpResponse: The HTTP response from the view
"""
# For non-read operations, set primary database immediately
if request.method not in self.READ_ONLY_METHODS:
set_use_read_replica(False)
logger.debug(f"Routing {request.method} {request.path} to primary database")
try:
# Process the request through the middleware chain
response = self.get_response(request)
return response
finally:
# Always clean up context, even if an exception occurs
# This prevents context leakage between requests
clear_read_replica_context()
def process_view(
self,
request: HttpRequest,
view_func: Callable,
view_args: tuple,
view_kwargs: dict,
) -> None:
"""
Hook called just before Django calls the view.
This is more efficient than resolving URLs in __call__ since Django
provides the view function directly.
Args:
request: The HTTP request object
view_func: The view function to be called
view_args: Positional arguments for the view
view_kwargs: Keyword arguments for the view
"""
# Only process read operations (write operations already handled in __call__)
if request.method in self.READ_ONLY_METHODS:
use_replica = self._should_use_read_replica(view_func)
set_use_read_replica(use_replica)
db_type = "read replica" if use_replica else "primary database"
logger.debug(f"Routing {request.method} {request.path} to {db_type}")
# Return None to continue normal request processing
return None
def _should_use_read_replica(self, view_func: Callable) -> bool:
"""
Determine if the view should use read replica based on its configuration.
Args:
view_func: The view function to inspect
Returns:
bool: True if should use read replica, False for primary database
"""
use_replica_attr = self._get_use_replica_attribute(view_func)
# Default to primary database for GET requests if no explicit setting
# This ensures only views that explicitly opt-in use read replicas
if use_replica_attr is None:
return False
return bool(use_replica_attr)
def _get_use_replica_attribute(self, view_func: Callable) -> Optional[bool]:
"""
Extract the use_read_replica attribute from various view types.
Args:
view_func: The view function to inspect
Returns:
Optional[bool]: The use_read_replica setting, or None if not found
"""
# Return None if view_func is None to prevent AttributeError
if view_func is None:
return None
# Check function-based view attribute
use_replica = getattr(view_func, "use_read_replica", None)
if use_replica is not None:
return use_replica
# Check Django CBV wrapper
if hasattr(view_func, "view_class"):
use_replica = getattr(view_func.view_class, "use_read_replica", None)
if use_replica is not None:
return use_replica
# Check DRF wrapper (APIView / ViewSet)
if hasattr(view_func, "cls"):
use_replica = getattr(view_func.cls, "use_read_replica", None)
if use_replica is not None:
return use_replica
return None
def process_exception(self, request: HttpRequest, exception: Exception) -> None:
"""
Handle exceptions that occur during view processing.
This provides an additional safety net for context cleanup when views
raise exceptions, complementing the try/finally in __call__.
Args:
request: The HTTP request object
exception: The exception that was raised
Returns:
None: Don't handle the exception, just clean up context
"""
# Clean up context on exception as a safety measure
# The try/finally in __call__ should handle most cases, but this
# provides extra protection specifically for view exceptions
clear_read_replica_context()
logger.debug(
f"Cleaned up read replica context due to exception: {type(exception).__name__}"
)
# Return None to let the exception continue propagating
return None
-32
View File
@@ -75,8 +75,6 @@ REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
"EXCEPTION_HANDLER": "plane.authentication.adapter.exception.auth_exception_handler",
# Preserve original Django URL parameter names (pk) instead of converting to 'id'
"SCHEMA_COERCE_PATH_PK": False,
}
# Django Auth Backend
@@ -149,29 +147,6 @@ else:
}
}
if os.environ.get("ENABLE_READ_REPLICA", "0") == "1":
if bool(os.environ.get("DATABASE_READ_REPLICA_URL")):
# Parse database configuration from $DATABASE_URL
DATABASES["replica"] = dj_database_url.parse(
os.environ.get("DATABASE_READ_REPLICA_URL")
)
else:
DATABASES["replica"] = {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_READ_REPLICA_DB"),
"USER": os.environ.get("POSTGRES_READ_REPLICA_USER"),
"PASSWORD": os.environ.get("POSTGRES_READ_REPLICA_PASSWORD"),
"HOST": os.environ.get("POSTGRES_READ_REPLICA_HOST"),
"PORT": os.environ.get("POSTGRES_READ_REPLICA_PORT", "5432"),
}
# Database Routers
DATABASE_ROUTERS = ["plane.utils.core.dbrouters.ReadReplicaRouter"]
# Add middleware at the end for read replica routing
MIDDLEWARE.append("plane.middleware.db_routing.ReadReplicaRoutingMiddleware")
# Redis Config
REDIS_URL = os.environ.get("REDIS_URL")
REDIS_SSL = REDIS_URL and "rediss" in REDIS_URL
@@ -464,10 +439,3 @@ ATTACHMENT_MIME_TYPES = [
# Seed directory path
SEED_DIR = os.path.join(BASE_DIR, "seeds")
ENABLE_DRF_SPECTACULAR = os.environ.get("ENABLE_DRF_SPECTACULAR", "0") == "1"
if ENABLE_DRF_SPECTACULAR:
REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema"
INSTALLED_APPS.append("drf_spectacular")
from .openapi import SPECTACULAR_SETTINGS # noqa: F401
-272
View File
@@ -1,272 +0,0 @@
"""
OpenAPI/Swagger configuration for drf-spectacular.
This file contains the complete configuration for API documentation generation.
"""
SPECTACULAR_SETTINGS = {
# ========================================================================
# Basic API Information
# ========================================================================
"TITLE": "The Plane REST API",
"DESCRIPTION": (
"The Plane REST API\n\n"
"Visit our quick start guide and full API documentation at "
"[developers.plane.so](https://developers.plane.so/api-reference/introduction)."
),
"CONTACT": {
"name": "Plane",
"url": "https://plane.so",
"email": "support@plane.so",
},
"VERSION": "0.0.1",
"LICENSE": {
"name": "GNU AGPLv3",
"url": "https://github.com/makeplane/plane/blob/preview/LICENSE.txt",
},
# ========================================================================
# Schema Generation Settings
# ========================================================================
"SERVE_INCLUDE_SCHEMA": False,
"SCHEMA_PATH_PREFIX": "/api/v1/",
"SCHEMA_CACHE_TIMEOUT": 0, # disables caching
# ========================================================================
# Processing Hooks
# ========================================================================
"PREPROCESSING_HOOKS": [
"plane.utils.openapi.hooks.preprocess_filter_api_v1_paths",
],
# ========================================================================
# Server Configuration
# ========================================================================
"SERVERS": [
{"url": "http://localhost:8000", "description": "Local"},
{"url": "https://api.plane.so", "description": "Production"},
],
# ========================================================================
# API Tag Definitions
# ========================================================================
"TAGS": [
# System Features
{
"name": "Assets",
"description": (
"**File Upload & Presigned URLs**\n\n"
"Generate presigned URLs for direct file uploads to cloud storage. Handle user avatars, "
"cover images, and generic project assets with secure upload workflows.\n\n"
"*Key Features:*\n"
"- Generate presigned URLs for S3 uploads\n"
"- Support for user avatars and cover images\n"
"- Generic asset upload for projects\n"
"- File validation and size limits\n\n"
"*Use Cases:* User profile images, project file uploads, secure direct-to-cloud uploads."
),
},
# Project Organization
{
"name": "Cycles",
"description": (
"**Sprint & Development Cycles**\n\n"
"Create and manage development cycles (sprints) to organize work into time-boxed iterations. "
"Track progress, assign work items, and monitor team velocity.\n\n"
"*Key Features:*\n"
"- Create and configure development cycles\n"
"- Assign work items to cycles\n"
"- Track cycle progress and completion\n"
"- Generate cycle analytics and reports\n\n"
"*Use Cases:* Sprint planning, iterative development, progress tracking, team velocity."
),
},
# System Features
{
"name": "Intake",
"description": (
"**Work Item Intake Queue**\n\n"
"Manage incoming work items through a dedicated intake queue for triage and review. "
"Submit, update, and process work items before they enter the main project workflow.\n\n"
"*Key Features:*\n"
"- Submit work items to intake queue\n"
"- Review and triage incoming work items\n"
"- Update intake work item status and properties\n"
"- Accept, reject, or modify work items before approval\n\n"
"*Use Cases:* Work item triage, external submissions, quality review, approval workflows."
),
},
# Project Organization
{
"name": "Labels",
"description": (
"**Labels & Tags**\n\n"
"Create and manage labels to categorize and organize work items. Use color-coded labels "
"for easy identification, filtering, and project organization.\n\n"
"*Key Features:*\n"
"- Create custom labels with colors and descriptions\n"
"- Apply labels to work items for categorization\n"
"- Filter and search by labels\n"
"- Organize labels across projects\n\n"
"*Use Cases:* Priority marking, feature categorization, bug classification, team organization."
),
},
# Team & User Management
{
"name": "Members",
"description": (
"**Team Member Management**\n\n"
"Manage team members, roles, and permissions within projects and workspaces. "
"Control access levels and track member participation.\n\n"
"*Key Features:*\n"
"- Invite and manage team members\n"
"- Assign roles and permissions\n"
"- Control project and workspace access\n"
"- Track member activity and participation\n\n"
"*Use Cases:* Team setup, access control, role management, collaboration."
),
},
# Project Organization
{
"name": "Modules",
"description": (
"**Feature Modules**\n\n"
"Group related work items into modules for better organization and tracking. "
"Plan features, track progress, and manage deliverables at a higher level.\n\n"
"*Key Features:*\n"
"- Create and organize feature modules\n"
"- Group work items by module\n"
"- Track module progress and completion\n"
"- Manage module leads and assignments\n\n"
"*Use Cases:* Feature planning, release organization, progress tracking, team coordination."
),
},
# Core Project Management
{
"name": "Projects",
"description": (
"**Project Management**\n\n"
"Create and manage projects to organize your development work. Configure project settings, "
"manage team access, and control project visibility.\n\n"
"*Key Features:*\n"
"- Create, update, and delete projects\n"
"- Configure project settings and preferences\n"
"- Manage team access and permissions\n"
"- Control project visibility and sharing\n\n"
"*Use Cases:* Project setup, team collaboration, access control, project configuration."
),
},
# Project Organization
{
"name": "States",
"description": (
"**Workflow States**\n\n"
"Define custom workflow states for work items to match your team's process. "
"Configure state transitions and track work item progress through different stages.\n\n"
"*Key Features:*\n"
"- Create custom workflow states\n"
"- Configure state transitions and rules\n"
"- Track work item progress through states\n"
"- Set state-based permissions and automation\n\n"
"*Use Cases:* Custom workflows, status tracking, process automation, progress monitoring."
),
},
# Team & User Management
{
"name": "Users",
"description": (
"**Current User Information**\n\n"
"Get information about the currently authenticated user including profile details "
"and account settings.\n\n"
"*Key Features:*\n"
"- Retrieve current user profile\n"
"- Access user account information\n"
"- View user preferences and settings\n"
"- Get authentication context\n\n"
"*Use Cases:* Profile display, user context, account information, authentication status."
),
},
# Work Item Management
{
"name": "Work Item Activity",
"description": (
"**Activity History & Search**\n\n"
"View activity history and search for work items across the workspace. "
"Get detailed activity logs and find work items using text search.\n\n"
"*Key Features:*\n"
"- View work item activity history\n"
"- Search work items across workspace\n"
"- Track changes and modifications\n"
"- Filter search results by project\n\n"
"*Use Cases:* Activity tracking, work item discovery, change history, workspace search."
),
},
{
"name": "Work Item Attachments",
"description": (
"**Work Item File Attachments**\n\n"
"Generate presigned URLs for uploading files directly to specific work items. "
"Upload and manage attachments associated with work items.\n\n"
"*Key Features:*\n"
"- Generate presigned URLs for work item attachments\n"
"- Upload files directly to work items\n"
"- Retrieve and manage attachment metadata\n"
"- Delete attachments from work items\n\n"
"*Use Cases:* Screenshots, error logs, design files, supporting documents."
),
},
{
"name": "Work Item Comments",
"description": (
"**Comments & Discussions**\n\n"
"Add comments and discussions to work items for team collaboration. "
"Support threaded conversations, mentions, and rich text formatting.\n\n"
"*Key Features:*\n"
"- Add comments to work items\n"
"- Thread conversations and replies\n"
"- Mention users and trigger notifications\n"
"- Rich text and markdown support\n\n"
"*Use Cases:* Team discussions, progress updates, code reviews, decision tracking."
),
},
{
"name": "Work Item Links",
"description": (
"**External Links & References**\n\n"
"Link work items to external resources like documentation, repositories, or design files. "
"Maintain connections between work items and external systems.\n\n"
"*Key Features:*\n"
"- Add external URL links to work items\n"
"- Validate and preview linked resources\n"
"- Organize links by type and category\n"
"- Track link usage and access\n\n"
"*Use Cases:* Documentation links, repository connections, design references, external tools."
),
},
{
"name": "Work Items",
"description": (
"**Work Items & Tasks**\n\n"
"Create and manage work items like tasks, bugs, features, and user stories. "
"The core entities for tracking work in your projects.\n\n"
"*Key Features:*\n"
"- Create, update, and manage work items\n"
"- Assign to team members and set priorities\n"
"- Track progress through workflow states\n"
"- Set due dates, estimates, and relationships\n\n"
"*Use Cases:* Bug tracking, task management, feature development, sprint planning."
),
},
],
# ========================================================================
# Security & Authentication
# ========================================================================
"AUTHENTICATION_WHITELIST": [
"plane.api.middleware.api_authentication.APIKeyAuthentication",
],
# ========================================================================
# Schema Generation Options
# ========================================================================
"COMPONENT_NO_READ_ONLY_REQUIRED": True,
"COMPONENT_SPLIT_REQUEST": True,
"ENUM_NAME_OVERRIDES": {
"ModuleStatusEnum": "plane.db.models.module.ModuleStatus",
"IntakeWorkItemStatusEnum": "plane.db.models.intake.IntakeIssueStatus",
},
}
+2 -2
View File
@@ -1,5 +1,5 @@
from .user import UserLiteSerializer
from .issue import LabelLiteSerializer, IssuePublicSerializer
from .issue import LabelLiteSerializer, StateLiteSerializer, IssuePublicSerializer
from .state import StateSerializer
from .state import StateSerializer, StateLiteSerializer
-22
View File
@@ -28,11 +28,6 @@ from plane.db.models import (
IssueVote,
IssueRelation,
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
class IssueStateFlatSerializer(BaseSerializer):
@@ -288,23 +283,6 @@ class IssueCreateSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description" in data and data["description"]:
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_html" in data and data["description_html"]:
is_valid, error_msg = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
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": error_msg})
return data
def create(self, validated_data):
+3 -1
View File
@@ -1,13 +1,15 @@
import pytest
from django.conf import settings
from rest_framework.test import APIClient
from pytest_django.fixtures import django_db_setup
from unittest.mock import patch, MagicMock
from plane.db.models import User, Workspace, WorkspaceMember
from plane.db.models.api import APIToken
@pytest.fixture(scope="session")
def django_db_setup(django_db_setup): # noqa: F811
def django_db_setup(django_db_setup):
"""Set up the Django database for the test session"""
pass
@@ -1,5 +1,6 @@
import pytest
from unittest.mock import MagicMock, patch
from django.conf import settings
@pytest.fixture
@@ -6,7 +6,7 @@ from django.utils import timezone
from rest_framework import status
from django.test import Client
from django.core.exceptions import ValidationError
from unittest.mock import patch
from unittest.mock import patch, MagicMock
from plane.db.models import User
from plane.settings.redis import redis_instance
@@ -181,7 +181,7 @@ class TestProjectAPIPost(TestProjectBase):
response = session_client.post(url, project_data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.status_code == status.HTTP_409_CONFLICT
@pytest.mark.django_db
def test_create_project_duplicate_identifier(
@@ -200,7 +200,7 @@ class TestProjectAPIPost(TestProjectBase):
response = session_client.post(url, project_data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.status_code == status.HTTP_409_CONFLICT
@pytest.mark.django_db
def test_create_project_missing_required_fields(
@@ -493,7 +493,7 @@ class TestProjectAPIPatchDelete(TestProjectBase):
response = session_client.patch(url, update_data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.status_code == status.HTTP_409_CONFLICT
@pytest.mark.django_db
def test_partial_update_duplicate_identifier_conflict(
@@ -515,7 +515,7 @@ class TestProjectAPIPatchDelete(TestProjectBase):
response = session_client.patch(url, update_data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.status_code == status.HTTP_409_CONFLICT
@pytest.mark.django_db
def test_partial_update_invalid_data(self, session_client, workspace, create_user):
@@ -1,433 +0,0 @@
"""
Unit tests for ReadReplicaRoutingMiddleware.
This module contains comprehensive tests for the ReadReplicaRoutingMiddleware
that handles intelligent database routing to read replicas based on HTTP methods
and view configuration.
Test Organization:
- TestReadReplicaRoutingMiddleware: Core middleware functionality
- TestProcessView: process_view method behavior
- TestReplicaDecisionLogic: Decision logic for replica usage
- TestAttributeDetection: View attribute detection methods
- TestExceptionHandling: Exception handling and cleanup
- TestRealViewIntegration: Real Django/DRF view integration
- TestEdgeCases: Edge cases and error conditions
"""
import pytest
from unittest.mock import Mock, patch
from django.http import HttpResponse
from django.test import RequestFactory
from django.views import View
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet
from plane.middleware.db_routing import ReadReplicaRoutingMiddleware
# Pytest fixtures
@pytest.fixture
def mock_get_response():
"""Fixture for mocked get_response callable."""
return Mock(return_value=HttpResponse())
@pytest.fixture
def middleware(mock_get_response):
"""Fixture for ReadReplicaRoutingMiddleware instance."""
return ReadReplicaRoutingMiddleware(mock_get_response)
@pytest.fixture
def request_factory():
"""Fixture for Django RequestFactory."""
return RequestFactory()
@pytest.fixture
def mock_view_func():
"""Fixture for a basic mocked view function."""
view = Mock()
view.use_read_replica = True
return view
@pytest.fixture
def get_request(request_factory):
"""Fixture for a GET request."""
return request_factory.get("/api/test/")
@pytest.fixture
def post_request(request_factory):
"""Fixture for a POST request."""
return request_factory.post("/api/test/")
@pytest.mark.unit
class TestReadReplicaRoutingMiddleware:
"""Test cases for ReadReplicaRoutingMiddleware core functionality."""
def test_middleware_initialization(self, middleware, mock_get_response):
"""Test middleware initializes correctly with expected attributes."""
assert middleware.get_response == mock_get_response
assert hasattr(middleware, "READ_ONLY_METHODS")
assert "GET" in middleware.READ_ONLY_METHODS
assert "HEAD" in middleware.READ_ONLY_METHODS
assert "OPTIONS" in middleware.READ_ONLY_METHODS
def test_read_only_methods_constant(self, middleware):
"""Test READ_ONLY_METHODS contains expected HTTP methods."""
expected_methods = {"GET", "HEAD", "OPTIONS"}
assert middleware.READ_ONLY_METHODS == expected_methods
@patch("plane.middleware.db_routing.set_use_read_replica")
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_call_routes_write_methods_to_primary(
self, mock_clear, mock_set, middleware, post_request, mock_get_response
):
"""Test __call__ routes write methods to primary database."""
response = middleware(post_request)
mock_set.assert_called_once_with(False) # Primary database
mock_clear.assert_called_once()
assert response == mock_get_response.return_value
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_call_with_read_methods_waits_for_process_view(
self, mock_clear, middleware, get_request, mock_get_response
):
"""Test __call__ with read methods waits for process_view."""
response = middleware(get_request)
mock_clear.assert_called_once()
assert response == mock_get_response.return_value
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_call_always_cleans_up_context(self, mock_clear, middleware, get_request):
"""Test __call__ always cleans up context."""
middleware(get_request)
mock_clear.assert_called_once()
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_call_cleans_up_context_on_exception(
self, mock_clear, middleware, get_request, mock_get_response
):
"""Test __call__ cleans up context even if get_response raises."""
mock_get_response.side_effect = Exception("Test exception")
with pytest.raises(Exception, match="Test exception"):
middleware(get_request)
mock_clear.assert_called_once()
@pytest.mark.unit
class TestProcessView:
"""Test cases for process_view method functionality."""
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_read_method_and_replica_true(self, mock_set, middleware, get_request):
"""Test process_view with GET request and use_read_replica=True."""
view_func = Mock()
view_func.use_read_replica = True
result = middleware.process_view(get_request, view_func, (), {})
mock_set.assert_called_once_with(True)
assert result is None
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_read_method_and_replica_false(
self, mock_set, middleware, get_request
):
"""Test process_view with GET request and use_read_replica=False."""
view_func = Mock()
view_func.use_read_replica = False
result = middleware.process_view(get_request, view_func, (), {})
mock_set.assert_called_once_with(False)
assert result is None
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_read_method_and_no_replica_attribute(
self, mock_set, middleware, get_request
):
"""Test process_view with GET request and no use_read_replica attr."""
view_func = Mock(spec=[]) # No use_read_replica attribute
result = middleware.process_view(get_request, view_func, (), {})
mock_set.assert_called_once_with(False) # Default to primary
assert result is None
def test_with_write_method_ignores_view_attributes(self, middleware, post_request):
"""Test process_view with write methods ignores view attributes."""
view_func = Mock()
view_func.use_read_replica = True # This should be ignored for POST
result = middleware.process_view(post_request, view_func, (), {})
assert result is None # Should not process for write methods
@pytest.mark.unit
class TestReplicaDecisionLogic:
"""Test cases for replica decision logic methods."""
def test_should_use_read_replica_with_true_attribute(self, middleware):
"""Test _should_use_read_replica returns True for True attribute."""
view_func = Mock()
view_func.use_read_replica = True
result = middleware._should_use_read_replica(view_func)
assert result is True
def test_should_use_read_replica_with_false_attribute(self, middleware):
"""Test _should_use_read_replica returns False for False attribute."""
view_func = Mock()
view_func.use_read_replica = False
result = middleware._should_use_read_replica(view_func)
assert result is False
def test_should_use_read_replica_with_no_attribute_defaults_false(self, middleware):
"""Test _should_use_read_replica defaults to False for missing attr."""
view_func = Mock(spec=[]) # No use_read_replica attribute
result = middleware._should_use_read_replica(view_func)
assert result is False
@pytest.mark.unit
class TestAttributeDetection:
"""Test cases for view attribute detection methods."""
def test_get_use_replica_attribute_function_based_view(self, middleware):
"""Test _get_use_replica_attribute with function-based view."""
# Test with True
view_func = Mock()
view_func.use_read_replica = True
result = middleware._get_use_replica_attribute(view_func)
assert result is True
# Test with False
view_func.use_read_replica = False
result = middleware._get_use_replica_attribute(view_func)
assert result is False
# Test with no attribute
view_func = Mock(spec=[])
result = middleware._get_use_replica_attribute(view_func)
assert result is None
def test_get_use_replica_attribute_django_cbv(self, middleware):
"""Test _get_use_replica_attribute with Django CBV wrapper."""
view_class = Mock()
view_class.use_read_replica = True
view_func = Mock()
view_func.view_class = view_class
# Remove use_read_replica from view_func to ensure it checks view_class
del view_func.use_read_replica
result = middleware._get_use_replica_attribute(view_func)
assert result is True
def test_get_use_replica_attribute_drf_wrapper(self, middleware):
"""Test _get_use_replica_attribute with DRF wrapper."""
# Create a real object to avoid Mock issues
class ViewClass:
use_read_replica = True
class ViewFunc:
cls = ViewClass()
view_func = ViewFunc()
result = middleware._get_use_replica_attribute(view_func)
assert result is True
def test_get_use_replica_attribute_priority_order(self, middleware):
"""Test attribute priority: direct > view_class > cls."""
view_func = Mock()
view_func.use_read_replica = True # Direct attribute (highest priority)
# Add conflicting attributes with lower priority
view_class = Mock()
view_class.use_read_replica = False
view_func.view_class = view_class
cls = Mock()
cls.use_read_replica = False
view_func.cls = cls
result = middleware._get_use_replica_attribute(view_func)
assert result is True # Should use direct attribute
@pytest.mark.parametrize(
"value,expected",
[
(True, True),
(False, False),
(1, True),
(0, False),
("yes", True),
("", False),
([], False),
([1], True),
(None, False),
],
)
def test_should_use_read_replica_truthy_falsy_values(
self, middleware, value, expected
):
"""Test _should_use_read_replica with various truthy/falsy values."""
# Create a real object to test the attribute handling
class TestView:
pass
view_func = TestView()
view_func.use_read_replica = value
result = middleware._should_use_read_replica(view_func)
assert result == expected
@pytest.mark.unit
class TestExceptionHandling:
"""Test cases for exception handling and cleanup."""
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_process_exception_cleans_up_context(
self, mock_clear, middleware, request_factory
):
"""Test process_exception cleans up context."""
request = request_factory.get("/api/test/")
exception = Exception("Test exception")
result = middleware.process_exception(request, exception)
mock_clear.assert_called_once()
assert result is None # Don't handle the exception
@patch("plane.middleware.db_routing.set_use_read_replica")
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_integration_full_request_cycle(
self, mock_clear, mock_set, middleware, request_factory, mock_get_response
):
"""Test complete request cycle from __call__ through process_view."""
request = request_factory.get("/api/test/")
view_func = Mock()
view_func.use_read_replica = True
# Call middleware and process_view manually
response = middleware(request)
middleware.process_view(request, view_func, (), {})
mock_set.assert_called_once_with(True)
mock_clear.assert_called_once()
assert response == mock_get_response.return_value
@pytest.mark.unit
class TestRealViewIntegration:
"""Test middleware with real Django/DRF view classes."""
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_django_class_based_view(self, mock_set, middleware, request_factory):
"""Test middleware with actual Django CBV."""
class TestView(View):
use_read_replica = True
# Simulate Django's URL resolver creating a view wrapper
view_func = TestView.as_view()
request = request_factory.get("/api/test/")
middleware.process_view(request, view_func, (), {})
mock_set.assert_called_once_with(True)
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_drf_api_view(self, mock_set, middleware, request_factory):
"""Test middleware with DRF APIView."""
class TestAPIView(APIView):
use_read_replica = True
# Simulate DRF's URL pattern creating a view wrapper
view_func = TestAPIView.as_view()
request = request_factory.get("/api/test/")
middleware.process_view(request, view_func, (), {})
mock_set.assert_called_once_with(True)
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_drf_viewset(self, mock_set, middleware, request_factory):
"""Test middleware with DRF ViewSet."""
class TestViewSet(ViewSet):
use_read_replica = True
# Simulate DRF router creating viewset action
view_func = TestViewSet.as_view({"get": "list"})
request = request_factory.get("/api/test/")
middleware.process_view(request, view_func, (), {})
mock_set.assert_called_once_with(True)
@pytest.mark.unit
class TestEdgeCases:
"""Test edge cases and error conditions."""
def test_process_view_with_none_view_func(self, middleware, request_factory):
"""Test process_view handles None view_func gracefully."""
request = request_factory.get("/api/test/")
result = middleware.process_view(request, None, (), {})
assert result is None # Should not crash
def test_get_use_replica_attribute_with_attribute_error(self, middleware):
"""Test _get_use_replica_attribute with view that raises AttributeError."""
# Create a view class that raises AttributeError on access
class ProblematicView:
def __getattr__(self, name):
if name == "use_read_replica":
raise AttributeError("Simulated attribute error")
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
view_func = ProblematicView()
result = middleware._get_use_replica_attribute(view_func)
assert result is None # Should handle gracefully
def test_multiple_exception_calls_are_safe(self, middleware, request_factory):
"""Test that multiple calls to process_exception don't cause issues."""
request = request_factory.get("/api/test/")
exception = Exception("Test exception")
# Call multiple times
result1 = middleware.process_exception(request, exception)
result2 = middleware.process_exception(request, exception)
assert result1 is None # Both should return None safely
assert result2 is None
@@ -1,7 +1,7 @@
import pytest
from uuid import uuid4
from plane.db.models import Workspace, WorkspaceMember
from plane.db.models import Workspace, WorkspaceMember, User
@pytest.mark.unit
-19
View File
@@ -2,11 +2,6 @@
from django.conf import settings
from django.urls import include, path, re_path
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
handler404 = "plane.app.views.error_404.custom_404_view"
@@ -19,20 +14,6 @@ urlpatterns = [
path("", include("plane.web.urls")),
]
if settings.ENABLE_DRF_SPECTACULAR:
urlpatterns += [
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"api/schema/swagger-ui/",
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path(
"api/schema/redoc/",
SpectacularRedocView.as_view(url_name="schema"),
name="redoc",
),
]
if settings.DEBUG:
try:
-9
View File
@@ -1,9 +0,0 @@
import random
import string
def get_random_color():
"""
Get a random color in hex format
"""
return "#" + "".join(random.choices(string.hexdigits, k=6))
-357
View File
@@ -1,357 +0,0 @@
# Python imports
import base64
import json
import re
# Maximum allowed size for binary data (10MB)
MAX_SIZE = 10 * 1024 * 1024
# Maximum recursion depth to prevent stack overflow
MAX_RECURSION_DEPTH = 20
# Dangerous text patterns that could indicate XSS or script injection
DANGEROUS_TEXT_PATTERNS = [
r"<script[^>]*>.*?</script>",
r"javascript\s*:",
r"data\s*:\s*text/html",
r"eval\s*\(",
r"document\s*\.",
r"window\s*\.",
r"location\s*\.",
]
# Dangerous attribute patterns for HTML attributes
DANGEROUS_ATTR_PATTERNS = [
r"javascript\s*:",
r"data\s*:\s*text/html",
r"eval\s*\(",
r"alert\s*\(",
r"document\s*\.",
r"window\s*\.",
]
# Suspicious patterns for binary data content
SUSPICIOUS_BINARY_PATTERNS = [
"<html",
"<!doctype",
"<script",
"javascript:",
"data:",
"<iframe",
]
# Malicious HTML patterns for content validation
MALICIOUS_HTML_PATTERNS = [
# Script tags with any content
r"<script[^>]*>",
r"</script>",
# JavaScript URLs in various attributes
r'(?:href|src|action)\s*=\s*["\']?\s*javascript:',
# Data URLs with text/html (potential XSS)
r'(?:href|src|action)\s*=\s*["\']?\s*data:text/html',
# Dangerous event handlers with JavaScript-like content
r'on(?:load|error|click|focus|blur|change|submit|reset|select|resize|scroll|unload|beforeunload|hashchange|popstate|storage|message|offline|online)\s*=\s*["\']?[^"\']*(?:javascript|alert|eval|document\.|window\.|location\.|history\.)[^"\']*["\']?',
# Object and embed tags that could load external content
r"<(?:object|embed)[^>]*(?:data|src)\s*=",
# Base tag that could change relative URL resolution
r"<base[^>]*href\s*=",
# Dangerous iframe sources
r'<iframe[^>]*src\s*=\s*["\']?(?:javascript:|data:text/html)',
# Meta refresh redirects
r'<meta[^>]*http-equiv\s*=\s*["\']?refresh["\']?',
# Link tags - simplified patterns
r'<link[^>]*rel\s*=\s*["\']?stylesheet["\']?',
r'<link[^>]*href\s*=\s*["\']?https?://',
r'<link[^>]*href\s*=\s*["\']?//',
r'<link[^>]*href\s*=\s*["\']?(?:data:|javascript:)',
# Style tags with external imports
r"<style[^>]*>.*?@import.*?(?:https?://|//)",
# Link tags with dangerous rel types
r'<link[^>]*rel\s*=\s*["\']?(?:import|preload|prefetch|dns-prefetch|preconnect)["\']?',
# Forms with action attributes
r"<form[^>]*action\s*=",
]
# Dangerous JavaScript patterns for event handlers
DANGEROUS_JS_PATTERNS = [
r"alert\s*\(",
r"eval\s*\(",
r"document\s*\.",
r"window\s*\.",
r"location\s*\.",
r"fetch\s*\(",
r"XMLHttpRequest",
r"innerHTML\s*=",
r"outerHTML\s*=",
r"document\.write",
r"script\s*>",
]
# HTML self-closing tags that don't need closing tags
SELF_CLOSING_TAGS = {
"img",
"br",
"hr",
"input",
"meta",
"link",
"area",
"base",
"col",
"embed",
"source",
"track",
"wbr",
}
def validate_binary_data(data):
"""
Validate that binary data appears to be valid document format and doesn't contain malicious content.
Args:
data (bytes or str): The binary data to validate, or base64-encoded string
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not data:
return True, None # Empty is OK
# Handle base64-encoded strings by decoding them first
if isinstance(data, str):
try:
binary_data = base64.b64decode(data)
except Exception:
return False, "Invalid base64 encoding"
else:
binary_data = data
# Size check - 10MB limit
if len(binary_data) > MAX_SIZE:
return False, "Binary data exceeds maximum size limit (10MB)"
# Basic format validation
if len(binary_data) < 4:
return False, "Binary data too short to be valid document format"
# Check for suspicious text patterns (HTML/JS)
try:
decoded_text = binary_data.decode("utf-8", errors="ignore")[:200]
if any(
pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS
):
return False, "Binary data contains suspicious content patterns"
except Exception:
pass # Binary data might not be decodable as text, which is fine
return True, None
def validate_html_content(html_content):
"""
Validate that HTML content is safe and doesn't contain malicious patterns.
Args:
html_content (str): The HTML content to validate
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not html_content:
return True, None # Empty is OK
# Size check - 10MB limit (consistent with binary validation)
if len(html_content.encode("utf-8")) > MAX_SIZE:
return False, "HTML content exceeds maximum size limit (10MB)"
# Check for specific malicious patterns (simplified and more reliable)
for pattern in MALICIOUS_HTML_PATTERNS:
if re.search(pattern, html_content, re.IGNORECASE | re.DOTALL):
return (
False,
f"HTML content contains potentially malicious patterns: {pattern}",
)
# Additional check for inline event handlers that contain suspicious content
# This is more permissive - only blocks if the event handler contains actual dangerous code
event_handler_pattern = r'on\w+\s*=\s*["\']([^"\']*)["\']'
event_matches = re.findall(event_handler_pattern, html_content, re.IGNORECASE)
for handler_content in event_matches:
for js_pattern in DANGEROUS_JS_PATTERNS:
if re.search(js_pattern, handler_content, re.IGNORECASE):
return (
False,
f"HTML content contains dangerous JavaScript in event handler: {handler_content[:100]}",
)
# Basic HTML structure validation - check for common malformed tags
try:
# Count opening and closing tags for basic structure validation
opening_tags = re.findall(r"<(\w+)[^>]*>", html_content)
closing_tags = re.findall(r"</(\w+)>", html_content)
# Filter out self-closing tags from opening tags
opening_tags_filtered = [
tag for tag in opening_tags if tag.lower() not in SELF_CLOSING_TAGS
]
# Basic check - if we have significantly more opening than closing tags, it might be malformed
if len(opening_tags_filtered) > len(closing_tags) + 10: # Allow some tolerance
return False, "HTML content appears to be malformed (unmatched tags)"
except Exception:
# If HTML parsing fails, we'll allow it
pass
return True, None
def validate_json_content(json_content):
"""
Validate that JSON content is safe and doesn't contain malicious patterns.
Args:
json_content (dict): The JSON content to validate
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not json_content:
return True, None # Empty is OK
try:
# Size check - 10MB limit (consistent with other validations)
json_str = json.dumps(json_content)
if len(json_str.encode("utf-8")) > MAX_SIZE:
return False, "JSON content exceeds maximum size limit (10MB)"
# Basic structure validation for page description JSON
if isinstance(json_content, dict):
# Check for expected page description structure
# This is based on ProseMirror/Tiptap JSON structure
if "type" in json_content and json_content.get("type") == "doc":
# Valid document structure
if "content" in json_content and isinstance(
json_content["content"], list
):
# Recursively check content for suspicious patterns
is_valid, error_msg = _validate_json_content_array(
json_content["content"]
)
if not is_valid:
return False, error_msg
elif "type" not in json_content and "content" not in json_content:
# Allow other JSON structures but validate for suspicious content
is_valid, error_msg = _validate_json_content_recursive(json_content)
if not is_valid:
return False, error_msg
else:
return False, "JSON description must be a valid object"
except (TypeError, ValueError) as e:
return False, "Invalid JSON structure"
except Exception as e:
return False, "Failed to validate JSON content"
return True, None
def _validate_json_content_array(content, depth=0):
"""
Validate JSON content array for suspicious patterns.
Args:
content (list): Array of content nodes to validate
depth (int): Current recursion depth (default: 0)
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
# Check recursion depth to prevent stack overflow
if depth > MAX_RECURSION_DEPTH:
return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded"
if not isinstance(content, list):
return True, None
for node in content:
if isinstance(node, dict):
# Check text content for suspicious patterns (more targeted)
if node.get("type") == "text" and "text" in node:
text_content = node["text"]
for pattern in DANGEROUS_TEXT_PATTERNS:
if re.search(pattern, text_content, re.IGNORECASE):
return (
False,
"JSON content contains suspicious script patterns in text",
)
# Check attributes for suspicious content (more targeted)
if "attrs" in node and isinstance(node["attrs"], dict):
for attr_name, attr_value in node["attrs"].items():
if isinstance(attr_value, str):
# Only check specific attributes that could be dangerous
if attr_name.lower() in [
"href",
"src",
"action",
"onclick",
"onload",
"onerror",
]:
for pattern in DANGEROUS_ATTR_PATTERNS:
if re.search(pattern, attr_value, re.IGNORECASE):
return (
False,
f"JSON content contains dangerous pattern in {attr_name} attribute",
)
# Recursively check nested content
if "content" in node and isinstance(node["content"], list):
is_valid, error_msg = _validate_json_content_array(
node["content"], depth + 1
)
if not is_valid:
return False, error_msg
return True, None
def _validate_json_content_recursive(obj, depth=0):
"""
Recursively validate JSON object for suspicious content.
Args:
obj: JSON object (dict, list, or primitive) to validate
depth (int): Current recursion depth (default: 0)
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
# Check recursion depth to prevent stack overflow
if depth > MAX_RECURSION_DEPTH:
return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded"
if isinstance(obj, dict):
for key, value in obj.items():
if isinstance(value, str):
# Check for dangerous patterns using module constants
for pattern in DANGEROUS_TEXT_PATTERNS:
if re.search(pattern, value, re.IGNORECASE):
return (
False,
"JSON content contains suspicious script patterns",
)
elif isinstance(value, (dict, list)):
is_valid, error_msg = _validate_json_content_recursive(value, depth + 1)
if not is_valid:
return False, error_msg
elif isinstance(obj, list):
for item in obj:
is_valid, error_msg = _validate_json_content_recursive(item, depth + 1)
if not is_valid:
return False, error_msg
return True, None
-21
View File
@@ -1,21 +0,0 @@
"""
Core utilities for Plane database routing and request scoping.
This package contains essential components for managing read replica routing
and request-scoped context in the Plane application.
"""
from .dbrouters import ReadReplicaRouter
from .mixins import ReadReplicaControlMixin
from .request_scope import (
set_use_read_replica,
should_use_read_replica,
clear_read_replica_context,
)
__all__ = [
"ReadReplicaRouter",
"ReadReplicaControlMixin",
"set_use_read_replica",
"should_use_read_replica",
"clear_read_replica_context",
]
-73
View File
@@ -1,73 +0,0 @@
"""
Database router for read replica selection.
This router determines which database to use for read/write operations
based on the request context set by the ReadReplicaRoutingMiddleware.
"""
import logging
from typing import Type
from django.db import models
from .request_scope import should_use_read_replica
logger = logging.getLogger("plane.db")
class ReadReplicaRouter:
"""
Database router that directs read operations to replica when appropriate.
This router works in conjunction with ReadReplicaRoutingMiddleware to:
- Route read operations to replica database when request context allows
- Always route write operations to primary database
- Ensure migrations only run on primary database
"""
def db_for_read(self, model: Type[models.Model], **hints) -> str:
"""
Determine which database to use for read operations.
Args:
model: The Django model class being queried
**hints: Additional routing hints
Returns:
str: Database alias ('replica' or 'default')
"""
if should_use_read_replica():
logger.debug(f"Routing read for {model._meta.label} to replica database")
return "replica"
else:
logger.debug(f"Routing read for {model._meta.label} to primary database")
return "default"
def db_for_write(self, model: Type[models.Model], **hints) -> str:
"""
Determine which database to use for write operations.
All write operations always go to the primary database to ensure
data consistency and avoid replication lag issues.
Args:
model: The Django model class being written to
**hints: Additional routing hints
Returns:
str: Always returns 'default' (primary database)
"""
logger.debug(f"Routing write for {model._meta.label} to primary database")
return "default"
def allow_migrate(
self, db: str, app_label: str, model_name: str = None, **hints
) -> bool:
"""
Ensure migrations only run on the primary database.
Args:
db: Database alias
app_label: Application label
model_name: Model name (optional)
**hints: Additional routing hints
Returns:
bool: True if migration is allowed on this database
"""
# Only allow migrations on the primary database
allowed = db == "default"
if not allowed:
logger.debug(f"Blocking migration for {app_label} on {db} database")
return allowed

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