Compare commits

..

2 Commits

Author SHA1 Message Date
Aaryan Khandelwal 015f8cbfe5 chore: split editor ref 2025-08-26 16:56:29 +05:30
Aaryan Khandelwal 42b38db131 chore: implement code splitting for editor 2025-08-26 14:37:17 +05:30
1082 changed files with 17963 additions and 31083 deletions
+12 -21
View File
@@ -35,10 +35,6 @@ on:
- preview
- canary
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
TARGET_BRANCH: ${{ github.ref_name }}
ARM64_BUILD: ${{ github.event.inputs.arm64 }}
@@ -272,14 +268,15 @@ jobs:
if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }}
name: Build-Push AIO Docker Image
runs-on: ubuntu-22.04
needs:
- branch_build_setup
- branch_build_push_admin
- branch_build_push_web
- branch_build_push_space
- branch_build_push_live
- branch_build_push_api
- branch_build_push_proxy
needs: [
branch_build_setup,
branch_build_push_admin,
branch_build_push_web,
branch_build_push_space,
branch_build_push_live,
branch_build_push_api,
branch_build_push_proxy
]
steps:
- name: Checkout Files
uses: actions/checkout@v4
@@ -288,7 +285,7 @@ jobs:
id: prepare_aio_assets
run: |
cd deployments/aio/community
if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then
aio_version=${{ needs.branch_build_setup.outputs.release_version }}
else
@@ -327,14 +324,7 @@ jobs:
upload_build_assets:
name: Upload Build Assets
runs-on: ubuntu-22.04
needs:
- branch_build_setup
- branch_build_push_admin
- branch_build_push_web
- branch_build_push_space
- branch_build_push_live
- branch_build_push_api
- branch_build_push_proxy
needs: [branch_build_setup, branch_build_push_admin, branch_build_push_web, branch_build_push_space, branch_build_push_live, branch_build_push_api, branch_build_push_proxy]
steps:
- name: Checkout Files
uses: actions/checkout@v4
@@ -407,3 +397,4 @@ jobs:
${{ github.workspace }}/deployments/cli/community/docker-compose.yml
${{ github.workspace }}/deployments/cli/community/variables.env
${{ github.workspace }}/deployments/swarm/community/swarm.sh
+2
View File
@@ -17,6 +17,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Get PR Branch version
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
+4 -2
View File
@@ -3,9 +3,11 @@ name: "CodeQL"
on:
workflow_dispatch:
push:
branches: ["preview", "canary", "master"]
branches: ["preview", "master"]
pull_request:
branches: ["preview", "canary", "master"]
branches: ["develop", "preview", "master"]
schedule:
- cron: "53 19 * * 5"
jobs:
analyze:
@@ -3,21 +3,11 @@ name: Build and lint API
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
branches: ["preview"]
types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"]
paths:
- "apps/api/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-api:
name: Lint API
@@ -3,18 +3,21 @@ name: Build and lint web apps
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
branches: ["preview"]
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
[
"opened",
"synchronize",
"ready_for_review",
"review_requested",
"reopened",
]
paths:
- "**.tsx?"
- "**.jsx?"
- "**.css"
- "**.json"
- "!apps/api/**"
jobs:
build-and-lint:
@@ -24,18 +27,16 @@ jobs:
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
TURBO_SCM_HEAD: ${{ github.sha }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 50
filter: blob:none
fetch-depth: 2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- name: Enable Corepack and pnpm
run: corepack enable pnpm
@@ -43,11 +44,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint Affected
run: pnpm turbo run check:lint --affected
- name: Lint web apps
run: pnpm run check:lint
- name: Check Affected format
run: pnpm turbo run check:format --affected
- name: Check format
run: pnpm run check:format
- name: Build Affected
run: pnpm turbo run build --affected
- name: Build apps
run: pnpm run build
+1 -1
View File
@@ -14,7 +14,7 @@ strict-peer-dependencies=false
# Turbo occasionally performs postinstall tasks for optimal performance
# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=eslint
public-hoist-pattern[]=prettier
public-hoist-pattern[]=typescript
+1
View File
@@ -0,0 +1 @@
lts/jod
-12
View File
@@ -1,12 +0,0 @@
.next/*
out/*
public/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test
+1
View File
@@ -1,4 +1,5 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
};
@@ -9,7 +9,7 @@ import { useInstance } from "@/hooks/store";
// components
import { InstanceEmailForm } from "./email-config-form";
const InstanceEmailPage: React.FC = observer(() => {
const InstanceEmailPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();
@@ -29,7 +29,7 @@ const InstanceEmailPage: React.FC = observer(() => {
message: "Email feature has been disabled",
type: TOAST_TYPE.SUCCESS,
});
} catch (_error) {
} catch (error) {
setToast({
title: "Error disabling email",
message: "Failed to disable email feature. Please try again.",
@@ -7,8 +7,7 @@ import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
import { Transition } from "@headlessui/react";
// plane internal packages
import { WEB_BASE_URL } from "@plane/constants";
import { DiscordIcon, GithubIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
@@ -5,8 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
// plane internal packages
import { WorkspaceIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { Tooltip, WorkspaceIcon } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { PlaneLockup } from "@plane/propel/icons";
import { PlaneLockup } from "@plane/ui";
export const AuthHeader = () => (
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
@@ -25,8 +25,9 @@ export const EmailCodesConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableMagicLogin))}
onChange={() => {
const newEnableMagicLogin = Boolean(parseInt(enableMagicLogin)) === true ? "0" : "1";
updateConfig("ENABLE_MAGIC_LINK_LOGIN", newEnableMagicLogin);
Boolean(parseInt(enableMagicLogin)) === true
? updateConfig("ENABLE_MAGIC_LINK_LOGIN", "0")
: updateConfig("ENABLE_MAGIC_LINK_LOGIN", "1");
}}
size="sm"
disabled={disabled}
@@ -35,8 +35,9 @@ export const GithubConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableGithubConfig))}
onChange={() => {
const newEnableGithubConfig = Boolean(parseInt(enableGithubConfig)) === true ? "0" : "1";
updateConfig("IS_GITHUB_ENABLED", newEnableGithubConfig);
Boolean(parseInt(enableGithubConfig)) === true
? updateConfig("IS_GITHUB_ENABLED", "0")
: updateConfig("IS_GITHUB_ENABLED", "1");
}}
size="sm"
disabled={disabled}
@@ -35,8 +35,9 @@ export const GitlabConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
const newEnableGitlabConfig = Boolean(parseInt(enableGitlabConfig)) === true ? "0" : "1";
updateConfig("IS_GITLAB_ENABLED", newEnableGitlabConfig);
Boolean(parseInt(enableGitlabConfig)) === true
? updateConfig("IS_GITLAB_ENABLED", "0")
: updateConfig("IS_GITLAB_ENABLED", "1");
}}
size="sm"
disabled={disabled}
@@ -35,8 +35,9 @@ export const GoogleConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => {
const newEnableGoogleConfig = Boolean(parseInt(enableGoogleConfig)) === true ? "0" : "1";
updateConfig("IS_GOOGLE_ENABLED", newEnableGoogleConfig);
Boolean(parseInt(enableGoogleConfig)) === true
? updateConfig("IS_GOOGLE_ENABLED", "0")
: updateConfig("IS_GOOGLE_ENABLED", "1");
}}
size="sm"
disabled={disabled}
@@ -25,8 +25,9 @@ export const PasswordLoginConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableEmailPassword))}
onChange={() => {
const newEnableEmailPassword = Boolean(parseInt(enableEmailPassword)) === true ? "0" : "1";
updateConfig("ENABLE_EMAIL_PASSWORD", newEnableEmailPassword);
Boolean(parseInt(enableEmailPassword)) === true
? updateConfig("ENABLE_EMAIL_PASSWORD", "0")
: updateConfig("ENABLE_EMAIL_PASSWORD", "1");
}}
size="sm"
disabled={disabled}
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { Tooltip } from "@plane/propel/tooltip";
import { Tooltip } from "@plane/ui";
type Props = {
label?: string;
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import { ExternalLink } from "lucide-react";
// plane internal packages
import { WEB_BASE_URL } from "@plane/constants";
import { Tooltip } from "@plane/propel/tooltip";
import { Tooltip } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store";
+1 -1
View File
@@ -209,7 +209,7 @@ export class InstanceStore implements IInstanceStore {
});
});
await this.instanceService.disableEmail();
} catch (_error) {
} catch (error) {
console.error("Error disabling the email");
this.instanceConfigurations = instanceConfigurations;
}
+16 -16
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "1.0.0",
"version": "0.28.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -26,30 +26,30 @@
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"autoprefixer": "10.4.14",
"axios": "catalog:",
"lodash": "catalog:",
"lucide-react": "catalog:",
"mobx": "catalog:",
"mobx-react": "catalog:",
"next": "catalog:",
"axios": "1.11.0",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "14.2.30",
"next-themes": "^0.2.1",
"postcss": "^8.4.49",
"react": "catalog:",
"react-dom": "catalog:",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "7.51.5",
"sharp": "catalog:",
"swr": "catalog:",
"uuid": "catalog:"
"sharp": "^0.33.5",
"swr": "^2.2.4",
"uuid": "^9.0.1"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash": "catalog:",
"@types/lodash": "^4.17.6",
"@types/node": "18.16.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"typescript": "catalog:"
"typescript": "5.8.3"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.0.0",
"version": "0.28.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
-1
View File
@@ -91,7 +91,6 @@ class BaseSerializer(serializers.ModelSerializer):
"project_lead": UserLiteSerializer,
"state": StateLiteSerializer,
"created_by": UserLiteSerializer,
"updated_by": UserLiteSerializer,
"issue": IssueSerializer,
"actor": UserLiteSerializer,
"owned_by": UserLiteSerializer,
+10 -13
View File
@@ -24,6 +24,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -88,24 +89,20 @@ class IssueSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid HTML passed")
# Validate description content for security
if data.get("description_html"):
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if data.get("description"):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
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": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if data.get("assignees", []):
+17 -7
View File
@@ -12,6 +12,7 @@ from plane.db.models import (
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
)
from .base import BaseSerializer
@@ -199,18 +200,27 @@ class ProjectSerializer(BaseSerializer):
)
# 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, sanitized_html = validate_html_content(
is_valid, error_msg = validate_json_content(data["description_html"])
else:
is_valid, error_msg = validate_html_content(
str(data["description_html"])
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
raise serializers.ValidationError({"description_html": error_msg})
return data
+2 -2
View File
@@ -66,7 +66,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
)
if intake is None or not project.intake_view:
if intake is None and not project.intake_view:
return IntakeIssue.objects.none()
return (
@@ -230,7 +230,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
)
if intake is None or not project.intake_view:
if intake is None and not project.intake_view:
return IntakeIssue.objects.none()
return (
@@ -13,4 +13,3 @@ from .project import (
ProjectLitePermission,
)
from .base import allow_permission, ROLE
from .page import ProjectPagePermission
+2 -20
View File
@@ -39,31 +39,13 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
).exists():
return view_func(instance, request, *args, **kwargs)
else:
is_user_has_allowed_role = ProjectMember.objects.filter(
if ProjectMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
project_id=kwargs["project_id"],
role__in=allowed_role_values,
is_active=True,
).exists()
# Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role
if is_user_has_allowed_role:
return view_func(instance, request, *args, **kwargs)
elif (
ProjectMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
project_id=kwargs["project_id"],
is_active=True,
).exists()
and WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
role=ROLE.ADMIN.value,
is_active=True,
).exists()
):
).exists():
return view_func(instance, request, *args, **kwargs)
# Return permission denied if no conditions are met
-125
View File
@@ -1,125 +0,0 @@
from plane.db.models import ProjectMember, Page
from plane.app.permissions import ROLE
from rest_framework.permissions import BasePermission, SAFE_METHODS
# Permission Mappings for workspace members
ADMIN = ROLE.ADMIN.value
MEMBER = ROLE.MEMBER.value
GUEST = ROLE.GUEST.value
class ProjectPagePermission(BasePermission):
"""
Custom permission to control access to pages within a workspace
based on user roles, page visibility (public/private), and feature flags.
"""
def has_permission(self, request, view):
"""
Check basic project-level permissions before checking object-level permissions.
"""
if request.user.is_anonymous:
return False
user_id = request.user.id
slug = view.kwargs.get("slug")
page_id = view.kwargs.get("page_id")
project_id = view.kwargs.get("project_id")
# Hook for extended validation
extended_access, role = self._check_access_and_get_role(
request, slug, project_id
)
if extended_access is False:
return False
if page_id:
page = Page.objects.get(id=page_id, workspace__slug=slug)
# Allow access if the user is the owner of the page
if page.owned_by_id == user_id:
return True
# Handle private page access
if page.access == Page.PRIVATE_ACCESS:
return self._has_private_page_action_access(
request, slug, page, project_id
)
# Handle public page access
return self._has_public_page_action_access(request, role)
def _check_project_member_access(self, request, slug, project_id):
"""
Check if the user is a project member.
"""
return (
ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
project_id=project_id,
)
.values_list("role", flat=True)
.first()
)
def _check_access_and_get_role(self, request, slug, project_id):
"""
Hook for extended access checking
Returns: True (allow), False (deny), None (continue with normal flow)
"""
role = self._check_project_member_access(request, slug, project_id)
if not role:
return False, None
return True, role
def _has_private_page_action_access(self, request, slug, page, project_id):
"""
Check access to private pages. Override for feature flag logic.
"""
# Base implementation: only owner can access private pages
return False
def _check_project_action_access(self, request, role):
method = request.method
# Only admins can create (POST) pages
if method == "POST":
if role in [ADMIN, MEMBER]:
return True
return False
# Safe methods (GET, HEAD, OPTIONS) allowed for all active roles
if method in SAFE_METHODS:
if role in [ADMIN, MEMBER, GUEST]:
return True
return False
# PUT/PATCH: Admins and members can update
if method in ["PUT", "PATCH"]:
if role in [ADMIN, MEMBER]:
return True
return False
# DELETE: Only admins can delete
if method == "DELETE":
if role in [ADMIN]:
return True
return False
# Deny by default
return False
def _has_public_page_action_access(self, request, role):
"""
Check if the user has permission to access a public page
and can perform operations on the page.
"""
project_member_exists = self._check_project_action_access(request, role)
if not project_member_exists:
return False
return True
+13 -22
View File
@@ -3,7 +3,11 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
# Module import
from plane.db.models import ProjectMember, WorkspaceMember
from plane.db.models.project import ROLE
# Permission Mappings
Admin = 20
Member = 15
Guest = 5
class ProjectBasePermission(BasePermission):
@@ -22,31 +26,18 @@ class ProjectBasePermission(BasePermission):
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role__in=[Admin, Member],
is_active=True,
).exists()
project_member_qs = ProjectMember.objects.filter(
## Only Project Admins can update project attributes
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role=Admin,
project_id=view.project_id,
is_active=True,
)
## Only project admins or workspace admin who is part of the project can access
if project_member_qs.filter(role=ROLE.ADMIN.value).exists():
return True
else:
return (
project_member_qs.exists()
and WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role=ROLE.ADMIN.value,
is_active=True,
).exists()
)
).exists()
class ProjectMemberPermission(BasePermission):
@@ -64,7 +55,7 @@ class ProjectMemberPermission(BasePermission):
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role__in=[Admin, Member],
is_active=True,
).exists()
@@ -72,7 +63,7 @@ class ProjectMemberPermission(BasePermission):
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role__in=[Admin, Member],
project_id=view.project_id,
is_active=True,
).exists()
@@ -106,7 +97,7 @@ class ProjectEntityPermission(BasePermission):
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role__in=[Admin, Member],
project_id=view.project_id,
is_active=True,
).exists()
@@ -92,6 +92,8 @@ from .importer import ImporterSerializer
from .page import (
PageSerializer,
PageLogSerializer,
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
PageBinaryUpdateSerializer,
+10 -13
View File
@@ -23,6 +23,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
from plane.app.permissions import ROLE
@@ -75,24 +76,20 @@ class DraftIssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the attrs with sanitized HTML if available
if sanitized_html is not None:
attrs["description_html"] = sanitized_html
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": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
+13 -33
View File
@@ -43,6 +43,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -127,24 +128,20 @@ class IssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the attrs with sanitized HTML if available
if sanitized_html is not None:
attrs["description_html"] = sanitized_html
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": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
@@ -667,33 +664,16 @@ class IssueReactionSerializer(BaseSerializer):
class IssueReactionLiteSerializer(DynamicBaseSerializer):
display_name = serializers.CharField(source="actor.display_name", read_only=True)
class Meta:
model = IssueReaction
fields = ["id", "actor", "issue", "reaction", "display_name"]
fields = ["id", "actor", "issue", "reaction"]
class CommentReactionSerializer(BaseSerializer):
display_name = serializers.CharField(source="actor.display_name", read_only=True)
class Meta:
model = CommentReaction
fields = [
"id",
"actor",
"comment",
"reaction",
"display_name",
"deleted_at",
"workspace",
"project",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at", "created_by", "updated_by"]
fields = "__all__"
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
class IssueVoteSerializer(BaseSerializer):
+40 -3
View File
@@ -7,6 +7,7 @@ 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,
@@ -130,6 +131,32 @@ class PageDetailSerializer(PageSerializer):
fields = PageSerializer.Meta.fields + ["description_html"]
class SubPageSerializer(BaseSerializer):
entity_details = serializers.SerializerMethodField()
class Meta:
model = PageLog
fields = "__all__"
read_only_fields = ["workspace", "page"]
def get_entity_details(self, obj):
entity_name = obj.entity_name
if entity_name == "forward_link" or entity_name == "back_link":
try:
page = Page.objects.get(pk=obj.entity_identifier)
return PageSerializer(page).data
except Page.DoesNotExist:
return None
return None
class PageLogSerializer(BaseSerializer):
class Meta:
model = PageLog
fields = "__all__"
read_only_fields = ["workspace", "page"]
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
@@ -202,13 +229,23 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
return value
# Use the validation function from utils
is_valid, error_message, sanitized_html = validate_html_content(value)
is_valid, error_message = validate_html_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
# Return sanitized HTML if available, otherwise return original
return sanitized_html if sanitized_html is not None else value
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"""
+20 -9
View File
@@ -15,6 +15,8 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -63,18 +65,27 @@ class ProjectSerializer(BaseSerializer):
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
str(data["description_html"])
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
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(
{"error": "html content is not 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
+10 -13
View File
@@ -26,6 +26,7 @@ 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,
)
@@ -318,24 +319,20 @@ class StickySerializer(BaseSerializer):
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if "description" in data and data["description"]:
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
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": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
return data
+24 -11
View File
@@ -4,24 +4,22 @@ from django.urls import path
from plane.app.views import (
PageViewSet,
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
PageDuplicateEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages-summary/",
PageViewSet.as_view({"get": "summary"}),
name="project-pages-summary",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
PageViewSet.as_view({"get": "list", "post": "create"}),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/",
PageViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
@@ -29,30 +27,45 @@ urlpatterns = [
),
# favorite pages
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:page_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:pk>/",
PageFavoriteViewSet.as_view({"post": "create", "delete": "destroy"}),
name="user-favorite-pages",
),
# archived pages
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/archive/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/archive/",
PageViewSet.as_view({"post": "archive", "delete": "unarchive"}),
name="project-page-archive-unarchive",
),
# lock and unlock
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/lock/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/lock/",
PageViewSet.as_view({"post": "lock", "delete": "unlock"}),
name="project-pages-lock-unlock",
),
# private and public page
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/access/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/access/",
PageViewSet.as_view({"post": "access"}),
name="project-pages-access",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/description/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
PageLogEndpoint.as_view(),
name="page-transactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/<uuid:transaction>/",
PageLogEndpoint.as_view(),
name="page-transactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/sub-pages/",
SubPagesEndpoint.as_view(),
name="sub-page",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/description/",
PagesDescriptionViewSet.as_view({"get": "retrieve", "patch": "partial_update"}),
name="page-description",
),
+2
View File
@@ -165,6 +165,8 @@ from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
from .page.base import (
PageViewSet,
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageDuplicateEndpoint,
)
+2 -10
View File
@@ -441,11 +441,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
signed_url = storage.generate_presigned_url(object_name=asset.asset.name)
# Redirect to the signed URL
return HttpResponseRedirect(signed_url)
@@ -645,11 +641,7 @@ class ProjectAssetEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
signed_url = storage.generate_presigned_url(object_name=asset.asset.name)
# Redirect to the signed URL
return HttpResponseRedirect(signed_url)
+13 -3
View File
@@ -504,6 +504,19 @@ class CycleViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN], creator=True, model=Cycle)
def destroy(self, request, slug, project_id, pk):
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(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or owner can delete the cycle"},
status=status.HTTP_403_FORBIDDEN,
)
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
@@ -1080,9 +1093,6 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
)
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
cycle_properties.rich_filters = request.data.get(
"rich_filters", cycle_properties.rich_filters
)
cycle_properties.display_filters = request.data.get(
"display_filters", cycle_properties.display_filters
)
+19 -37
View File
@@ -1,5 +1,4 @@
# Python imports
import copy
import json
# Django imports
@@ -29,15 +28,11 @@ from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.app.permissions import allow_permission, ROLE
from plane.utils.host import base_host
from plane.utils.filters import ComplexFilterBackend
from plane.utils.filters import IssueFilterSet
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
webhook_event = "cycle_issue"
bulk = True
@@ -70,9 +65,24 @@ class CycleIssueViewSet(BaseViewSet):
.distinct()
)
def apply_annotations(self, issues):
return (
issues.annotate(
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def list(self, request, slug, project_id, cycle_id):
order_by_param = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
issue_queryset = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
.filter(**filters)
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -100,36 +110,11 @@ class CycleIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
)
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def list(self, request, slug, project_id, cycle_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
)
# Apply filtering from filterset
issue_queryset = self.filter_queryset(issue_queryset)
# Apply legacy filters
issue_queryset = issue_queryset.filter(**filters)
# Total count queryset
total_issue_queryset = copy.deepcopy(issue_queryset)
# Applying annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = issue_queryset.filter(**filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
@@ -160,7 +145,6 @@ class CycleIssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -195,7 +179,6 @@ class CycleIssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -222,7 +205,6 @@ class CycleIssueViewSet(BaseViewSet):
order_by=order_by_param,
request=request,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
+10 -29
View File
@@ -28,7 +28,6 @@ from plane.db.models import (
ProjectMember,
CycleIssue,
IssueDescriptionVersion,
WorkspaceMember,
)
from plane.app.serializers import (
IssueCreateSerializer,
@@ -349,32 +348,17 @@ class IntakeIssueViewSet(BaseViewSet):
project_id=project_id,
intake_id=intake_id,
)
project_member = ProjectMember.objects.filter(
# Get the project member
project_member = ProjectMember.objects.get(
workspace__slug=slug,
project_id=project_id,
member=request.user,
is_active=True,
).first()
is_workspace_admin = WorkspaceMember.objects.filter(
workspace__slug=slug,
is_active=True,
member=request.user,
role=ROLE.ADMIN.value,
).exists()
if not project_member and not is_workspace_admin:
return Response(
{"error": "Only admin or creator can update the intake work items"},
status=status.HTTP_403_FORBIDDEN,
)
)
# Only project members admins and created_by users can access this endpoint
if (
(project_member and project_member.role <= ROLE.GUEST.value)
and not is_workspace_admin
) and str(intake_issue.created_by_id) != str(request.user.id):
if project_member.role <= 5 and str(intake_issue.created_by_id) != str(
request.user.id
):
return Response(
{"error": "You cannot edit intake issues"},
status=status.HTTP_400_BAD_REQUEST,
@@ -407,8 +391,8 @@ class IntakeIssueViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())),
),
).get(pk=intake_issue.issue_id, workspace__slug=slug, project_id=project_id)
if project_member and project_member.role <= ROLE.GUEST.value:
# Only allow guests to edit name and description
if project_member.role <= 5:
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get(
@@ -416,7 +400,6 @@ class IntakeIssueViewSet(BaseViewSet):
),
"description": issue_data.get("description", issue.description),
}
current_instance = json.dumps(
IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
)
@@ -453,10 +436,8 @@ class IntakeIssueViewSet(BaseViewSet):
issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
# Only project admins can edit intake issue attributes
if (
project_member and project_member.role > ROLE.MEMBER.value
) or is_workspace_admin:
# Only project admins and members can edit intake issue attributes
if project_member.role > 15:
serializer = IntakeIssueSerializer(
intake_issue, data=request.data, partial=True
)
+28 -30
View File
@@ -1,5 +1,4 @@
# Python imports
import copy
import json
# Django imports
@@ -42,20 +41,27 @@ from plane.utils.host import base_host
# Module imports
from .. import BaseViewSet, BaseAPIView
from plane.utils.filters import ComplexFilterBackend
from plane.utils.filters import IssueFilterSet
class IssueArchiveViewSet(BaseViewSet):
serializer_class = IssueFlatSerializer
model = Issue
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
def apply_annotations(self, issues):
def get_queryset(self):
return (
issues.annotate(
Issue.objects.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(deleted_at__isnull=True)
.filter(archived_at__isnull=False)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -89,15 +95,6 @@ class IssueArchiveViewSet(BaseViewSet):
.values("count")
)
)
.prefetch_related("assignees", "labels", "issue_module__module")
)
def get_queryset(self):
return (
Issue.objects.filter(Q(type__isnull=True) | Q(type__is_epic=False))
.filter(archived_at__isnull=False)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
)
@method_decorator(gzip_page)
@@ -108,25 +105,26 @@ class IssueArchiveViewSet(BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset()
issue_queryset = self.get_queryset().filter(**filters)
total_issue_queryset = Issue.objects.filter(
deleted_at__isnull=True,
archived_at__isnull=False,
project_id=project_id,
workspace__slug=slug,
).filter(**filters)
total_issue_queryset = (
total_issue_queryset
if show_sub_issues == "true"
else total_issue_queryset.filter(parent__isnull=True)
)
issue_queryset = (
issue_queryset
if show_sub_issues == "true"
else issue_queryset.filter(parent__isnull=True)
)
# Apply filtering from filterset
issue_queryset = self.filter_queryset(issue_queryset)
# Apply legacy filters
issue_queryset = issue_queryset.filter(**filters)
# Total count queryset
total_issue_queryset = copy.deepcopy(issue_queryset)
# Applying annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
+110 -171
View File
@@ -1,5 +1,4 @@
# Python imports
import copy
import json
# Django imports
@@ -7,16 +6,16 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Count,
Exists,
F,
Func,
OuterRef,
Prefetch,
Q,
Subquery,
UUIDField,
Value,
Subquery,
Count,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -28,55 +27,50 @@ from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import ROLE, allow_permission
from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssueListDetailSerializer,
IssueSerializer,
IssueUserPropertySerializer,
IssueSerializer,
IssueListDetailSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.webhook_task import model_activity
from plane.db.models import (
CycleIssue,
FileAsset,
IntakeIssue,
Issue,
IssueAssignee,
IssueLabel,
FileAsset,
IssueLink,
IssueReaction,
IssueRelation,
IssueSubscriber,
IssueUserProperty,
ModuleIssue,
IssueReaction,
IssueSubscriber,
Project,
ProjectMember,
CycleIssue,
UserRecentVisit,
ModuleIssue,
IssueRelation,
IssueAssignee,
IssueLabel,
IntakeIssue,
)
from plane.utils.filters import ComplexFilterBackend, IssueFilterSet
from plane.utils.global_paginator import paginate
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.host import base_host
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.utils.timezone_converter import user_timezone_converter
from .. import BaseAPIView, BaseViewSet
from plane.utils.timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
from plane.utils.host import base_host
class IssueListEndpoint(BaseAPIView):
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
issue_ids = request.GET.get("issues", False)
@@ -88,27 +82,14 @@ class IssueListEndpoint(BaseAPIView):
issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""]
# Base queryset with basic filters
queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
# Apply filtering from filterset
queryset = self.filter_queryset(queryset)
# Apply legacy filters
filters = issue_filters(request.query_params, "GET")
issue_queryset = queryset.filter(**filters)
# Add select_related, prefetch_related if fields or expand is not None
if self.fields or self.expand:
issue_queryset = issue_queryset.select_related(
"workspace", "project", "state", "parent"
).prefetch_related("assignees", "labels", "issue_module__module")
# Add annotations
issue_queryset = (
issue_queryset.annotate(
queryset = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -136,10 +117,12 @@ class IssueListEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.distinct()
)
).distinct()
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = queryset.filter(**filters)
# Issue queryset
issue_queryset, _ = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
@@ -203,12 +186,6 @@ class IssueListEndpoint(BaseAPIView):
class IssueViewSet(BaseViewSet):
model = Issue
webhook_event = "issue"
search_fields = ["name"]
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
def get_serializer_class(self):
return (
IssueCreateSerializer
@@ -216,17 +193,20 @@ class IssueViewSet(BaseViewSet):
else IssueSerializer
)
model = Issue
webhook_event = "issue"
search_fields = ["name"]
filterset_fields = ["state__name", "assignees__id", "workspace__id"]
def get_queryset(self):
issues = Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
).distinct()
return issues
def apply_annotations(self, issues):
issues = (
issues.annotate(
return (
Issue.issue_objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -262,8 +242,6 @@ class IssueViewSet(BaseViewSet):
)
)
return issues
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
@@ -272,24 +250,15 @@ class IssueViewSet(BaseViewSet):
extra_filters = {"updated_at__gt": request.GET.get("updated_at__gt")}
project = Project.objects.get(pk=project_id, workspace__slug=slug)
query_params = request.query_params.copy()
filters = issue_filters(query_params, "GET")
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset()
issue_queryset = self.get_queryset().filter(**filters, **extra_filters)
# Custom ordering for priority and state
# Apply rich filters
issue_queryset = self.filter_queryset(issue_queryset)
# Apply legacy filters
issue_queryset = issue_queryset.filter(**filters, **extra_filters)
# Keeping a copy of the queryset before applying annotations
filtered_issue_queryset = copy.deepcopy(issue_queryset)
# Applying annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
total_issue_queryset = Issue.issue_objects.filter(
project_id=project_id, workspace__slug=slug
).filter(**filters, **extra_filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
@@ -323,16 +292,14 @@ class IssueViewSet(BaseViewSet):
and not project.guest_view_all_features
):
issue_queryset = issue_queryset.filter(created_by=request.user)
filtered_issue_queryset = filtered_issue_queryset.filter(
created_by=request.user
)
total_issue_queryset = total_issue_queryset.filter(created_by=request.user)
if group_by:
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters" # noqa: E501
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -341,7 +308,7 @@ class IssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=filtered_issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -351,14 +318,12 @@ class IssueViewSet(BaseViewSet):
slug=slug,
project_id=project_id,
filters=filters,
queryset=filtered_issue_queryset,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
queryset=filtered_issue_queryset,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
@@ -377,7 +342,7 @@ class IssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=filtered_issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -387,7 +352,6 @@ class IssueViewSet(BaseViewSet):
slug=slug,
project_id=project_id,
filters=filters,
queryset=filtered_issue_queryset,
),
group_by_field_name=group_by,
count_filter=Q(
@@ -404,7 +368,7 @@ class IssueViewSet(BaseViewSet):
order_by=order_by_param,
request=request,
queryset=issue_queryset,
total_count_queryset=filtered_issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -438,11 +402,9 @@ class IssueViewSet(BaseViewSet):
notification=True,
origin=base_host(request=request, is_app=True),
)
queryset = self.get_queryset()
queryset = self.apply_annotations(queryset)
issue = (
issue_queryset_grouper(
queryset=queryset.filter(pk=serializer.data["id"]),
queryset=self.get_queryset().filter(pk=serializer.data["id"]),
group_by=None,
sub_group_by=None,
)
@@ -647,10 +609,9 @@ class IssueViewSet(BaseViewSet):
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], creator=True, model=Issue
)
def partial_update(self, request, slug, project_id, pk=None):
queryset = self.get_queryset()
queryset = self.apply_annotations(queryset)
issue = (
queryset.annotate(
self.get_queryset()
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
@@ -769,9 +730,6 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
user=request.user, project_id=project_id
)
issue_property.rich_filters = request.data.get(
"rich_filters", issue_property.rich_filters
)
issue_property.filters = request.data.get("filters", issue_property.filters)
issue_property.display_filters = request.data.get(
"display_filters", issue_property.display_filters
@@ -1011,59 +969,6 @@ class IssuePaginatedViewSet(BaseViewSet):
class IssueDetailEndpoint(BaseAPIView):
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
def apply_annotations(self, issues):
return (
issues.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
.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")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
@@ -1097,9 +1002,56 @@ class IssueDetailEndpoint(BaseAPIView):
.values("id")
)
# Main issue query
issue = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
).filter(Exists(permission_subquery))
issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.filter(Exists(permission_subquery))
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
.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")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Add additional prefetch based on expand parameter
if self.expand:
@@ -1118,20 +1070,8 @@ class IssueDetailEndpoint(BaseAPIView):
)
)
# Apply filtering from filterset
issue = self.filter_queryset(issue)
# Apply legacy filters
issue = issue.filter(**filters)
# Total count queryset
total_issue_queryset = copy.deepcopy(issue)
# Applying annotations to the issue queryset
issue = self.apply_annotations(issue)
order_by_param = request.GET.get("order_by", "-created_at")
# Issue queryset
issue, order_by_param = order_issue_queryset(
issue_queryset=issue, order_by_param=order_by_param
@@ -1139,8 +1079,7 @@ class IssueDetailEndpoint(BaseAPIView):
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue,
total_count_queryset=total_issue_queryset,
queryset=(issue),
on_results=lambda issue: IssueListDetailSerializer(
issue, many=True, fields=self.fields, expand=self.expand
).data,
+4 -7
View File
@@ -232,6 +232,9 @@ class ModuleViewSet(BaseViewSet):
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
@@ -312,10 +315,7 @@ class ModuleViewSet(BaseViewSet):
ArrayAgg(
"members__id",
distinct=True,
filter=Q(
members__id__isnull=False,
modulemember__deleted_at__isnull=True,
),
filter=~Q(members__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
)
@@ -904,9 +904,6 @@ class ModuleUserPropertiesEndpoint(BaseAPIView):
module_properties.filters = request.data.get(
"filters", module_properties.filters
)
module_properties.rich_filters = request.data.get(
"rich_filters", module_properties.rich_filters
)
module_properties.display_filters = request.data.get(
"display_filters", module_properties.display_filters
)
+18 -43
View File
@@ -1,5 +1,4 @@
# Python imports
import copy
import json
from django.db.models import F, Func, OuterRef, Q, Subquery
@@ -32,8 +31,8 @@ from plane.utils.grouper import (
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.utils.filters import ComplexFilterBackend
from plane.utils.filters import IssueFilterSet
# Module imports
from .. import BaseViewSet
from plane.utils.host import base_host
@@ -43,12 +42,20 @@ class ModuleIssueViewSet(BaseViewSet):
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
def apply_annotations(self, issues):
filterset_fields = ["issue__labels__id", "issue__assignees__id"]
def get_queryset(self):
return (
issues.annotate(
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id"),
issue_module__deleted_at__isnull=True,
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -76,37 +83,13 @@ class ModuleIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related("assignees", "labels", "issue_module__module")
)
def get_queryset(self):
return (
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id"),
issue_module__deleted_at__isnull=True,
)
).distinct()
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def list(self, request, slug, project_id, module_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset()
# Apply filtering from filterset
issue_queryset = self.filter_queryset(issue_queryset)
# Apply legacy filters
issue_queryset = issue_queryset.filter(**filters)
# Total count queryset
total_issue_queryset = copy.deepcopy(issue_queryset)
# Apply annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
issue_queryset = self.get_queryset().filter(**filters)
order_by_param = request.GET.get("order_by", "created_at")
# Issue queryset
@@ -139,7 +122,6 @@ class ModuleIssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -149,14 +131,12 @@ class ModuleIssueViewSet(BaseViewSet):
slug=slug,
project_id=project_id,
filters=filters,
queryset=total_issue_queryset,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
queryset=total_issue_queryset,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
@@ -176,7 +156,6 @@ class ModuleIssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -186,7 +165,6 @@ class ModuleIssueViewSet(BaseViewSet):
slug=slug,
project_id=project_id,
filters=filters,
queryset=total_issue_queryset,
),
group_by_field_name=group_by,
count_filter=Q(
@@ -204,7 +182,6 @@ class ModuleIssueViewSet(BaseViewSet):
order_by=order_by_param,
request=request,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -305,11 +282,9 @@ class ModuleIssueViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=json.dumps(
{
"module_name": (
module_issue.first().module.name
if (module_issue.first() and module_issue.first().module)
else None
)
"module_name": module_issue.first().module.name
if (module_issue.first() and module_issue.first().module)
else None
}
),
epoch=int(timezone.now().timestamp()),
+114 -128
View File
@@ -6,17 +6,9 @@ from django.core.serializers.json import DjangoJSONEncoder
# Django imports
from django.db import connection
from django.db.models import (
Exists,
OuterRef,
Q,
Value,
UUIDField,
Count,
Case,
When,
IntegerField,
)
from django.db.models import Exists, OuterRef, Q, Value, UUIDField
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.http import StreamingHttpResponse
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
@@ -29,7 +21,9 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import (
PageLogSerializer,
PageSerializer,
SubPageSerializer,
PageDetailSerializer,
PageBinaryUpdateSerializer,
)
@@ -43,14 +37,11 @@ from plane.db.models import (
UserRecentVisit,
)
from plane.utils.error_codes import ERROR_CODES
# Local imports
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets
from plane.app.permissions import ProjectPagePermission
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -72,7 +63,6 @@ def unarchive_archive_page_and_descendants(page_id, archived_at):
class PageViewSet(BaseViewSet):
serializer_class = PageSerializer
model = Page
permission_classes = [ProjectPagePermission]
search_fields = ["name"]
def get_queryset(self):
@@ -127,6 +117,7 @@ class PageViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
serializer = PageSerializer(
data=request.data,
@@ -148,10 +139,11 @@ class PageViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, page_id):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(
pk=page_id, workspace__slug=slug, projects__id=project_id
pk=pk, workspace__slug=slug, projects__id=project_id
)
if page.is_locked:
@@ -189,7 +181,7 @@ class PageViewSet(BaseViewSet):
{"description_html": page_description},
cls=DjangoJSONEncoder,
),
page_id=page_id,
page_id=pk,
)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -202,10 +194,10 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, page_id=None):
page = self.get_queryset().filter(pk=page_id).first()
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk=None):
page = self.get_queryset().filter(pk=pk).first()
project = Project.objects.get(pk=project_id)
track_visit = request.query_params.get("track_visit", "true").lower() == "true"
"""
if the role is guest and guest_view_all_features is false and owned by is not
@@ -234,32 +226,33 @@ class PageViewSet(BaseViewSet):
)
else:
issue_ids = PageLog.objects.filter(
page_id=page_id, entity_name="issue"
page_id=pk, entity_name="issue"
).values_list("entity_identifier", flat=True)
data = PageDetailSerializer(page).data
data["issue_ids"] = issue_ids
if track_visit:
recent_visited_task.delay(
slug=slug,
entity_name="page",
entity_identifier=page_id,
user_id=request.user.id,
project_id=project_id,
)
recent_visited_task.delay(
slug=slug,
entity_name="page",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
return Response(data, status=status.HTTP_200_OK)
def lock(self, request, slug, project_id, page_id):
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def lock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
pk=pk, workspace__slug=slug, projects__id=project_id
).first()
page.is_locked = True
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def unlock(self, request, slug, project_id, page_id):
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
pk=pk, workspace__slug=slug, projects__id=project_id
).first()
page.is_locked = False
@@ -267,10 +260,11 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def access(self, request, slug, project_id, page_id):
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0)
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
pk=pk, workspace__slug=slug, projects__id=project_id
).first()
# Only update access if the page owner is the requesting user
@@ -289,6 +283,7 @@ class PageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
queryset = self.get_queryset()
project = Project.objects.get(pk=project_id)
@@ -306,10 +301,9 @@ class PageViewSet(BaseViewSet):
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
def archive(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id, workspace__slug=slug, projects__id=project_id
)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
# only the owner or admin can archive the page
if (
@@ -325,19 +319,18 @@ class PageViewSet(BaseViewSet):
UserFavorite.objects.filter(
entity_type="page",
entity_identifier=page_id,
entity_identifier=pk,
project_id=project_id,
workspace__slug=slug,
).delete()
unarchive_archive_page_and_descendants(page_id, datetime.now())
unarchive_archive_page_and_descendants(pk, datetime.now())
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
def unarchive(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id, workspace__slug=slug, projects__id=project_id
)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
# only the owner or admin can un archive the page
if (
@@ -351,19 +344,18 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
# if parent archived then page will be un archived breaking hierarchy
# if parent page is archived then the page will be un archived breaking the hierarchy
if page.parent_id and page.parent.archived_at:
page.parent = None
page.save(update_fields=["parent"])
unarchive_archive_page_and_descendants(page_id, None)
unarchive_archive_page_and_descendants(pk, None)
return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id, workspace__slug=slug, projects__id=project_id
)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
if page.archived_at is None:
return Response(
@@ -387,7 +379,7 @@ class PageViewSet(BaseViewSet):
# remove parent from all the children
_ = Page.objects.filter(
parent_id=page_id, projects__id=project_id, workspace__slug=slug
parent_id=pk, projects__id=project_id, workspace__slug=slug
).update(parent=None)
page.delete()
@@ -395,109 +387,105 @@ class PageViewSet(BaseViewSet):
UserFavorite.objects.filter(
project=project_id,
workspace__slug=slug,
entity_identifier=page_id,
entity_identifier=pk,
entity_type="page",
).delete()
# Delete the page from recent visit
UserRecentVisit.objects.filter(
project_id=project_id,
workspace__slug=slug,
entity_identifier=page_id,
entity_identifier=pk,
entity_name="page",
).delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
def summary(self, request, slug, project_id):
queryset = (
Page.objects.filter(workspace__slug=slug)
.filter(
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
projects__archived_at__isnull=True,
)
.filter(parent__isnull=True)
.filter(Q(owned_by=request.user) | Q(access=0))
.annotate(
project=Exists(
ProjectPage.objects.filter(
page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")
)
)
)
.filter(project=True)
.distinct()
)
project = Project.objects.get(pk=project_id)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
):
queryset = queryset.filter(owned_by=request.user)
stats = queryset.aggregate(
public_pages=Count(
Case(
When(access=Page.PUBLIC_ACCESS, archived_at__isnull=True, then=1),
output_field=IntegerField(),
)
),
private_pages=Count(
Case(
When(access=Page.PRIVATE_ACCESS, archived_at__isnull=True, then=1),
output_field=IntegerField(),
)
),
archived_pages=Count(
Case(
When(archived_at__isnull=False, then=1), output_field=IntegerField()
)
),
)
return Response(stats, status=status.HTTP_200_OK)
class PageFavoriteViewSet(BaseViewSet):
model = UserFavorite
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, page_id):
def create(self, request, slug, project_id, pk):
_ = UserFavorite.objects.create(
project_id=project_id,
entity_identifier=page_id,
entity_identifier=pk,
entity_type="page",
user=request.user,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, page_id):
def destroy(self, request, slug, project_id, pk):
page_favorite = UserFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
entity_identifier=page_id,
entity_identifier=pk,
entity_type="page",
)
page_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
class PagesDescriptionViewSet(BaseViewSet):
permission_classes = [ProjectPagePermission]
class PageLogEndpoint(BaseAPIView):
serializer_class = PageLogSerializer
model = PageLog
def retrieve(self, request, slug, project_id, page_id):
page = (
Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
def post(self, request, slug, project_id, page_id):
serializer = PageLogSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, page_id=page_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, project_id, page_id, transaction):
page_transaction = PageLog.objects.get(
workspace__slug=slug,
project_id=project_id,
page_id=page_id,
transaction=transaction,
)
serializer = PageLogSerializer(
page_transaction, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, page_id, transaction):
transaction = PageLog.objects.get(
workspace__slug=slug,
project_id=project_id,
page_id=page_id,
transaction=transaction,
)
# Delete the transaction object
transaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class SubPagesEndpoint(BaseAPIView):
@method_decorator(gzip_page)
def get(self, request, slug, project_id, page_id):
pages = (
PageLog.objects.filter(
page_id=page_id,
workspace__slug=slug,
entity_name__in=["forward_link", "back_link"],
)
.select_related("project")
.select_related("workspace")
)
return Response(
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
)
class PagesDescriptionViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk):
page = (
Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
)
@@ -517,11 +505,10 @@ class PagesDescriptionViewSet(BaseViewSet):
response["Content-Disposition"] = 'attachment; filename="page_description.bin"'
return response
def partial_update(self, request, slug, project_id, page_id):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
page = (
Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
)
Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
)
@@ -558,7 +545,7 @@ class PagesDescriptionViewSet(BaseViewSet):
# Capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=page_id
new_value=request.data, old_value=existing_instance, page_id=pk
)
# Update the page using serializer
@@ -576,8 +563,7 @@ class PagesDescriptionViewSet(BaseViewSet):
class PageDuplicateEndpoint(BaseAPIView):
permission_classes = [ProjectPagePermission]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
+2 -4
View File
@@ -7,12 +7,10 @@ from plane.db.models import PageVersion
from ..base import BaseAPIView
from plane.app.serializers import PageVersionSerializer, PageVersionDetailSerializer
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ProjectPagePermission
class PageVersionEndpoint(BaseAPIView):
permission_classes = [ProjectPagePermission]
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
+13 -39
View File
@@ -5,12 +5,13 @@ from django.utils import timezone
import json
# Django imports
from django.db import IntegrityError
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework import serializers, status
from rest_framework.permissions import AllowAny
# Module imports
@@ -105,10 +106,7 @@ class ProjectViewSet(BaseViewSet):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
projects = self.get_queryset().order_by("sort_order", "name")
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.GUEST.value,
member=request.user, workspace__slug=slug, is_active=True, role=5
).exists():
projects = projects.filter(
project_projectmember__member=self.request.user,
@@ -116,10 +114,7 @@ class ProjectViewSet(BaseViewSet):
)
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.MEMBER.value,
member=request.user, workspace__slug=slug, is_active=True, role=15
).exists():
projects = projects.filter(
Q(
@@ -194,10 +189,7 @@ class ProjectViewSet(BaseViewSet):
)
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.GUEST.value,
member=request.user, workspace__slug=slug, is_active=True, role=5
).exists():
projects = projects.filter(
project_projectmember__member=self.request.user,
@@ -205,10 +197,7 @@ class ProjectViewSet(BaseViewSet):
)
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.MEMBER.value,
member=request.user, workspace__slug=slug, is_active=True, role=15
).exists():
projects = projects.filter(
Q(
@@ -261,9 +250,7 @@ class ProjectViewSet(BaseViewSet):
# Add the user as Administrator to the project
_ = ProjectMember.objects.create(
project_id=serializer.data["id"],
member=request.user,
role=ROLE.ADMIN.value,
project_id=serializer.data["id"], member=request.user, role=20
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(
@@ -276,7 +263,7 @@ class ProjectViewSet(BaseViewSet):
ProjectMember.objects.create(
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
role=ROLE.ADMIN.value,
role=20,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
@@ -354,23 +341,13 @@ class ProjectViewSet(BaseViewSet):
def partial_update(self, request, slug, pk=None):
# try:
is_workspace_admin = WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.ADMIN.value,
).exists()
is_project_admin = ProjectMember.objects.filter(
if not ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=ROLE.ADMIN.value,
role=20,
is_active=True,
).exists()
# Return error for if the user is neither workspace admin nor project admin
if not is_project_admin and not is_workspace_admin:
).exists():
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
@@ -425,16 +402,13 @@ class ProjectViewSet(BaseViewSet):
def destroy(self, request, slug, pk):
if (
WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.ADMIN.value,
member=request.user, workspace__slug=slug, is_active=True, role=20
).exists()
or ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=ROLE.ADMIN.value,
role=20,
is_active=True,
).exists()
):
+40 -45
View File
@@ -1,5 +1,3 @@
import copy
# Django imports
from django.db.models import (
Exists,
@@ -41,8 +39,6 @@ from plane.utils.order_queryset import order_issue_queryset
from plane.bgtasks.recent_visited_task import recent_visited_task
from .. import BaseViewSet
from plane.db.models import UserFavorite
from plane.utils.filters import ComplexFilterBackend
from plane.utils.filters import IssueFilterSet
class WorkspaceViewViewSet(BaseViewSet):
@@ -60,6 +56,7 @@ class WorkspaceViewViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project__isnull=True)
.filter(Q(owned_by=self.request.user) | Q(access=1))
.select_related("workspace")
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
@@ -148,9 +145,6 @@ class WorkspaceViewViewSet(BaseViewSet):
class WorkspaceViewIssuesViewSet(BaseViewSet):
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
def _get_project_permission_filters(self):
"""
Get common project permission filters for guest users and role-based access control.
@@ -173,9 +167,35 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
project__project_projectmember__is_active=True,
)
def apply_annotations(self, issues):
def get_queryset(self):
return (
issues.annotate(
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("state")
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -203,56 +223,31 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
)
def get_queryset(self):
return Issue.issue_objects.filter(workspace__slug=self.kwargs.get("slug"))
@method_decorator(gzip_page)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def list(self, request, slug):
issue_queryset = self.get_queryset()
# Apply filtering from filterset
issue_queryset = self.filter_queryset(issue_queryset)
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
# Apply legacy filters
filters = issue_filters(request.query_params, "GET")
issue_queryset = issue_queryset.filter(**filters)
issue_queryset = self.get_queryset().filter(**filters)
# Get common project permission filters
permission_filters = self._get_project_permission_filters()
# Apply project permission filters to the issue queryset
issue_queryset = issue_queryset.filter(permission_filters)
# Base query for the counts
total_issue_count_queryset = copy.deepcopy(issue_queryset)
total_issue_count_queryset = total_issue_count_queryset.only("id")
total_issue_count = (
Issue.issue_objects.filter(**filters)
.filter(workspace__slug=slug)
.filter(permission_filters)
.only("id")
)
# Apply annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
# Apply project permission filters to the issue queryset
issue_queryset = issue_queryset.filter(permission_filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
@@ -265,7 +260,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
request=request,
queryset=issue_queryset,
on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
total_count_queryset=total_issue_count_queryset,
total_count_queryset=total_issue_count,
)
+1 -3
View File
@@ -172,14 +172,12 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
{"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND
)
project_id = request.data.get("project_id", issue.project_id)
serializer = DraftIssueCreateSerializer(
issue,
data=request.data,
partial=True,
context={
"project_id": project_id,
"project_id": request.data.get("project_id", None),
"cycle_id": request.data.get("cycle_id", "not_provided"),
},
)
@@ -7,6 +7,7 @@ from plane.app.serializers import StateSerializer
from plane.app.views.base import BaseAPIView
from plane.db.models import State
from plane.app.permissions import WorkspaceEntityPermission
from plane.utils.cache import cache_response
from collections import defaultdict
@@ -14,6 +15,7 @@ class WorkspaceStatesEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
use_read_replica = True
@cache_response(60 * 60 * 2)
def get(self, request, slug):
states = State.objects.filter(
workspace__slug=slug,
+22 -57
View File
@@ -1,5 +1,4 @@
# Python imports
import copy
from datetime import date
from dateutil.relativedelta import relativedelta
@@ -57,8 +56,6 @@ from plane.utils.grouper import (
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.utils.filters import ComplexFilterBackend
from plane.utils.filters import IssueFilterSet
class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
@@ -94,12 +91,23 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
def get(self, request, slug, user_id):
filters = issue_filters(request.query_params, "GET")
def apply_annotations(self, issues):
return (
issues.annotate(
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.filter(
Q(assignees__in=[user_id])
| Q(created_by_id=user_id)
| Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -127,36 +135,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related("assignees", "labels", "issue_module__module")
)
def get(self, request, slug, user_id):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = Issue.issue_objects.filter(
id__in=Issue.issue_objects.filter(
Q(assignees__in=[user_id])
| Q(created_by_id=user_id)
| Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug,
).values_list("id", flat=True),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
# Apply filtering from filterset
issue_queryset = self.filter_queryset(issue_queryset)
# Apply legacy filters
issue_queryset = issue_queryset.filter(**filters)
# Total count queryset
total_issue_queryset = copy.deepcopy(issue_queryset)
# Apply annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
.order_by("created_at")
).distinct()
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
@@ -177,7 +157,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters" # noqa: E501
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -186,22 +166,15 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
filters=filters,
queryset=total_issue_queryset,
field=group_by, slug=slug, filters=filters
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
filters=filters,
queryset=total_issue_queryset,
field=sub_group_by, slug=slug, filters=filters
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
@@ -220,16 +193,12 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
filters=filters,
queryset=total_issue_queryset,
field=group_by, slug=slug, filters=filters
),
group_by_field_name=group_by,
count_filter=Q(
@@ -246,7 +215,6 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
order_by=order_by_param,
request=request,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -264,9 +232,6 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
workspace_properties.filters = request.data.get(
"filters", workspace_properties.filters
)
workspace_properties.rich_filters = request.data.get(
"rich_filters", workspace_properties.rich_filters
)
workspace_properties.display_filters = request.data.get(
"display_filters", workspace_properties.display_filters
)
@@ -1,3 +1,6 @@
# Python imports
from urllib.parse import urlencode, urljoin
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
@@ -16,7 +19,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import get_safe_redirect_url
from plane.utils.path_validator import validate_next_path
class SignInAuthEndpoint(View):
@@ -31,11 +34,11 @@ class SignInAuthEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
# Base URL join
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -55,10 +58,10 @@ class SignInAuthEndpoint(View):
)
params = exc.get_error_dict()
# Next path
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -73,10 +76,10 @@ class SignInAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -89,10 +92,10 @@ class SignInAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -109,23 +112,19 @@ class SignInAuthEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
path = str(validate_next_path(next_path))
else:
path = get_redirection_path(user=user)
# Get the safe redirect URL
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
# redirect to referer path
url = urljoin(base_host(request=request, is_app=True), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -142,10 +141,10 @@ class SignUpAuthEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -162,10 +161,10 @@ class SignUpAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
# Validate the email
@@ -180,10 +179,10 @@ class SignUpAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -198,10 +197,10 @@ class SignUpAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -218,21 +217,17 @@ class SignUpAuthEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
path = str(validate_next_path(next_path))
else:
path = get_redirection_path(user=user)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
# redirect to referer path
url = urljoin(base_host(request=request, is_app=True), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -1,5 +1,5 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
@@ -16,7 +16,8 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import get_safe_redirect_url
from plane.utils.path_validator import validate_next_path
class GitHubOauthInitiateEndpoint(View):
def get(self, request):
@@ -34,10 +35,10 @@ class GitHubOauthInitiateEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
try:
@@ -48,10 +49,10 @@ class GitHubOauthInitiateEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -60,6 +61,7 @@ class GitHubCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
@@ -68,11 +70,9 @@ class GitHubCallbackEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
if not code:
@@ -81,11 +81,9 @@ class GitHubCallbackEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
try:
@@ -95,23 +93,17 @@ class GitHubCallbackEndpoint(View):
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
path = str(validate_next_path(next_path))
else:
path = get_redirection_path(user=user)
# Get the safe redirect URL
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={}
)
# redirect to referer path
url = urljoin(base_host, path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -1,5 +1,5 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
@@ -16,7 +16,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import get_safe_redirect_url
from plane.utils.path_validator import validate_next_path
class GitLabOauthInitiateEndpoint(View):
@@ -25,7 +25,7 @@ class GitLabOauthInitiateEndpoint(View):
request.session["host"] = base_host(request=request, is_app=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
request.session["next_path"] = str(validate_next_path(next_path))
# Check instance configuration
instance = Instance.objects.first()
@@ -35,10 +35,10 @@ class GitLabOauthInitiateEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
try:
@@ -49,10 +49,10 @@ class GitLabOauthInitiateEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -61,6 +61,7 @@ class GitLabCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
@@ -69,11 +70,9 @@ class GitLabCallbackEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(next_path)
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
if not code:
@@ -82,11 +81,9 @@ class GitLabCallbackEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
try:
@@ -97,23 +94,16 @@ class GitLabCallbackEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
path = str(validate_next_path(next_path))
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={}
)
url = urljoin(base_host, path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -1,5 +1,6 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
@@ -17,7 +18,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import get_safe_redirect_url
from plane.utils.path_validator import validate_next_path
class GoogleOauthInitiateEndpoint(View):
@@ -35,10 +36,10 @@ class GoogleOauthInitiateEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -50,10 +51,10 @@ class GoogleOauthInitiateEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -62,6 +63,7 @@ class GoogleCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
@@ -70,11 +72,9 @@ class GoogleCallbackEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
@@ -82,11 +82,9 @@ class GoogleCallbackEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
try:
provider = GoogleOAuthProvider(
@@ -96,21 +94,15 @@ class GoogleCallbackEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
else:
path = get_redirection_path(user=user)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={}
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(
base_host, str(validate_next_path(next_path)) if next_path else path
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -1,3 +1,6 @@
# Python imports
from urllib.parse import urlencode, urljoin
# Django imports
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
@@ -23,7 +26,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
)
from plane.authentication.rate_limit import AuthenticationThrottle
from plane.utils.path_validator import get_safe_redirect_url
from plane.utils.path_validator import validate_next_path
class MagicGenerateEndpoint(APIView):
@@ -69,10 +72,10 @@ class MagicSignInEndpoint(View):
error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -85,10 +88,10 @@ class MagicSignInEndpoint(View):
error_message="USER_DOES_NOT_EXIST",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -104,8 +107,7 @@ class MagicSignInEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
if user.is_password_autoset and profile.is_onboarded:
# Redirect to the home page
path = "/"
path = "accounts/set-password"
else:
# Get the redirection path
path = (
@@ -114,19 +116,15 @@ class MagicSignInEndpoint(View):
else str(get_redirection_path(user=user))
)
# redirect to referer path
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
url = urljoin(base_host(request=request, is_app=True), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -146,10 +144,10 @@ class MagicSignUpEndpoint(View):
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
# Existing user
@@ -160,10 +158,10 @@ class MagicSignUpEndpoint(View):
error_message="USER_ALREADY_EXIST",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -179,22 +177,18 @@ class MagicSignUpEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
path = str(validate_next_path(next_path))
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
url = urljoin(base_host(request=request, is_app=True), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
@@ -1,9 +1,11 @@
# Python imports
from urllib.parse import urlencode
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
from django.views import View
from django.utils.http import url_has_allowed_host_and_scheme
# Module imports
from plane.authentication.provider.credentials.email import EmailProvider
@@ -15,7 +17,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
from plane.utils.path_validator import validate_next_path
class SignInAuthSpaceEndpoint(View):
@@ -30,11 +32,9 @@ class SignInAuthSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# set the referer as session to redirect after login
@@ -51,11 +51,9 @@ class SignInAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# Validate email
@@ -69,11 +67,9 @@ class SignInAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# Existing User
@@ -86,11 +82,9 @@ class SignInAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -100,20 +94,14 @@ class SignInAuthSpaceEndpoint(View):
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
# redirect to next path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -129,11 +117,9 @@ class SignUpAuthSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
email = request.POST.get("email", False)
@@ -149,11 +135,9 @@ class SignUpAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# Validate the email
email = email.strip().lower()
@@ -167,11 +151,9 @@ class SignUpAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# Existing User
@@ -184,11 +166,9 @@ class SignUpAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -199,17 +179,11 @@ class SignUpAuthSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -1,10 +1,10 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
from django.views import View
from django.utils.http import url_has_allowed_host_and_scheme
# Module imports
from plane.authentication.provider.oauth.github import GitHubOAuthProvider
@@ -15,7 +15,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
from plane.utils.path_validator import validate_next_path
class GitHubOauthInitiateSpaceEndpoint(View):
@@ -23,6 +23,9 @@ class GitHubOauthInitiateSpaceEndpoint(View):
# Get host and next path
request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
@@ -31,11 +34,9 @@ class GitHubOauthInitiateSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -46,11 +47,9 @@ class GitHubOauthInitiateSpaceEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -67,11 +66,9 @@ class GitHubCallbackSpaceEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
if not code:
@@ -80,11 +77,9 @@ class GitHubCallbackSpaceEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -94,18 +89,11 @@ class GitHubCallbackSpaceEndpoint(View):
user_login(request=request, user=user, is_space=True)
# Process workspace and project invitations
# redirect to referer path
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -1,10 +1,10 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
from django.views import View
from django.utils.http import url_has_allowed_host_and_scheme
# Module imports
from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
@@ -15,8 +15,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
from plane.utils.path_validator import validate_next_path
class GitLabOauthInitiateSpaceEndpoint(View):
@@ -24,6 +23,8 @@ class GitLabOauthInitiateSpaceEndpoint(View):
# Get host and next path
request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
@@ -33,11 +34,9 @@ class GitLabOauthInitiateSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -48,11 +47,9 @@ class GitLabOauthInitiateSpaceEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -69,11 +66,9 @@ class GitLabCallbackSpaceEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
if not code:
@@ -82,11 +77,9 @@ class GitLabCallbackSpaceEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -96,18 +89,11 @@ class GitLabCallbackSpaceEndpoint(View):
user_login(request=request, user=user, is_space=True)
# Process workspace and project invitations
# redirect to referer path
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -1,10 +1,10 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
from django.views import View
from django.utils.http import url_has_allowed_host_and_scheme
# Module imports
from plane.authentication.provider.oauth.google import GoogleOAuthProvider
@@ -15,13 +15,15 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
from plane.utils.path_validator import validate_next_path
class GoogleOauthInitiateSpaceEndpoint(View):
def get(self, request):
request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
@@ -31,11 +33,9 @@ class GoogleOauthInitiateSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -46,11 +46,9 @@ class GoogleOauthInitiateSpaceEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -67,11 +65,9 @@ class GoogleCallbackSpaceEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
@@ -79,11 +75,9 @@ class GoogleCallbackSpaceEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
provider = GoogleOAuthProvider(request=request, code=code)
@@ -91,18 +85,11 @@ class GoogleCallbackSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -1,8 +1,10 @@
# Python imports
from urllib.parse import urlencode
# Django imports
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
from django.views import View
from django.utils.http import url_has_allowed_host_and_scheme
# Third party imports
from rest_framework import status
@@ -21,7 +23,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
from plane.utils.path_validator import validate_next_path
class MagicGenerateSpaceEndpoint(APIView):
@@ -64,11 +66,9 @@ class MagicSignInSpaceEndpoint(View):
error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
existing_user = User.objects.filter(email=email).first()
@@ -79,11 +79,9 @@ class MagicSignInSpaceEndpoint(View):
error_message="USER_DOES_NOT_EXIST",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# Active User
@@ -95,20 +93,15 @@ class MagicSignInSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
path = str(next_path) if next_path else ""
url = f"{base_host(request=request, is_space=True)}{path}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -127,11 +120,9 @@ class MagicSignUpSpaceEndpoint(View):
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# Existing User
existing_user = User.objects.filter(email=email).first()
@@ -142,11 +133,9 @@ class MagicSignUpSpaceEndpoint(View):
error_message="USER_ALREADY_EXIST",
)
params = exc.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -157,18 +146,12 @@ class MagicSignUpSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -7,7 +7,7 @@ from django.utils import timezone
# Module imports
from plane.authentication.utils.host import base_host, user_ip
from plane.db.models import User
from plane.utils.path_validator import get_safe_redirect_url
from plane.utils.path_validator import validate_next_path
class SignOutAuthSpaceEndpoint(View):
@@ -22,14 +22,8 @@ class SignOutAuthSpaceEndpoint(View):
user.save()
# Log the user out
logout(request)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path
)
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
return HttpResponseRedirect(url)
except Exception:
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path
)
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
return HttpResponseRedirect(url)
+26 -87
View File
@@ -21,14 +21,13 @@ from plane.db.models import (
PageVersion,
APIActivityLog,
IssueDescriptionVersion,
WebhookLog,
)
from plane.settings.mongo import MongoConnection
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
BATCH_SIZE = 500
BATCH_SIZE = 1000
def get_mongo_collection(collection_name: str) -> Optional[Collection]:
@@ -68,7 +67,7 @@ def flush_to_mongo_and_delete(
mongo_archival_failed = False
# Try to insert into MongoDB if available
if mongo_collection is not None and mongo_available:
if mongo_collection and mongo_available:
try:
mongo_collection.bulk_write([InsertOne(doc) for doc in buffer])
except BulkWriteError as bwe:
@@ -167,9 +166,9 @@ def process_cleanup_task(
def transform_api_log(record: Dict) -> Dict:
"""Transform API activity log record."""
return {
"id": str(record["id"]),
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"token_identifier": str(record["token_identifier"]),
"token_identifier": record["token_identifier"],
"path": record["path"],
"method": record["method"],
"query_params": record.get("query_params"),
@@ -179,18 +178,18 @@ def transform_api_log(record: Dict) -> Dict:
"response_body": record["response_body"],
"ip_address": record["ip_address"],
"user_agent": record["user_agent"],
"created_by_id": str(record["created_by_id"]),
"created_by_id": record["created_by_id"],
}
def transform_email_log(record: Dict) -> Dict:
"""Transform email notification log record."""
return {
"id": str(record["id"]),
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"receiver_id": str(record["receiver_id"]),
"triggered_by_id": str(record["triggered_by_id"]),
"entity_identifier": str(record["entity_identifier"]),
"receiver_id": record["receiver_id"],
"triggered_by_id": record["triggered_by_id"],
"entity_identifier": record["entity_identifier"],
"entity_name": record["entity_name"],
"data": record["data"],
"processed_at": (
@@ -198,27 +197,27 @@ def transform_email_log(record: Dict) -> Dict:
),
"sent_at": str(record["sent_at"]) if record.get("sent_at") else None,
"entity": record["entity"],
"old_value": str(record["old_value"]),
"new_value": str(record["new_value"]),
"created_by_id": str(record["created_by_id"]),
"old_value": record["old_value"],
"new_value": record["new_value"],
"created_by_id": record["created_by_id"],
}
def transform_page_version(record: Dict) -> Dict:
"""Transform page version record."""
return {
"id": str(record["id"]),
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"page_id": str(record["page_id"]),
"workspace_id": str(record["workspace_id"]),
"owned_by_id": str(record["owned_by_id"]),
"page_id": record["page_id"],
"workspace_id": record["workspace_id"],
"owned_by_id": record["owned_by_id"],
"description_html": record["description_html"],
"description_binary": record["description_binary"],
"description_stripped": record["description_stripped"],
"description_json": record["description_json"],
"sub_pages_data": record["sub_pages_data"],
"created_by_id": str(record["created_by_id"]),
"updated_by_id": str(record["updated_by_id"]),
"created_by_id": record["created_by_id"],
"updated_by_id": record["updated_by_id"],
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
"last_saved_at": (
str(record["last_saved_at"]) if record.get("last_saved_at") else None
@@ -229,14 +228,14 @@ def transform_page_version(record: Dict) -> Dict:
def transform_issue_description_version(record: Dict) -> Dict:
"""Transform issue description version record."""
return {
"id": str(record["id"]),
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"issue_id": str(record["issue_id"]),
"workspace_id": str(record["workspace_id"]),
"project_id": str(record["project_id"]),
"created_by_id": str(record["created_by_id"]),
"updated_by_id": str(record["updated_by_id"]),
"owned_by_id": str(record["owned_by_id"]),
"issue_id": record["issue_id"],
"workspace_id": record["workspace_id"],
"project_id": record["project_id"],
"created_by_id": record["created_by_id"],
"updated_by_id": record["updated_by_id"],
"owned_by_id": record["owned_by_id"],
"last_saved_at": (
str(record["last_saved_at"]) if record.get("last_saved_at") else None
),
@@ -248,27 +247,6 @@ def transform_issue_description_version(record: Dict) -> Dict:
}
def transform_webhook_log(record: Dict):
"""Transfer webhook logs to a new destination."""
return {
"id": str(record["id"]),
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"workspace_id": str(record["workspace_id"]),
"webhook": str(record["webhook"]),
# Request
"event_type": str(record["event_type"]),
"request_method": str(record["request_method"]),
"request_headers": str(record["request_headers"]),
"request_body": str(record["request_body"]),
# Response
"response_status": str(record["response_status"]),
"response_body": str(record["response_body"]),
"response_headers": str(record["response_headers"]),
# retry count
"retry_count": str(record["retry_count"]),
}
# Queryset functions for each cleanup task
def get_api_logs_queryset():
"""Get API logs older than cutoff days."""
@@ -396,34 +374,7 @@ def get_issue_description_versions_queryset():
)
def get_webhook_logs_queryset():
"""Get email logs older than cutoff days."""
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
logger.info(f"Webhook logs cutoff time: {cutoff_time}")
return (
WebhookLog.all_objects.filter(created_at__lte=cutoff_time)
.values(
"id",
"created_at",
"workspace_id",
"webhook",
"event_type",
# Request
"request_method",
"request_headers",
"request_body",
# Response
"response_status",
"response_body",
"response_headers",
"retry_count",
)
.iterator(chunk_size=BATCH_SIZE)
)
# Celery tasks - now much simpler!
@shared_task
def delete_api_logs():
"""Delete old API activity logs."""
@@ -470,15 +421,3 @@ def delete_issue_description_versions():
task_name="Issue Description Version",
collection_name="issue_description_versions",
)
@shared_task
def delete_webhook_logs():
"""Delete old webhook logs"""
process_cleanup_task(
queryset_func=get_webhook_logs_queryset,
transform_func=transform_webhook_log,
model=WebhookLog,
task_name="Webhook Log",
collection_name="webhook_logs",
)
+24 -54
View File
@@ -34,14 +34,6 @@ from plane.utils.issue_relation_mapper import get_inverse_relation
from plane.utils.uuid import is_valid_uuid
def extract_ids(data: dict | None, primary_key: str, fallback_key: str) -> set[str]:
if not data:
return set()
if primary_key in data:
return {str(x) for x in data.get(primary_key, [])}
return {str(x) for x in data.get(fallback_key, [])}
# Track Changes in name
def track_name(
requested_data,
@@ -124,32 +116,18 @@ def track_parent(
issue_activities,
epoch,
):
current_parent_id = current_instance.get("parent_id") or current_instance.get(
"parent"
)
requested_parent_id = requested_data.get("parent_id") or requested_data.get(
"parent"
)
# Validate UUIDs before database queries
if current_parent_id is not None and not is_valid_uuid(current_parent_id):
return
if requested_parent_id is not None and not is_valid_uuid(requested_parent_id):
return
if current_parent_id != requested_parent_id:
if current_instance.get("parent_id") != requested_data.get("parent_id"):
old_parent = (
Issue.objects.filter(pk=current_parent_id).first()
if current_parent_id is not None
Issue.objects.filter(pk=current_instance.get("parent_id")).first()
if current_instance.get("parent_id") is not None
else None
)
new_parent = (
Issue.objects.filter(pk=requested_parent_id).first()
if requested_parent_id is not None
Issue.objects.filter(pk=requested_data.get("parent_id")).first()
if requested_data.get("parent_id") is not None
else None
)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
@@ -215,31 +193,23 @@ def track_state(
issue_activities,
epoch,
):
current_state_id = current_instance.get("state_id") or current_instance.get("state")
requested_state_id = requested_data.get("state_id") or requested_data.get("state")
if current_state_id is not None and not is_valid_uuid(current_state_id):
current_state_id = None
if requested_state_id is not None and not is_valid_uuid(requested_state_id):
requested_state_id = None
if current_state_id != requested_state_id:
new_state = State.objects.filter(pk=requested_state_id, project_id=project_id).first()
old_state = State.objects.filter(pk=current_state_id, project_id=project_id).first()
if current_instance.get("state_id") != requested_data.get("state_id"):
new_state = State.objects.get(pk=requested_data.get("state_id", None))
old_state = State.objects.get(pk=current_instance.get("state_id", None))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="updated",
old_value=old_state.name if old_state else None,
new_value=new_state.name if new_state else None,
old_value=old_state.name,
new_value=new_state.name,
field="state",
project_id=project_id,
workspace_id=workspace_id,
comment="updated the state to",
old_identifier=old_state.id if old_state else None,
new_identifier=new_state.id if new_state else None,
old_identifier=old_state.id,
new_identifier=new_state.id,
epoch=epoch,
)
)
@@ -328,10 +298,8 @@ def track_labels(
issue_activities,
epoch,
):
# Labels
requested_labels = extract_ids(requested_data, "label_ids", "labels")
current_labels = extract_ids(current_instance, "label_ids", "labels")
requested_labels = set([str(lab) for lab in requested_data.get("label_ids", [])])
current_labels = set([str(lab) for lab in current_instance.get("label_ids", [])])
added_labels = requested_labels - current_labels
dropped_labels = current_labels - requested_labels
@@ -396,9 +364,16 @@ def track_assignees(
issue_activities,
epoch,
):
# Assignees
requested_assignees = extract_ids(requested_data, "assignee_ids", "assignees")
current_assignees = extract_ids(current_instance, "assignee_ids", "assignees")
requested_assignees = (
set([str(asg) for asg in requested_data.get("assignee_ids", [])])
if requested_data is not None
else set()
)
current_assignees = (
set([str(asg) for asg in current_instance.get("assignee_ids", [])])
if current_instance is not None
else set()
)
added_assignees = requested_assignees - current_assignees
dropped_assginees = current_assignees - requested_assignees
@@ -656,11 +631,6 @@ def update_issue_activity(
"estimate_point": track_estimate_points,
"archived_at": track_archive_at,
"closed_to": track_closed_to,
# External endpoint keys
"parent": track_parent,
"state": track_state,
"assignees": track_assignees,
"labels": track_labels,
}
requested_data = json.loads(requested_data) if requested_data is not None else None
@@ -30,8 +30,6 @@ def page_version(page_id, existing_instance, user_id):
description_binary=page.description_binary,
owned_by_id=user_id,
last_saved_at=page.updated_at,
description_json=page.description,
description_stripped=page.description_stripped,
)
# If page versions are greater than 20 delete the oldest one
@@ -92,10 +92,6 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
name=workspace.name, # Use workspace name
identifier=project_identifier,
created_by_id=workspace.created_by_id,
# Enable all views in seed data
cycle_view=True,
module_view=True,
issue_views_view=True,
)
# Create project members
+3 -11
View File
@@ -55,23 +55,15 @@ app.conf.beat_schedule = {
},
"check-every-day-to-delete-email-notification-logs": {
"task": "plane.bgtasks.cleanup_task.delete_email_notification_logs",
"schedule": crontab(hour=2, minute=45), # UTC 02:45
"schedule": crontab(hour=3, minute=0), # UTC 03:00
},
"check-every-day-to-delete-page-versions": {
"task": "plane.bgtasks.cleanup_task.delete_page_versions",
"schedule": crontab(hour=3, minute=0), # UTC 03:00
"schedule": crontab(hour=3, minute=30), # UTC 03:30
},
"check-every-day-to-delete-issue-description-versions": {
"task": "plane.bgtasks.cleanup_task.delete_issue_description_versions",
"schedule": crontab(hour=3, minute=15), # UTC 03:15
},
"check-every-day-to-delete-webhook-logs": {
"task": "plane.bgtasks.cleanup_task.delete_webhook_logs",
"schedule": crontab(hour=3, minute=30), # UTC 03:30
},
"check-every-day-to-delete-exporter-history": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=3, minute=45), # UTC 03:45
"schedule": crontab(hour=4, minute=0), # UTC 04:00
},
}
@@ -1,30 +0,0 @@
# Generated by Django 4.2.22 on 2025-08-29 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0101_description_descriptionversion"),
]
operations = [
migrations.AddField(
model_name="page",
name="sort_order",
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name="pagelog",
name="entity_type",
field=models.CharField(
blank=True, max_length=30, null=True, verbose_name="Entity Type"
),
),
migrations.AlterField(
model_name="pagelog",
name="entity_identifier",
field=models.UUIDField(blank=True, null=True),
),
]
@@ -1,75 +0,0 @@
# Generated by Django 4.2.22 on 2025-09-01 14:33
from django.db import migrations, models
from django.contrib.postgres.operations import AddIndexConcurrently
class Migration(migrations.Migration):
atomic = False
dependencies = [
('db', '0102_page_sort_order_pagelog_entity_type_and_more'),
]
operations = [
AddIndexConcurrently(
model_name='fileasset',
index=models.Index(fields=['entity_type'], name='asset_entity_type_idx'),
),
AddIndexConcurrently(
model_name='fileasset',
index=models.Index(fields=['entity_identifier'], name='asset_entity_identifier_idx'),
),
AddIndexConcurrently(
model_name='fileasset',
index=models.Index(fields=['entity_type', 'entity_identifier'], name='asset_entity_idx'),
),
AddIndexConcurrently(
model_name='notification',
index=models.Index(fields=['entity_identifier'], name='notif_entity_identifier_idx'),
),
AddIndexConcurrently(
model_name='notification',
index=models.Index(fields=['entity_name'], name='notif_entity_name_idx'),
),
AddIndexConcurrently(
model_name='notification',
index=models.Index(fields=['read_at'], name='notif_read_at_idx'),
),
AddIndexConcurrently(
model_name='notification',
index=models.Index(fields=['receiver', 'read_at'], name='notif_entity_idx'),
),
AddIndexConcurrently(
model_name='pagelog',
index=models.Index(fields=['entity_type'], name='pagelog_entity_type_idx'),
),
AddIndexConcurrently(
model_name='pagelog',
index=models.Index(fields=['entity_identifier'], name='pagelog_entity_id_idx'),
),
AddIndexConcurrently(
model_name='pagelog',
index=models.Index(fields=['entity_name'], name='pagelog_entity_name_idx'),
),
AddIndexConcurrently(
model_name='pagelog',
index=models.Index(fields=['entity_type', 'entity_identifier'], name='pagelog_type_id_idx'),
),
AddIndexConcurrently(
model_name='pagelog',
index=models.Index(fields=['entity_name', 'entity_identifier'], name='pagelog_name_id_idx'),
),
AddIndexConcurrently(
model_name='userfavorite',
index=models.Index(fields=['entity_type'], name='fav_entity_type_idx'),
),
AddIndexConcurrently(
model_name='userfavorite',
index=models.Index(fields=['entity_identifier'], name='fav_entity_identifier_idx'),
),
AddIndexConcurrently(
model_name='userfavorite',
index=models.Index(fields=['entity_type', 'entity_identifier'], name='fav_entity_idx'),
),
]
@@ -1,43 +0,0 @@
# Generated by Django 4.2.22 on 2025-09-03 05:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0103_fileasset_asset_entity_type_idx_and_more'),
]
operations = [
migrations.AddField(
model_name='cycleuserproperties',
name='rich_filters',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='exporterhistory',
name='rich_filters',
field=models.JSONField(blank=True, default=dict, null=True),
),
migrations.AddField(
model_name='issueuserproperty',
name='rich_filters',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='issueview',
name='rich_filters',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='moduleuserproperties',
name='rich_filters',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='workspaceuserproperties',
name='rich_filters',
field=models.JSONField(default=dict),
),
]
@@ -1,33 +0,0 @@
# Generated by Django 4.2.22 on 2025-09-10 09:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0104_cycleuserproperties_rich_filters_and_more"),
]
operations = [
migrations.AlterField(
model_name="project",
name="cycle_view",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="project",
name="issue_views_view",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="project",
name="module_view",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="session",
name="user_id",
field=models.CharField(db_index=True, max_length=50, null=True),
),
]
@@ -1,152 +0,0 @@
# Generated by Django 4.2.22 on 2025-09-12 08:45
import uuid
import django
from django.conf import settings
from django.db import migrations, models
def set_page_sort_order(apps, schema_editor):
Page = apps.get_model("db", "Page")
batch_size = 3000
sort_order = 100
# Get page IDs ordered by name using the historical model
# This should include all pages regardless of soft-delete status
page_ids = list(Page.objects.all().order_by("name").values_list("id", flat=True))
updated_pages = []
for page_id in page_ids:
# Create page instance with minimal data
updated_pages.append(Page(id=page_id, sort_order=sort_order))
sort_order += 100
# Bulk update when batch is full
if len(updated_pages) >= batch_size:
Page.objects.bulk_update(
updated_pages, ["sort_order"], batch_size=batch_size
)
updated_pages = []
# Update remaining pages
if updated_pages:
Page.objects.bulk_update(updated_pages, ["sort_order"], batch_size=batch_size)
def reverse_set_page_sort_order(apps, schema_editor):
Page = apps.get_model("db", "Page")
Page.objects.update(sort_order=Page.DEFAULT_SORT_ORDER)
class Migration(migrations.Migration):
dependencies = [
("db", "0105_alter_project_cycle_view_and_more"),
]
operations = [
migrations.CreateModel(
name="ProjectWebhook",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"webhook",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_webhooks",
to="db.webhook",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Project Webhook",
"verbose_name_plural": "Project Webhooks",
"db_table": "project_webhooks",
"ordering": ("-created_at",),
},
),
migrations.AddConstraint(
model_name="projectwebhook",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("project", "webhook"),
name="project_webhook_unique_project_webhook_when_deleted_at_null",
),
),
migrations.AlterUniqueTogether(
name="projectwebhook",
unique_together={("project", "webhook", "deleted_at")},
),
migrations.AlterField(
model_name="issuerelation",
name="relation_type",
field=models.CharField(
default="blocked_by", max_length=20, verbose_name="Issue Relation Type"
),
),
migrations.RunPython(
set_page_sort_order, reverse_code=reverse_set_page_sort_order
),
]
@@ -1,74 +0,0 @@
from django.db import migrations
from plane.utils.filters import LegacyToRichFiltersConverter
from plane.utils.filters.filter_migrations import (
migrate_models_filters_to_rich_filters,
clear_models_rich_filters,
)
# Define all models that need migration in one place
MODEL_NAMES = [
"IssueView",
"WorkspaceUserProperties",
"ModuleUserProperties",
"IssueUserProperty",
"CycleUserProperties",
]
def migrate_filters_to_rich_filters(apps, schema_editor):
"""
Migrate legacy filters to rich_filters format for all models that have both fields.
"""
# Get the model classes from model names
models_to_migrate = {}
for model_name in MODEL_NAMES:
try:
model_class = apps.get_model("db", model_name)
models_to_migrate[model_name] = model_class
except Exception as e:
# Log error but continue with other models
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to get model {model_name}: {str(e)}")
converter = LegacyToRichFiltersConverter()
# Migrate all models
migrate_models_filters_to_rich_filters(models_to_migrate, converter)
def reverse_migrate_rich_filters_to_filters(apps, schema_editor):
"""
Reverse migration to clear rich_filters field for all models.
"""
# Get the model classes from model names
models_to_clear = {}
for model_name in MODEL_NAMES:
try:
model_class = apps.get_model("db", model_name)
models_to_clear[model_name] = model_class
except Exception as e:
# Log error but continue with other models
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to get model {model_name}: {str(e)}")
# Clear rich_filters for all models
clear_models_rich_filters(models_to_clear)
class Migration(migrations.Migration):
dependencies = [
('db', '0106_auto_20250912_0845'),
]
operations = [
migrations.RunPython(
migrate_filters_to_rich_filters,
reverse_code=reverse_migrate_rich_filters_to_filters,
),
]
-9
View File
@@ -76,15 +76,6 @@ class FileAsset(BaseModel):
verbose_name_plural = "File Assets"
db_table = "file_assets"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["entity_type"], name="asset_entity_type_idx"),
models.Index(
fields=["entity_identifier"], name="asset_entity_identifier_idx"
),
models.Index(
fields=["entity_type", "entity_identifier"], name="asset_entity_idx"
),
]
def __str__(self):
return str(self.asset)
-1
View File
@@ -139,7 +139,6 @@ class CycleUserProperties(ProjectBaseModel):
filters = models.JSONField(default=get_default_filters)
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(default=get_default_display_properties)
rich_filters = models.JSONField(default=dict)
class Meta:
unique_together = ["cycle", "user", "deleted_at"]
-1
View File
@@ -56,7 +56,6 @@ class ExporterHistory(BaseModel):
related_name="workspace_exporters",
)
filters = models.JSONField(blank=True, null=True)
rich_filters = models.JSONField(default=dict, blank=True, null=True)
class Meta:
verbose_name = "Exporter"
-9
View File
@@ -41,15 +41,6 @@ class UserFavorite(WorkspaceBaseModel):
verbose_name_plural = "User Favorites"
db_table = "user_favorites"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["entity_type"], name="fav_entity_type_idx"),
models.Index(
fields=["entity_identifier"], name="fav_entity_identifier_idx"
),
models.Index(
fields=["entity_type", "entity_identifier"], name="fav_entity_idx"
),
]
def save(self, *args, **kwargs):
if self._state.adding:
+1 -2
View File
@@ -284,7 +284,6 @@ class IssueRelationChoices(models.TextChoices):
BLOCKED_BY = "blocked_by", "Blocked By"
START_BEFORE = "start_before", "Start Before"
FINISH_BEFORE = "finish_before", "Finish Before"
IMPLEMENTED_BY = "implemented_by", "Implemented By"
class IssueRelation(ProjectBaseModel):
@@ -296,6 +295,7 @@ class IssueRelation(ProjectBaseModel):
)
relation_type = models.CharField(
max_length=20,
choices=IssueRelationChoices.choices,
verbose_name="Issue Relation Type",
default=IssueRelationChoices.BLOCKED_BY,
)
@@ -509,7 +509,6 @@ class IssueUserProperty(ProjectBaseModel):
filters = models.JSONField(default=get_default_filters)
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(default=get_default_display_properties)
rich_filters = models.JSONField(default=dict)
class Meta:
verbose_name = "Issue User Property"
-1
View File
@@ -207,7 +207,6 @@ class ModuleUserProperties(ProjectBaseModel):
filters = models.JSONField(default=get_default_filters)
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(default=get_default_display_properties)
rich_filters = models.JSONField(default=dict)
class Meta:
unique_together = ["module", "user", "deleted_at"]
-8
View File
@@ -39,14 +39,6 @@ class Notification(BaseModel):
verbose_name_plural = "Notifications"
db_table = "notifications"
ordering = ("-created_at",)
indexes = [
models.Index(
fields=["entity_identifier"], name="notif_entity_identifier_idx"
),
models.Index(fields=["entity_name"], name="notif_entity_name_idx"),
models.Index(fields=["read_at"], name="notif_read_at_idx"),
models.Index(fields=["receiver", "read_at"], name="notif_entity_idx"),
]
def __str__(self):
"""Return name of the notifications"""
+1 -17
View File
@@ -19,7 +19,6 @@ def get_view_props():
class Page(BaseModel):
PRIVATE_ACCESS = 1
PUBLIC_ACCESS = 0
DEFAULT_SORT_ORDER = 65535
ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public"))
@@ -58,7 +57,6 @@ class Page(BaseModel):
)
moved_to_page = models.UUIDField(null=True, blank=True)
moved_to_project = models.UUIDField(null=True, blank=True)
sort_order = models.FloatField(default=DEFAULT_SORT_ORDER)
external_id = models.CharField(max_length=255, null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
@@ -100,11 +98,8 @@ class PageLog(BaseModel):
)
transaction = models.UUIDField(default=uuid.uuid4)
page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE)
entity_identifier = models.UUIDField(null=True, blank=True)
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(max_length=30, verbose_name="Transaction Type")
entity_type = models.CharField(
max_length=30, verbose_name="Entity Type", null=True, blank=True
)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
)
@@ -115,17 +110,6 @@ class PageLog(BaseModel):
verbose_name_plural = "Page Logs"
db_table = "page_logs"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["entity_type"], name="pagelog_entity_type_idx"),
models.Index(fields=["entity_identifier"], name="pagelog_entity_id_idx"),
models.Index(fields=["entity_name"], name="pagelog_entity_name_idx"),
models.Index(
fields=["entity_type", "entity_identifier"], name="pagelog_type_id_idx"
),
models.Index(
fields=["entity_name", "entity_identifier"], name="pagelog_name_id_idx"
),
]
def __str__(self):
return f"{self.page.name} {self.entity_name}"
+3 -9
View File
@@ -18,12 +18,6 @@ from .base import BaseModel
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
class ROLE(Enum):
ADMIN = 20
MEMBER = 15
GUEST = 5
class ProjectNetwork(Enum):
SECRET = 0
PUBLIC = 2
@@ -95,9 +89,9 @@ class Project(BaseModel):
)
emoji = models.CharField(max_length=255, null=True, blank=True)
icon_prop = models.JSONField(null=True)
module_view = models.BooleanField(default=False)
cycle_view = models.BooleanField(default=False)
issue_views_view = models.BooleanField(default=False)
module_view = models.BooleanField(default=True)
cycle_view = models.BooleanField(default=True)
issue_views_view = models.BooleanField(default=True)
page_view = models.BooleanField(default=True)
intake_view = models.BooleanField(default=False)
is_time_tracking_enabled = models.BooleanField(default=False)
+1 -1
View File
@@ -13,7 +13,7 @@ VALID_KEY_CHARS = string.ascii_lowercase + string.digits
class Session(AbstractBaseSession):
device_info = models.JSONField(null=True, blank=True, default=None)
session_key = models.CharField(max_length=128, primary_key=True)
user_id = models.CharField(null=True, max_length=50, db_index=True)
user_id = models.CharField(null=True, max_length=50)
@classmethod
def get_session_store_class(cls):
-1
View File
@@ -58,7 +58,6 @@ class IssueView(WorkspaceBaseModel):
filters = models.JSONField(default=dict)
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(default=get_default_display_properties)
rich_filters = models.JSONField(default=dict)
access = models.PositiveSmallIntegerField(
default=1, choices=((0, "Private"), (1, "Public"))
)
+1 -22
View File
@@ -7,7 +7,7 @@ from django.db import models
from django.core.exceptions import ValidationError
# Module imports
from plane.db.models import BaseModel, ProjectBaseModel
from plane.db.models import BaseModel
def generate_token():
@@ -90,24 +90,3 @@ class WebhookLog(BaseModel):
def __str__(self):
return f"{self.event_type} {str(self.webhook)}"
class ProjectWebhook(ProjectBaseModel):
webhook = models.ForeignKey(
"db.Webhook", on_delete=models.CASCADE, related_name="project_webhooks"
)
class Meta:
unique_together = ["project", "webhook", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "webhook"],
condition=models.Q(deleted_at__isnull=True),
name="project_webhook_unique_project_webhook_when_deleted_at_null",
)
]
verbose_name = "Project Webhook"
verbose_name_plural = "Project Webhooks"
db_table = "project_webhooks"
ordering = ("-created_at",)
-1
View File
@@ -332,7 +332,6 @@ class WorkspaceUserProperties(BaseModel):
filters = models.JSONField(default=get_default_filters)
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(default=get_default_display_properties)
rich_filters = models.JSONField(default=dict)
class Meta:
unique_together = ["workspace", "user", "deleted_at"]
+2 -10
View File
@@ -34,7 +34,6 @@ from plane.authentication.adapter.error import (
AuthenticationException,
)
from plane.utils.ip_address import get_client_ip
from plane.utils.path_validator import get_safe_redirect_url
class InstanceAdminEndpoint(BaseAPIView):
@@ -393,14 +392,7 @@ class InstanceAdminSignOutEndpoint(View):
user.save()
# Log the user out
logout(request)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_admin=True),
next_path=""
)
url = urljoin(base_host(request=request, is_admin=True))
return HttpResponseRedirect(url)
except Exception:
url = get_safe_redirect_url(
base_url=base_host(request=request, is_admin=True),
next_path=""
)
return HttpResponseRedirect(url)
return HttpResponseRedirect(base_host(request=request, is_admin=True))
@@ -1,18 +0,0 @@
# Generated by Django 4.2.22 on 2025-09-11 08:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("license", "0005_rename_product_instance_edition_and_more"),
]
operations = [
migrations.AddField(
model_name="instance",
name="is_current_version_deprecated",
field=models.BooleanField(default=False),
),
]
@@ -38,8 +38,6 @@ class Instance(BaseModel):
is_signup_screen_visited = models.BooleanField(default=False)
is_verified = models.BooleanField(default=False)
is_test = models.BooleanField(default=False)
# field for validating if the current version is deprecated
is_current_version_deprecated = models.BooleanField(default=False)
class Meta:
verbose_name = "Instance"
-4
View File
@@ -465,7 +465,3 @@ 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
# MongoDB Settings
MONGO_DB_URL = os.environ.get("MONGO_DB_URL", False)
MONGO_DB_DATABASE = os.environ.get("MONGO_DB_DATABASE", False)
-3
View File
@@ -118,7 +118,4 @@ class MongoConnection:
Returns:
bool: True if MongoDB is configured and connected, False otherwise
"""
if cls._client is None:
cls._instance = cls()
return cls._client is not None and cls._db is not None
+10 -11
View File
@@ -30,6 +30,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -289,22 +290,20 @@ class IssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if "description" in data and data["description"]:
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
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": "Invalid binary data"})
raise serializers.ValidationError({"description_binary": error_msg})
return data
+18 -4
View File
@@ -184,7 +184,6 @@ def issue_group_values(
slug: str,
project_id: Optional[str] = None,
filters: Dict[str, Any] = {},
queryset: Optional[QuerySet] = None,
) -> List[Union[str, Any]]:
if field == "state_id":
queryset = State.objects.filter(
@@ -239,20 +238,35 @@ def issue_group_values(
if field == "state__group":
return ["backlog", "unstarted", "started", "completed", "cancelled"]
if field == "target_date":
queryset = queryset.values_list("target_date", flat=True).distinct()
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("target_date", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "start_date":
queryset = queryset.values_list("start_date", flat=True).distinct()
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("start_date", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "created_by":
queryset = queryset.values_list("created_by", flat=True).distinct()
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("created_by", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
+267 -171
View File
@@ -1,14 +1,36 @@
# Python imports
import base64
import nh3
from plane.utils.exception_logger import log_exception
from bs4 import BeautifulSoup
from collections import defaultdict
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",
@@ -19,11 +41,74 @@ SUSPICIOUS_BINARY_PATTERNS = [
"<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 a valid document format
and doesn't contain malicious content.
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
@@ -64,180 +149,191 @@ def validate_binary_data(data):
return True, None
# Combine custom components and editor-specific nodes into a single set of tags
CUSTOM_TAGS = {
# editor node/tag names
"mention-component",
"label",
"input",
"image-component",
}
ALLOWED_TAGS = nh3.ALLOWED_TAGS | CUSTOM_TAGS
# Merge nh3 defaults with all attributes used across our custom components
ATTRIBUTES = {
"*": {
"class",
"id",
"title",
"role",
"aria-label",
"aria-hidden",
"style",
"start",
"type",
# common editor data-* attributes seen in stored HTML
# (wildcards like data-* are NOT supported by nh3; we add known keys
# here and dynamically include all data-* seen in the input below)
"data-tight",
"data-node-type",
"data-type",
"data-checked",
"data-background-color",
"data-text-color",
"data-name",
# callout attributes
"data-icon-name",
"data-icon-color",
"data-background",
"data-emoji-unicode",
"data-emoji-url",
"data-logo-in-use",
"data-block-type",
},
"a": {"href", "target"},
# editor node/tag attributes
"image-component": {
"id",
"width",
"height",
"aspectRatio",
"aspectratio",
"src",
"alignment",
},
"img": {
"width",
"height",
"aspectRatio",
"aspectratio",
"alignment",
"src",
"alt",
"title",
},
"mention-component": {"id", "entity_identifier", "entity_name"},
"th": {
"colspan",
"rowspan",
"colwidth",
"background",
"hideContent",
"hidecontent",
"style",
},
"td": {
"colspan",
"rowspan",
"colwidth",
"background",
"textColor",
"textcolor",
"hideContent",
"hidecontent",
"style",
},
"tr": {"background", "textColor", "textcolor", "style"},
"pre": {"language"},
"code": {"language", "spellcheck"},
"input": {"type", "checked"},
}
SAFE_PROTOCOLS = {"http", "https", "mailto", "tel"}
def _compute_html_sanitization_diff(before_html: str, after_html: str):
def validate_html_content(html_content):
"""
Compute a coarse diff between original and sanitized HTML.
Validate that HTML content is safe and doesn't contain malicious patterns.
Returns a dict with:
- removed_tags: mapping[tag] -> removed_count
- removed_attributes: mapping[tag] -> sorted list of attribute names removed
"""
try:
Args:
html_content (str): The HTML content to validate
def collect(soup):
tag_counts = defaultdict(int)
attrs_by_tag = defaultdict(set)
for el in soup.find_all(True):
tag_name = (el.name or "").lower()
if not tag_name:
continue
tag_counts[tag_name] += 1
for attr_name in list(el.attrs.keys()):
if isinstance(attr_name, str) and attr_name:
attrs_by_tag[tag_name].add(attr_name.lower())
return tag_counts, attrs_by_tag
soup_before = BeautifulSoup(before_html or "", "html.parser")
soup_after = BeautifulSoup(after_html or "", "html.parser")
counts_before, attrs_before = collect(soup_before)
counts_after, attrs_after = collect(soup_after)
removed_tags = {}
for tag, cnt_before in counts_before.items():
cnt_after = counts_after.get(tag, 0)
if cnt_after < cnt_before:
removed = cnt_before - cnt_after
removed_tags[tag] = removed
removed_attributes = {}
for tag, before_set in attrs_before.items():
after_set = attrs_after.get(tag, set())
removed = before_set - after_set
if removed:
removed_attributes[tag] = sorted(list(removed))
return {"removed_tags": removed_tags, "removed_attributes": removed_attributes}
except Exception:
# Best-effort only; if diffing fails we don't block the request
return {"removed_tags": {}, "removed_attributes": {}}
def validate_html_content(html_content: str):
"""
Sanitize HTML content using nh3.
Returns a tuple: (is_valid, error_message, clean_html)
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not html_content:
return True, None, None
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)", None
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]}",
)
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:
clean_html = nh3.clean(
html_content,
tags=ALLOWED_TAGS,
attributes=ATTRIBUTES,
url_schemes=SAFE_PROTOCOLS,
)
# Report removals to logger (Sentry) if anything was stripped
diff = _compute_html_sanitization_diff(html_content, clean_html)
if diff.get("removed_tags") or diff.get("removed_attributes"):
try:
import json
# 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)"
summary = json.dumps(diff)
except Exception:
summary = str(diff)
log_exception(
f"HTML sanitization removals: {summary}",
warning=True,
)
return True, None, clean_html
# 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:
log_exception(e)
return False, "Failed to sanitize HTML", None
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
-10
View File
@@ -1,10 +0,0 @@
# Filters module for handling complex filtering operations
# Import all utilities from base modules
from .filter_backend import ComplexFilterBackend
from .converters import LegacyToRichFiltersConverter
from .filterset import IssueFilterSet
# Public API exports
__all__ = ["ComplexFilterBackend", "LegacyToRichFiltersConverter", "IssueFilterSet"]
-438
View File
@@ -1,438 +0,0 @@
import re
import uuid
from datetime import datetime
from typing import Any, Dict, List, Union
from dateutil.parser import parse as dateutil_parse
class LegacyToRichFiltersConverter:
# Default mapping from legacy filter names to new rich filter field names
DEFAULT_FIELD_MAPPINGS = {
"state": "state_id",
"labels": "label_id",
"cycle": "cycle_id",
"module": "module_id",
"assignees": "assignee_id",
"mentions": "mention_id",
"created_by": "created_by_id",
"state_group": "state_group",
"priority": "priority",
"project": "project_id",
"start_date": "start_date",
"target_date": "target_date",
}
# Default fields that expect UUID values
DEFAULT_UUID_FIELDS = {
"state_id",
"label_id",
"cycle_id",
"module_id",
"assignee_id",
"mention_id",
"created_by_id",
"project_id",
}
# Default valid choices for choice fields
DEFAULT_VALID_CHOICES = {
"state_group": ["backlog", "unstarted", "started", "completed", "cancelled"],
"priority": ["urgent", "high", "medium", "low", "none"],
}
# Default date fields
DEFAULT_DATE_FIELDS = {"start_date", "target_date"}
# Pattern for relative date strings like "2_weeks" or "3_months"
DATE_PATTERN = re.compile(r"(\d+)_(weeks|months)$")
def __init__(
self,
field_mappings: Dict[str, str] = None,
uuid_fields: set = None,
valid_choices: Dict[str, List[str]] = None,
date_fields: set = None,
extend_defaults: bool = True,
):
"""
Initialize the converter with optional custom configurations.
Args:
field_mappings: Custom field mappings (legacy_key -> rich_field_name)
uuid_fields: Set of field names that should be validated as UUIDs
valid_choices: Dict of valid choices for choice fields
date_fields: Set of field names that should be treated as dates
extend_defaults: If True, merge with defaults; if False, replace defaults
Examples:
# Use defaults
converter = LegacyToRichFiltersConverter()
# Add custom field mapping
converter = LegacyToRichFiltersConverter(
field_mappings={"custom_field": "custom_field_id"}
)
# Override priority choices
converter = LegacyToRichFiltersConverter(
valid_choices={"priority": ["critical", "high", "medium", "low"]}
)
# Complete replacement (not extending defaults)
converter = LegacyToRichFiltersConverter(
field_mappings={"state": "status_id"},
extend_defaults=False
)
"""
if extend_defaults:
# Merge with defaults
self.FIELD_MAPPINGS = {**self.DEFAULT_FIELD_MAPPINGS}
if field_mappings:
self.FIELD_MAPPINGS.update(field_mappings)
self.UUID_FIELDS = {*self.DEFAULT_UUID_FIELDS}
if uuid_fields:
self.UUID_FIELDS.update(uuid_fields)
self.VALID_CHOICES = {**self.DEFAULT_VALID_CHOICES}
if valid_choices:
self.VALID_CHOICES.update(valid_choices)
self.DATE_FIELDS = {*self.DEFAULT_DATE_FIELDS}
if date_fields:
self.DATE_FIELDS.update(date_fields)
else:
# Replace defaults entirely
self.FIELD_MAPPINGS = field_mappings or {}
self.UUID_FIELDS = uuid_fields or set()
self.VALID_CHOICES = valid_choices or {}
self.DATE_FIELDS = date_fields or set()
def add_field_mapping(self, legacy_key: str, rich_field_name: str) -> None:
"""Add or update a single field mapping."""
self.FIELD_MAPPINGS[legacy_key] = rich_field_name
def add_uuid_field(self, field_name: str) -> None:
"""Add a field that should be validated as UUID."""
self.UUID_FIELDS.add(field_name)
def add_choice_field(self, field_name: str, choices: List[str]) -> None:
"""Add or update valid choices for a choice field."""
self.VALID_CHOICES[field_name] = choices
def add_date_field(self, field_name: str) -> None:
"""Add a field that should be treated as a date field."""
self.DATE_FIELDS.add(field_name)
def update_mappings(
self,
field_mappings: Dict[str, str] = None,
uuid_fields: set = None,
valid_choices: Dict[str, List[str]] = None,
date_fields: set = None,
) -> None:
"""
Update multiple configurations at once.
Args:
field_mappings: Additional field mappings to add/update
uuid_fields: Additional UUID fields to add
valid_choices: Additional choice fields to add/update
date_fields: Additional date fields to add
"""
if field_mappings:
self.FIELD_MAPPINGS.update(field_mappings)
if uuid_fields:
self.UUID_FIELDS.update(uuid_fields)
if valid_choices:
self.VALID_CHOICES.update(valid_choices)
if date_fields:
self.DATE_FIELDS.update(date_fields)
def _validate_uuid(self, value: str) -> bool:
"""Validate if a string is a valid UUID"""
try:
uuid.UUID(str(value))
return True
except (ValueError, TypeError):
return False
def _validate_choice(self, field_name: str, value: str) -> bool:
"""Validate if a value is valid for a choice field"""
if field_name not in self.VALID_CHOICES:
return True # No validation needed for this field
return value in self.VALID_CHOICES[field_name]
def _validate_date(self, value: Union[str, datetime]) -> bool:
"""Validate if a value is a valid date using dateutil parser"""
if isinstance(value, datetime):
return True
if isinstance(value, str):
try:
# Use dateutil for flexible date parsing
dateutil_parse(value)
return True
except (ValueError, TypeError):
return False
return False
def _validate_value(self, rich_field_name: str, value: Any) -> bool:
"""Validate a single value based on field type"""
if rich_field_name in self.UUID_FIELDS:
return self._validate_uuid(value)
elif rich_field_name in self.VALID_CHOICES:
return self._validate_choice(rich_field_name, value)
elif rich_field_name in self.DATE_FIELDS:
return self._validate_date(value)
return True # No specific validation needed
def _filter_valid_values(
self, rich_field_name: str, values: List[Any]
) -> List[Any]:
"""Filter out invalid values from a list and return only valid ones"""
valid_values = []
for value in values:
if self._validate_value(rich_field_name, value):
valid_values.append(value)
return valid_values
def _add_validation_error(
self, strict: bool, validation_errors: List[str], message: str
) -> None:
"""Add validation error if in strict mode."""
if strict:
validation_errors.append(message)
def _add_rich_filter(
self, rich_filters: Dict[str, Any], field_name: str, operator: str, value: Any
) -> None:
"""Add a rich filter with proper field name formatting."""
# Convert lists to comma-separated strings for 'in' and 'range' operations
if operator in ("in", "range") and isinstance(value, list):
value = ",".join(str(v) for v in value)
rich_filters[f"{field_name}__{operator}"] = value
def _handle_value_error(
self, e: ValueError, strict: bool, validation_errors: List[str]
) -> None:
"""Handle ValueError with consistent strict/non-strict behavior."""
if strict:
validation_errors.append(str(e))
# In non-strict mode, we just skip (no action needed)
def _process_date_field(
self,
rich_field_name: str,
values: List[str],
strict: bool,
validation_errors: List[str],
rich_filters: Dict[str, Any],
) -> bool:
"""Process date field with basic functionality (exact, range)."""
if rich_field_name not in self.DATE_FIELDS:
return False
try:
date_filter_result = self._convert_date_value(
rich_field_name, values, strict
)
if date_filter_result:
rich_filters.update(date_filter_result)
return True
except ValueError as e:
self._handle_value_error(e, strict, validation_errors)
return True
def _convert_date_value(
self, field_name: str, values: List[str], strict: bool = False
) -> Dict[str, Any]:
"""
Convert legacy date values to rich filter format - basic implementation.
Supports:
- Simple dates: "2023-01-01" -> __exact
- Basic ranges: ["2023-01-01;after", "2023-12-31;before"] -> __range
- Skips complex or relative date patterns
Args:
field_name: Name of the rich filter field
values: List of legacy date values
strict: If True, raise errors for validation failures
Raises:
ValueError: For malformed date patterns (strict mode)
"""
# Check for relative dates and skip the entire field if found
for value in values:
if ";" in value:
parts = value.split(";")
if len(parts) > 0 and self.DATE_PATTERN.match(parts[0]):
# Skip relative date patterns entirely
return {}
# Skip complex conditions (more than 2 values)
if len(values) > 2:
return {}
# Process each date value
exact_dates = []
after_dates = []
before_dates = []
for value in values:
if ";" not in value:
# Simple date string
if not self._validate_date(value):
if strict:
raise ValueError(f"Invalid date format: {value}")
continue
exact_dates.append(value)
else:
# Directional date - only handle basic after/before
parts = value.split(";")
if len(parts) < 2:
if strict:
raise ValueError(f"Invalid date format: {value}")
continue
date_part = parts[0]
direction = parts[1]
if not self._validate_date(date_part):
if strict:
raise ValueError(f"Invalid date format: {date_part}")
continue
if direction == "after":
after_dates.append(date_part)
elif direction == "before":
before_dates.append(date_part)
# Skip unsupported directions
# Determine return format
result = {}
if len(after_dates) == 1 and len(before_dates) == 1 and len(exact_dates) == 0:
# Simple range: one after and one before
start_date = min(after_dates[0], before_dates[0])
end_date = max(after_dates[0], before_dates[0])
self._add_rich_filter(result, field_name, "range", [start_date, end_date])
elif len(exact_dates) == 1 and len(after_dates) == 0 and len(before_dates) == 0:
# Single exact date
self._add_rich_filter(result, field_name, "exact", exact_dates[0])
# Skip all other combinations
return result
def convert(self, legacy_filters: dict, strict: bool = False) -> Dict[str, Any]:
"""
Convert legacy filters to rich filters format with validation
Args:
legacy_filters: Dictionary of legacy filters
strict: If True, raise exception on validation errors.
If False, skip invalid values (default behavior)
Returns:
Dictionary of rich filters
Raises:
ValueError: If strict=True and validation fails
"""
rich_filters = {}
validation_errors = []
for legacy_key, value in legacy_filters.items():
# Skip if value is None or empty
if value is None or (isinstance(value, list) and len(value) == 0):
continue
# Skip if legacy key is not in our mappings (not supported in filterset)
if legacy_key not in self.FIELD_MAPPINGS:
self._add_validation_error(
strict, validation_errors, f"Unsupported filter key: {legacy_key}"
)
continue
# Get the new field name
rich_field_name = self.FIELD_MAPPINGS[legacy_key]
# Handle list values
if isinstance(value, list):
# Process date fields with helper method
if self._process_date_field(
rich_field_name, value, strict, validation_errors, rich_filters
):
continue
# Regular non-date field processing
# Filter out invalid values
valid_values = self._filter_valid_values(rich_field_name, value)
if not valid_values:
self._add_validation_error(
strict,
validation_errors,
f"No valid values found for {legacy_key}: {value}",
)
continue
# Check for invalid values if in strict mode
if strict and len(valid_values) != len(value):
invalid_values = [v for v in value if v not in valid_values]
self._add_validation_error(
strict,
validation_errors,
f"Invalid values for {legacy_key}: {invalid_values}",
)
# For list values, always use __in operator for non-date fields
self._add_rich_filter(rich_filters, rich_field_name, "in", valid_values)
else:
# Handle single values
# Process date fields with helper method
if self._process_date_field(
rich_field_name, [value], strict, validation_errors, rich_filters
):
continue
# For non-list values, use __exact operator for non-date fields
if self._validate_value(rich_field_name, value):
self._add_rich_filter(rich_filters, rich_field_name, "exact", value)
else:
error_msg = f"Invalid value for {legacy_key}: {value}"
self._add_validation_error(strict, validation_errors, error_msg)
# Raise validation errors if in strict mode
if strict and validation_errors:
error_message = f"Filter validation errors: {'; '.join(validation_errors)}"
raise ValueError(error_message)
# Convert flat dict to rich filter format
return self._format_as_rich_filter(rich_filters)
def _format_as_rich_filter(self, flat_filters: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert a flat dictionary of filters to the proper rich filter format.
Args:
flat_filters: Dictionary with field__lookup keys and values
Returns:
Rich filter format using logical operators (and/or/not)
"""
if not flat_filters:
return {}
# If only one filter, return as leaf node
if len(flat_filters) == 1:
key, value = next(iter(flat_filters.items()))
return {key: value}
# Multiple filters: wrap in 'and' operator
filter_conditions = []
for key, value in flat_filters.items():
filter_conditions.append({key: value})
return {"and": filter_conditions}
@@ -1,380 +0,0 @@
import json
from django.core.exceptions import ValidationError
from django.http import QueryDict
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters
class ComplexFilterBackend(filters.BaseFilterBackend):
"""
Filter backend that supports complex JSON filtering.
For full, up-to-date examples and usage, see the package README
at `plane/utils/filters/README.md`.
"""
filter_param = "filters"
default_max_depth = 5
def filter_queryset(self, request, queryset, view, filter_data=None):
"""Normalize filter input and apply JSON-based filtering.
Accepts explicit `filter_data` (dict or JSON string) or reads the
`filter` query parameter. Enforces JSON-only filtering.
"""
try:
if filter_data is not None:
normalized = self._normalize_filter_data(filter_data, "filter_data")
return self._apply_json_filter(queryset, normalized, view)
filter_string = request.query_params.get(self.filter_param, None)
if not filter_string:
return queryset
normalized = self._normalize_filter_data(filter_string, "filter")
return self._apply_json_filter(queryset, normalized, view)
except ValidationError:
# Propagate validation errors unchanged
raise
except Exception as e:
raise
# Convert unexpected errors to ValidationError to keep response consistent
raise ValidationError(f"Filter error: {str(e)}")
def _normalize_filter_data(self, raw_filter, source_label):
"""Return a dict from raw filter input or raise a ValidationError.
- raw_filter may be a dict or a JSON string
- source_label is used in error messages (e.g., 'filter_data' or 'filter')
"""
try:
if isinstance(raw_filter, str):
return json.loads(raw_filter)
if isinstance(raw_filter, dict):
return raw_filter
raise ValidationError(f"'{source_label}' must be a dict or a JSON string.")
except json.JSONDecodeError:
raise ValidationError(
f"Invalid JSON for '{source_label}'. Expected a valid JSON object."
)
def _apply_json_filter(self, queryset, filter_data, view):
"""Process a JSON filter structure using OR/AND/NOT set operations."""
if not filter_data:
return queryset
# Validate structure and depth before field allowlist checks
max_depth = self._get_max_depth(view)
self._validate_structure(filter_data, max_depth=max_depth, current_depth=1)
# Validate against the view's FilterSet (only declared filters are allowed)
self._validate_fields(filter_data, view)
# Build combined queryset using FilterSet-driven leaf evaluation
combined_qs = self._evaluate_node(filter_data, queryset, view)
if combined_qs is None:
return queryset
return combined_qs
def _validate_fields(self, filter_data, view):
"""Validate that filtered fields are defined in the view's FilterSet."""
filterset_class = getattr(view, "filterset_class", None)
allowed_fields = (
set(filterset_class.base_filters.keys()) if filterset_class else None
)
if not allowed_fields:
# If no FilterSet is configured, reject filtering to avoid unintended exposure # noqa: E501
raise ValidationError(
"Filtering is not enabled for this endpoint (missing filterset_class)"
)
# Extract field names from the filter data
fields = self._extract_field_names(filter_data)
# Check if all fields are allowed
for field in fields:
# Field keys must match FilterSet filter names (including any lookups)
# Example: 'sequence_id__gte' should be declared in base_filters
# Special-case __range: require the '<base>__range' filter itself
if field not in allowed_fields:
raise ValidationError(f"Filtering on field '{field}' is not allowed")
def _extract_field_names(self, filter_data):
"""Extract all field names from a nested filter structure"""
if isinstance(filter_data, dict):
fields = []
for key, value in filter_data.items():
if key.lower() in ("or", "and", "not"):
# This is a logical operator, process its children
if key.lower() == "not":
# 'not' has a dict as its value, not a list
if isinstance(value, dict):
fields.extend(self._extract_field_names(value))
else:
# 'or' and 'and' have lists as their values
for item in value:
fields.extend(self._extract_field_names(item))
else:
# This is a field name
fields.append(key)
return fields
return []
def _evaluate_node(self, node, base_queryset, view):
"""
Recursively evaluate a JSON node into a combined queryset using branch-based filtering.
Rules:
- leaf dict → evaluated through DjangoFilterBackend as a mini-querystring
- {"or": [...]} → union (|) of children
- {"and": [...]} → collect field conditions per branch and apply together
- {"not": {...}} → exclude child's rows from the base queryset
(complement within base scope)
"""
if not isinstance(node, dict):
return None
# 'or' combination - requires set operations between children
if "or" in node:
children = node["or"]
if not isinstance(children, list) or not children:
return None
combined = None
for child in children:
child_qs = self._evaluate_node(child, base_queryset, view)
if child_qs is None:
continue
combined = child_qs if combined is None else (combined | child_qs)
return combined
# 'and' combination - collect field conditions per branch
if "and" in node:
children = node["and"]
if not isinstance(children, list) or not children:
return None
return self._evaluate_and_branch(children, base_queryset, view)
# 'not' negation
if "not" in node:
child = node["not"]
if not isinstance(child, dict):
return None
child_qs = self._evaluate_node(child, base_queryset, view)
if child_qs is None:
return None
# Use subquery instead of pk__in for better performance
# This avoids evaluating child_qs and creating large IN clauses
return base_queryset.exclude(pk__in=child_qs.values("pk"))
# Leaf dict: evaluate via DjangoFilterBackend using FilterSet
return self._filter_leaf_via_backend(node, base_queryset, view)
def _evaluate_and_branch(self, children, base_queryset, view):
"""
Evaluate an AND branch by collecting field conditions and applying them together.
This approach is more efficient than individual leaf evaluation because:
- Field conditions within the same AND branch are collected and applied together
- Only logical operation children require separate evaluation and set intersection
- Reduces the number of intermediate querysets and database queries
"""
collected_conditions = {}
logical_querysets = []
# Separate field conditions from logical operations
for child in children:
if not isinstance(child, dict):
continue
# Check if this child contains logical operators
has_logical = any(
k.lower() in ("or", "and", "not")
for k in child.keys()
if isinstance(k, str)
)
if has_logical:
# This child has logical operators, evaluate separately
child_qs = self._evaluate_node(child, base_queryset, view)
if child_qs is not None:
logical_querysets.append(child_qs)
else:
# This is a leaf with field conditions, collect them
collected_conditions.update(child)
# Start with base queryset
result_qs = base_queryset
# Apply collected field conditions together if any exist
if collected_conditions:
result_qs = self._filter_leaf_via_backend(
collected_conditions, result_qs, view
)
if result_qs is None:
return None
# Intersect with any logical operation results
for logical_qs in logical_querysets:
result_qs = result_qs & logical_qs
return result_qs
def _filter_leaf_via_backend(self, leaf_conditions, base_queryset, view):
"""Evaluate a leaf dict by delegating to DjangoFilterBackend once.
We serialize the leaf dict into a mini querystring and let the view's
filterset_class perform validation, conversion, and filtering. This returns
a lazy queryset suitable for set-operations with siblings.
"""
if not leaf_conditions:
return None
# Build a QueryDict from the leaf conditions
qd = QueryDict(mutable=True)
for key, value in leaf_conditions.items():
# Default serialization to string; QueryDict expects strings
if isinstance(value, list):
# Repeat key for list values (e.g., __in)
qd.setlist(key, [str(v) for v in value])
else:
qd[key] = "" if value is None else str(value)
qd = qd.copy()
qd._mutable = False
# Temporarily patch request.GET and delegate to DjangoFilterBackend
backend = DjangoFilterBackend()
request = view.request
original_get = request._request.GET if hasattr(request, "_request") else None
try:
if hasattr(request, "_request"):
request._request.GET = qd
return backend.filter_queryset(request, base_queryset, view)
finally:
if hasattr(request, "_request") and original_get is not None:
request._request.GET = original_get
def _get_max_depth(self, view):
"""Return the maximum allowed nesting depth for complex filters.
Falls back to class default if the view does not specify it or has
an invalid value.
"""
value = getattr(view, "complex_filter_max_depth", self.default_max_depth)
try:
value_int = int(value)
if value_int <= 0:
return self.default_max_depth
return value_int
except Exception:
return self.default_max_depth
def _validate_structure(self, node, max_depth, current_depth):
"""Validate JSON structure and enforce nesting depth.
Rules:
- Each object may contain only one logical operator:
or/and/not (case-insensitive)
- Logical operator objects cannot contain field keys alongside the
operator
- or/and values must be non-empty lists of dicts
- not value must be a dict
- Leaf objects must only contain field keys and acceptable values
- Depth must not exceed max_depth
"""
if current_depth > max_depth:
raise ValidationError(
f"Filter nesting is too deep (max {max_depth}); found depth"
f" {current_depth}"
)
if not isinstance(node, dict):
raise ValidationError("Each filter node must be a JSON object")
if not node:
raise ValidationError("Filter objects must not be empty")
logical_keys = [
k
for k in node.keys()
if isinstance(k, str) and k.lower() in ("or", "and", "not")
]
if len(logical_keys) > 1:
raise ValidationError(
"A filter object cannot contain multiple logical operators at"
" the same level"
)
if len(logical_keys) == 1:
op_key = logical_keys[0]
# must not mix operator with other keys
if len(node) != 1:
raise ValidationError(
f"Cannot mix logical operator '{op_key}' with field keys at"
f" the same level"
)
op = op_key.lower()
value = node[op_key]
if op in ("or", "and"):
if not isinstance(value, list) or len(value) == 0:
raise ValidationError(
f"'{op}' must be a non-empty list of filter objects"
)
for child in value:
if not isinstance(child, dict):
raise ValidationError(
f"All children of '{op}' must be JSON objects"
)
self._validate_structure(
child,
max_depth=max_depth,
current_depth=current_depth + 1,
)
return
if op == "not":
if not isinstance(value, dict):
raise ValidationError("'not' must be a single JSON object")
self._validate_structure(
value, max_depth=max_depth, current_depth=current_depth + 1
)
return
# Leaf node: validate fields and values
self._validate_leaf(node)
def _validate_leaf(self, leaf):
"""Validate a leaf dict containing field lookups and values."""
if not isinstance(leaf, dict) or not leaf:
raise ValidationError("Leaf filter must be a non-empty JSON object")
for key, value in leaf.items():
if isinstance(key, str) and key.lower() in ("or", "and", "not"):
raise ValidationError(
"Logical operators cannot appear in a leaf filter object"
)
# Lists/Tuples must contain only scalar values
if isinstance(value, (list, tuple)):
if len(value) == 0:
raise ValidationError(f"List value for '{key}' must not be empty")
for item in value:
if not self._is_scalar(item):
raise ValidationError(
f"List value for '{key}' must contain only scalar items"
)
continue
# Scalars and None are allowed
if not self._is_scalar(value):
raise ValidationError(
f"Value for '{key}' must be a scalar, null, or list/tuple of"
f" scalars"
)
def _is_scalar(self, value):
return value is None or isinstance(value, (str, int, float, bool))

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