Compare commits

...

64 Commits

Author SHA1 Message Date
Vihar Kurama 7743e59fff fix: allow command palette search results to open in new tab 2025-10-23 12:17:19 +01:00
Aaron e710f5b278 [WEB-5196] chore: switch from isomorphic-dompurify to dompurify (#7983)
* [WEB-5196] chore: switch from isomorphic-dompurify to dompurify

Replace isomorphic-dompurify with dompurify package in utils.
This change simplifies the dependency and uses the canonical
DOMPurify package directly.

* fix: removing dompurify from the space app as dependency

* chore: remove unused import

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-10-23 16:33:32 +05:30
sriramveeraghanta 80bd1b6dcd Merge branch 'canary' of github.com:makeplane/plane into preview 2025-10-23 15:43:16 +05:30
sriram veeraghanta b34c9ca04f fix: live server secret key app version env variables (#7997)
* fix: live server secret key app version env variables

* fix: revert to stable version

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 15:42:43 +05:30
sriramveeraghanta ad16ea1440 Merge branch 'canary' of github.com:makeplane/plane into preview 2025-10-23 14:54:09 +05:30
sriramveeraghanta addf3c4104 chore: added live server secret key in community docker file 2025-10-23 14:53:41 +05:30
Anmol Singh Bhatia 38cdf756a5 [WEB-5158] chore: calendar start of week alignment fix and code refactoring #7978 2025-10-23 13:59:29 +05:30
Bavisetti Narayan f94da68597 [WEB-5153] chore: optimised the cycle transfer issues (#7969)
* chore: optimised the cycle transfer issues

* chore: added more vlaidation in transfer

* chore: improve the comments
2025-10-23 00:29:32 +05:30
Bavisetti Narayan 68aa2fe0b8 [WIKI-553] chore: improved pages components tracking (#7966)
* chore: page components tracking

* chore: changed the transaction task

* chore: added logger for description html
2025-10-23 00:29:05 +05:30
Vamsi Krishna 5fa9943b66 [WEB-5141] chore: project features refactor (#7960)
* chore: project features refactor

* fix: enable/disable condition for toggle

* fix: dependencies

* chore: lint fix

* chore: lint fix

* chore: added fallback exports
2025-10-23 00:27:16 +05:30
Anmol Singh Bhatia 772b5c5719 [WEB-5201] chore: work item and inbox modal refactor (#7990)
* chore: work item and inbbox modal refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-10-22 21:11:08 +05:30
Nikhil d6657d5168 [WEB-5207] fix: update date formatting in IssueExportSchema for cycle start and end dates #7992 2025-10-22 17:11:43 +05:30
sriramveeraghanta 1b43efbc2a chore: version bump 2025-10-21 19:14:43 +05:30
Aaryan Khandelwal 64781be7d2 regression: downgrade to tiptap v2 (#7982)
* chore: downgrade to tiptap v2

* fix: revert back to hocuspocus

* fix: collaboration events added

* fix: lock unlock issues

* fix: build errors

* fix: type errors

* fix: graceful shutdown

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
2025-10-21 18:28:16 +05:30
sriramveeraghanta 59022b6beb fix: vite package upgrade 2025-10-21 14:41:34 +05:30
Nikhil f266cd8414 [WEB-5162] fix: add exporter URL patterns for exporting issues (#7977)
* feat: add exporter URL patterns for exporting issues and update session cookie age handling

* fix: ensure ADMIN_SESSION_COOKIE_AGE is an integer for consistent session handling
2025-10-16 15:36:48 +05:30
sriramveeraghanta 98b81d7ebb chore(deps): happy dom version bump 2025-10-16 14:55:57 +05:30
Aaron 00807d2d62 [WEB-5157] chore: fix propel icon clip paths #7975 2025-10-16 14:03:26 +05:30
Vamsi Krishna 9d757ccc6a [WEB-5156] fix: activity filters bug #7971 2025-10-15 19:57:52 +05:30
Anmol Singh Bhatia a8c253acfe [WEB-5154] chore: nav button text colour updated #7970 2025-10-15 19:27:10 +05:30
Vamsi Krishna 2b106cbd66 [WEB-5054]feat: added activity filters for state and assignee activities (#7918)
* feat: added activity filters for state and assignee

* chore: removed unused funtion

* chore: lint fix
2025-10-15 17:12:03 +05:30
sriramveeraghanta f9cca8e2cb fix: live server log 2025-10-15 16:18:42 +05:30
Vamsi Krishna ee176efae3 [WEB-5095]chore: updated delete modal info content (#7967)
* chore: updated delete modal info content

* chore: added language support for modal content

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-10-15 16:05:55 +05:30
Akshita Goyal 44a483f895 [WEB-5142] fix: Issue modal styling #7961 2025-10-15 15:19:40 +05:30
sriramveeraghanta 196baf099a chore: lint errors 2025-10-15 15:15:48 +05:30
Nikhil 1a9ebc8b68 [WEB-5071] chore: implement webhook logging to MongoDB and fallback to database (#7896)
* feat: implement webhook logging to MongoDB and fallback to database (#7887)

- Added a new function `save_webhook_log` to log webhook requests and responses to MongoDB, with a fallback to the database if the MongoDB save fails.
- Updated the `webhook_send_task` to utilize the new logging function.
- Modified the `get_webhook_logs_queryset` to order logs by creation date and adjusted the chunk size for iteration.

* refactor: clean up log data formatting in save_webhook_log function

* fix: update retry_count type in save_webhook_log function
2025-10-15 14:53:43 +05:30
Vamsi Krishna 97e662215a [WEB-5125] fix: suspended user view #7964 2025-10-14 17:35:39 +05:30
Bavisetti Narayan 606e34ec81 [WIKI-730] chore: handle body too large error (#7963)
* chore: added middleware to handle body too large

* chore: added middleware to handle body too large

* chore: indentend the code

* chore: changed the response structure

* chore: changed the response structure

* chore: created a new file for middleware

* chore: added a standardized error key
2025-10-14 17:21:11 +05:30
Anmol Singh Bhatia a3019ebd46 [WEB-5092] feat: app sidebar enhancements (#7946)
* chore: project sidebar and settings sidebar improvement

* chore: navigationitem ui improvement

* chore: workspace level new icon added

* chore: propel icon migration

* chore: code refactor

* chore: dashboard icon updated

* chore: code refactor

* chore: icons updated

* chore: code refactor

* chore: code refactor

* chore: scroll area component refactor

* chore: sidebar enhancements

* chore: add and archive icon updated

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-10-14 17:20:20 +05:30
Prateek Shourya 9cfde896b3 [WEB-5134] refactor: update web ESLint configuration and refactor imports to use type imports (#7957)
* [WEB-5134] refactor: update `web` ESLint configuration and refactor imports to use type imports

- Enhanced ESLint configuration by adding new rules for import consistency and type imports.
- Refactored multiple files to replace regular imports with type imports for better clarity and performance.
- Ensured consistent use of type imports across the application to align with TypeScript best practices.

* refactor: standardize type imports across components

- Updated multiple files to replace regular imports with type imports for improved clarity and consistency.
- Ensured adherence to TypeScript best practices in the rich filters and issue layouts components.
2025-10-14 16:45:07 +05:30
Dheeraj Kumar Ketireddy 4168127803 [WEB-4999] feat: implement flexible data export utility with CSV, JSON, and XLSX support (#7884)
* feat: implement flexible data export utility with CSV, JSON, and XLSX support

- Introduced Exporter class for handling various data formats.
- Added formatters for CSV, JSON, and XLSX exports.
- Created schemas for defining export fields and their transformations.
- Implemented IssueExportSchema for exporting issue data with nested attributes.
- Enhanced issue export task to utilize the new exporter system for better data handling.

* feat: enhance issue export functionality with new relations and context handling

- Updated issue export task to utilize new IssueRelation model for better relationship management.
- Refactored Exporter class to accept QuerySets directly, improving performance and flexibility.
- Enhanced IssueExportSchema to include parent issues and relations in the export.
- Improved documentation for exporting multiple projects and filtering fields during export.

* feat: enhance export functionality with field filtering and context support

- Updated Exporter class to merge fields into options for formatting.
- Modified formatters to filter fields based on specified options.
- Enhanced ExportSchema to support optional field selection during serialization.
- Improved documentation for the serialize method to clarify field filtering capabilities.

* fixed type
2025-10-14 15:46:55 +05:30
Anmol Singh Bhatia 9dc14d8d67 [WEB-5139] fix: custom menu item link (#7959)
* fix: custom menu link action

* chore: code refactor

* chore: code refactor
2025-10-14 15:45:49 +05:30
Anmol Singh Bhatia 56007e7d47 [WEB-5129] chore: icons revamp (#7958)
* chore: code refactor

* chore: code refactor
2025-10-14 12:47:53 +05:30
Prateek Shourya cfb4a8212c [WEB-5099] improvement: enhance rich filters with new components and configurations (#7916)
* feat: enhance rich filters with new components and configurations

- Added `AdditionalFilterValueInput` for unsupported filter types.
- Introduced `FilterItem` and related components for better filter item management.
- Updated filter configurations to include new properties and support for multiple values.
- Improved loading states and error handling in filter components.
- Refactored existing filter logic to streamline operations and enhance performance.

* Refactor rich filters component structure and enhance filter item functionality

- Moved AddFilterButton and AddFilterDropdown to a new directory structure for better organization.
- Updated FilterItemProperty to handle filter selection and condition updates more effectively.
- Enhanced the FilterInstance class with methods to update condition properties and operators, improving filter management.
- Added new functionality to handle invalid filter states and improve user feedback.

* [WEB-5111] feat: add 'created_at' and 'updated_at' filters to work item configuration

- Introduced new filter configurations for 'created_at' and 'updated_at' in the work item filters.
- Updated relevant components to utilize these new filters, enhancing filtering capabilities.
- Added corresponding filter configuration functions in the utils for better date handling.

* fix: build
2025-10-14 01:39:24 +05:30
Prateek Shourya 9f41e92d21 [WEB-5135] refactor: update sites ESLint configuration and refactor imports to use type imports (#7956)
- Enhanced ESLint configuration by adding new rules for import consistency and type imports.
- Refactored multiple files to replace regular imports with type imports for better clarity and performance.
- Ensured consistent use of type imports across the application to align with TypeScript best practices.

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-10-14 00:40:30 +05:30
Vamsi Krishna ddf07dc993 [WEB-5029] chore: updates icons for title and description #7862 2025-10-14 00:09:50 +05:30
Prateek Shourya eee9744159 [WEB-5119] chore: remove any deprecated toast instances from the plane/ui page (#7943)
* [WEB-5119] chore: remove deprecated toast implementation from @plane/ui

* fix: build

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-10-14 00:08:00 +05:30
Prateek Shourya c80c76b882 [WEB-5136] refactor: update admin ESLint configuration and refactor imports to use type imports (#7955)
- Enhanced ESLint configuration by adding new rules for import consistency and type imports.
- Refactored multiple files to replace regular imports with type imports for better clarity and performance.
- Ensured consistent use of type imports across the application to align with TypeScript best practices.
2025-10-13 21:07:49 +05:30
Vamsi Krishna ffe38b592a [WEB-5080] fix: attachment and link count in spreadsheet columns #7942 2025-10-13 21:03:54 +05:30
Anmol Singh Bhatia 87eb1949c6 [WEB-5129] feat: icons revamp (#7945) 2025-10-13 19:43:24 +05:30
Anmol Singh Bhatia c736354739 [WEB-5130] chore: propel scroll area improvement #7952 2025-10-13 19:30:58 +05:30
sriramveeraghanta 3eecec552a chore(deps): lock file resolutions 2025-10-13 13:43:05 +05:30
sriramveeraghanta c561164d65 chore(deps): happy dom resolution added 2025-10-13 13:32:13 +05:30
Nikhil c3dd790f7e [WEB-5058] feat: enhance workspace seeding with cycles, modules, and views creation (#7951)
* feat: enhance workspace seeding with cycles, modules, and views creation

- Added `create_cycles`, `create_modules`, and `create_views` functions to the workspace seeding process, enabling the creation of cycles, modules, and views based on new seed data.
- Updated `create_project_issues` to associate issues with cycles and modules.
- Introduced new seed files: `cycles.json`, `modules.json`, and `views.json` to provide initial data for cycles, modules, and views.
- Integrated these new functionalities into the `workspace_seed` task for comprehensive workspace initialization.

* feat: add project_id to page seed data for improved association

- Added `project_id` field to the page with `id` 2 in `pages.json` to establish a clear link between pages and their respective projects.
- This enhancement supports better organization and retrieval of page data within the project context.

* feat: enhance workspace seed task with improved display properties and layout options

- Updated the `create_project_and_member` function to include new display properties and layout configurations for better project visualization.
- Modified display filters to group by state and added calendar layout options.
- Enhanced the `create_modules` and `create_views` functions with improved formatting and structure for better readability and maintainability.

* Update apps/api/plane/bgtasks/workspace_seed_task.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: correct project_id mapping in cycle and view creation functions

- Updated the `create_cycles` function to use the correct `project_id` from the `project_map` for fetching the last cycle.
- Removed redundant `view_id` extraction in the `create_views` function to streamline view creation process.

* refactor: update create_cycles function to return project mapping

- Changed the return type of the `create_cycles` function from `None` to `Dict[int, uuid.UUID]` to provide a mapping of project IDs after cycle creation.
- This modification enhances the function's utility by allowing the caller to access the generated project mappings directly.

* refactor: remove unused view_map variable in create_views function

- Eliminated the `view_map` dictionary from the `create_views` function as it was not utilized, streamlining the code.
- This change enhances code clarity and maintainability by removing unnecessary elements.

* refactor: improve issue creation logic in create_project_issues function

- Added comments to clarify the creation of issue labels, cycle issues, and module issues within the `create_project_issues` function.
- Enhanced code readability and maintainability by structuring the issue creation process with clear conditional checks for cycles and modules.

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-11 20:55:48 +05:30
Akshita Goyal 70be4a4ace [WEB-5127] chore: added readonly properties + fixed state dropdown + added additional props to issue modal (#7949)
* chore: added readonly properties + fixed state dropdown + added addditional props to issue modal

* fix: refactor

* fix: refactors

* fix: build
2025-10-11 16:18:32 +05:30
Anmol Singh Bhatia 2c17f8ad72 [WEB-5103] fix: custom menu event propagation (#7932)
* fix: custommenu event propagation

* chore: code refactor
2025-10-11 15:02:34 +05:30
Nikhil 151674687c [WEB-5059] feat: add page creation functionality to workspace seeding process (#7948)
* feat: add page creation functionality to workspace seeding process

- Implemented `create_pages` function to create pages for each project in the workspace based on data from `pages.json`.
- Integrated page creation into the `workspace_seed` task, ensuring pages are created alongside project issues.
- Added a new `pages.json` seed file containing initial page data and descriptions.

* fix: update page creation logic and seed data

- Set `is_global` to `False` for pages created in the `create_pages` function.
- Adjusted the project type check to be case-insensitive in the page creation logic.
- Added `id`, `project_id`, and `description_stripped` fields to the `pages.json` seed data for improved page initialization.
2025-10-10 18:32:15 +05:30
sriram veeraghanta 8cd29c5009 fix: adding comprehensive logs for live server (#7947)
* fix: adding comprehensive logs

* fix: document controller naming convention

* fix: axios interception logger
2025-10-10 18:28:52 +05:30
Jayash Tripathy 9ce6179421 [WEB-5120] refactor: add onSearchQueryKeyDown prop to ComboboxOptions for keyboard event handling #7941 2025-10-10 16:46:47 +05:30
sriram veeraghanta 71ec9fadb7 chore: package versions 2025-10-10 01:52:23 +05:30
sriram veeraghanta c88ed332e5 chore: package version upgrade 2025-10-10 01:36:32 +05:30
Aaryan Khandelwal d8c1dff34f [WIKI-725] regression: editor floaters propagation #7938 2025-10-10 01:35:14 +05:30
Akshat Jain 5d161f671d [INFRA-246] Update Valkey image version to 7.2.11-alpine in docker-compose files #7935 2025-10-10 00:19:17 +05:30
sriram veeraghanta ae215a542e [WIKI-724 ] feat: sentry setup for live express server #7934 2025-10-09 21:00:43 +05:30
Anmol Singh Bhatia b7c14ac9f5 [WEB-5104] chore: combobox improvements (#7933)
* chore: base ui package upgrade

* feat: migrate combobox to use base-ui primitives

* chore: code refactor

* chore: code refactor
2025-10-09 19:13:17 +05:30
Anmol Singh Bhatia 75cd2017a0 [WEB-5104] chore: base-ui package upgrade and combobox migration (#7931)
* chore: base ui package upgrade

* feat: migrate combobox to use base-ui primitives

* chore: code refactor

* fix: format fixes

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-10-09 18:47:09 +05:30
Rohan Birtia 1895cfe728 Update docker-compose.yml for valkey security patch (#7926)
Critical security patch for Redis.
https://github.com/valkey-io/valkey/security/advisories/GHSA-9rfg-jx7v-52p6
2025-10-09 17:15:49 +05:30
M. Palanikannan 98e0089723 [WIKI-722] fix: hocuspocus provider update to v3 (#7930)
* fix: hocuspocus provider

* fix: hocuspocus updated url instead of queryparams
2025-10-09 15:38:15 +05:30
sriramveeraghanta 561ae82d9d chore: update gitignore and dockerignore 2025-10-09 15:35:11 +05:30
Vamsi Krishna afac9f72db [WEB-5101]fix: toast action items hover behaviour (#7929)
* fix: toast action items hover behaviour

* fix: refactored toast styles
2025-10-09 15:10:56 +05:30
M. Palanikannan 0f038705ed fix: hocuspocus version upgrade (#7928) 2025-10-09 14:32:43 +05:30
Prateek Shourya 0b257c8693 [WEB-5093] improvement: adds content-based MIME type detection for uploads #7925
- Refactored file upload utilities to use async functions for better handling of file metadata.
- Introduced MIME type detection using the file-type library.
- Updated file service methods to await metadata retrieval.
- Added new dependencies for file-type and updated package.json accordingly.
- Removed deprecated file handling code from utils and adjusted imports across services.
2025-10-09 12:22:43 +05:30
Aaryan Khandelwal f2539c5051 fix: editor dropdowns positioning (#7927)
* fix: editor dropdowns positioning

* fix: add cleanup to prevent memory leak

* chore: add editor fallback
2025-10-09 00:25:21 +05:30
sriram veeraghanta 5d60d6d702 chore: adding types node package to pnpm catalog (#7923) 2025-10-08 18:42:53 +05:30
1456 changed files with 12112 additions and 7325 deletions
+6
View File
@@ -61,3 +61,9 @@ temp/
# Misc
*.pem
*.key
# React Router - https://github.com/remix-run/react-router-templates/blob/dc79b1a065f59f3bfd840d4ef75cc27689b611e6/default/.dockerignore
.react-router/
build/
node_modules/
README.md
+2 -1
View File
@@ -16,9 +16,10 @@ node_modules
/out/
# Production
/build
dist/
out/
build/
.react-router/
# Misc
.DS_Store
+14
View File
@@ -1,4 +1,18 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
rules: {
"no-duplicate-imports": "off",
"import/no-duplicates": ["error", { "prefer-inline": false }],
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"@typescript-eslint/no-import-type-side-effects": "error",
"@typescript-eslint/consistent-type-imports": [
"error",
{
prefer: "type-imports",
fixStyle: "separate-type-imports",
disallowTypeAnnotations: false,
},
],
},
};
+4 -3
View File
@@ -1,12 +1,13 @@
"use client";
import { FC } from "react";
import type { FC } from "react";
import { useForm } from "react-hook-form";
import { Lightbulb } from "lucide-react";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
import type { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
// components
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
@@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Artificial Intelligence Settings - God Mode",
@@ -1,6 +1,7 @@
"use client";
import { FC, useState } from "react";
import type { FC } from "react";
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
@@ -9,14 +10,16 @@ import { Monitor } from "lucide-react";
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
import type { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
import { CopyField, TCopyField } from "@/components/common/copy-field";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
@@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "GitHub Authentication - God Mode",
@@ -1,4 +1,5 @@
import { FC, useState } from "react";
import type { FC } from "react";
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
@@ -6,13 +7,15 @@ import { useForm } from "react-hook-form";
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
import type { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
import { CopyField, TCopyField } from "@/components/common/copy-field";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
@@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "GitLab Authentication - God Mode",
@@ -1,5 +1,6 @@
"use client";
import { FC, useState } from "react";
import type { FC } from "react";
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
@@ -8,13 +9,15 @@ import { Monitor } from "lucide-react";
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
import type { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
import { CopyField, TCopyField } from "@/components/common/copy-field";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
@@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Google Authentication - God Mode",
@@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Authentication Settings - Plane Web",
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
import { TInstanceConfigurationKeys } from "@plane/types";
import type { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
@@ -1,15 +1,17 @@
"use client";
import React, { FC, useMemo, useState } from "react";
import type { FC } from "react";
import React, { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
// types
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
import type { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
// ui
import { CustomSelect } from "@plane/ui";
// components
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
// local components
@@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import type { ReactNode } from "react";
import type { Metadata } from "next";
interface EmailLayoutProps {
children: ReactNode;
@@ -1,4 +1,5 @@
import React, { FC, useEffect, useState } from "react";
import type { FC } from "react";
import React, { useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { Button } from "@plane/propel/button";
@@ -1,12 +1,12 @@
"use client";
import { FC } from "react";
import type { FC } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Telescope } from "lucide-react";
// types
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { IInstance, IInstanceAdmin } from "@plane/types";
import type { IInstance, IInstanceAdmin } from "@plane/types";
// ui
import { Input, ToggleSwitch } from "@plane/ui";
// components
@@ -1,10 +1,11 @@
"use client";
import { FC, useState } from "react";
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { MessageSquare } from "lucide-react";
import { IFormattedInstanceConfiguration } from "@plane/types";
import type { IFormattedInstanceConfiguration } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
@@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "General Settings - God Mode",
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { FC } from "react";
import type { FC } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Menu, Settings } from "lucide-react";
@@ -1,9 +1,9 @@
"use client";
import { FC } from "react";
import type { FC } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
import type { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
// components
import { ControllerInput } from "@/components/common/controller-input";
// hooks
@@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import type { ReactNode } from "react";
import type { Metadata } from "next";
interface ImageLayoutProps {
children: ReactNode;
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { FC, ReactNode, useEffect } from "react";
import type { FC, ReactNode } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
// components
@@ -1,13 +1,14 @@
"use client";
import { FC, useState, useRef } from "react";
import type { FC } from "react";
import { useState, useRef } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
import { ExternalLink, 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 { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// hooks
@@ -20,7 +21,7 @@ const helpOptions = [
{
name: "Documentation",
href: "https://docs.plane.so/",
Icon: FileText,
Icon: PageIcon,
},
{
name: "Join our Discord",
@@ -110,7 +111,7 @@ export const AdminSidebarHelpSection: FC = observer(() => {
<Link href={href} key={name} target="_blank">
<div className="flex items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80">
<div className="grid flex-shrink-0 place-items-center">
<Icon className="h-3.5 w-3.5 text-custom-text-200" size={14} />
<Icon className="h-3.5 w-3.5 text-custom-text-200" width={14} height={14} />
</div>
<span className="text-xs">{name}</span>
</div>
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { FC, useEffect, useRef } from "react";
import type { FC } from "react";
import { useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
@@ -7,7 +7,7 @@ import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constan
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { InstanceWorkspaceService } from "@plane/services";
import { IWorkspace } from "@plane/types";
import type { IWorkspace } from "@plane/types";
// components
import { CustomSelect, Input } from "@plane/ui";
// hooks
@@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Workspace Management - God Mode",
@@ -8,7 +8,7 @@ import { Loader as LoaderIcon } from "lucide-react";
// types
import { Button, getButtonStyling } from "@plane/propel/button";
import { setPromiseToast } from "@plane/propel/toast";
import { TInstanceConfigurationKeys } from "@plane/types";
import type { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
+2 -2
View File
@@ -1,7 +1,7 @@
import { FC } from "react";
import type { FC } from "react";
import { Info, X } from "lucide-react";
// plane constants
import { TAdminAuthErrorInfo } from "@plane/constants";
import type { TAdminAuthErrorInfo } from "@plane/constants";
type TAuthBanner = {
bannerData: TAdminAuthErrorInfo | undefined;
+4 -3
View File
@@ -1,10 +1,11 @@
import { ReactNode } from "react";
import type { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import type { TAdminAuthErrorInfo } from "@plane/constants";
import { SUPPORT_EMAIL, EAdminAuthErrorCodes } from "@plane/constants";
import type { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// components
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
+4 -2
View File
@@ -1,10 +1,12 @@
"use client";
import { FC, useEffect, useMemo, useState } from "react";
import type { FC } from "react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import type { EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { API_BASE_URL } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { AuthService } from "@plane/services";
import { Input, Spinner } from "@plane/ui";
+1 -1
View File
@@ -1,4 +1,4 @@
import { FC, ReactNode } from "react";
import type { FC, ReactNode } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// hooks
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { ReactNode, createContext } from "react";
import type { ReactNode } from "react";
import { createContext } from "react";
// plane admin store
import { RootStore } from "@/plane-admin/store/root.store";
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { FC, ReactNode, useEffect } from "react";
import type { FC, ReactNode } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// hooks
+2 -2
View File
@@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import type { ReactNode } from "react";
import type { Metadata } from "next";
// plane imports
import { ADMIN_BASE_PATH } from "@plane/constants";
// styles
@@ -3,7 +3,7 @@ import Image from "next/image";
import { useTheme } from "next-themes";
import { KeyRound, Mails } from "lucide-react";
// types
import {
import type {
TGetBaseAuthenticationModeProps,
TInstanceAuthenticationMethodKeys,
TInstanceAuthenticationModes,
@@ -1,6 +1,6 @@
"use client";
import { FC } from "react";
import type { FC } from "react";
// helpers
import { cn } from "@plane/utils";
@@ -3,7 +3,7 @@
import React from "react";
import { observer } from "mobx-react";
// hooks
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { useInstance } from "@/hooks/store";
// ui
@@ -7,7 +7,7 @@ import Link from "next/link";
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
@@ -7,7 +7,7 @@ import Link from "next/link";
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
@@ -7,7 +7,7 @@ import Link from "next/link";
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
@@ -3,7 +3,7 @@
import React from "react";
import { observer } from "mobx-react";
// hooks
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { useInstance } from "@/hooks/store";
// ui
+1 -1
View File
@@ -1,4 +1,4 @@
import { FC } from "react";
import type { FC } from "react";
import { AlertCircle, CheckCircle2 } from "lucide-react";
type TBanner = {
@@ -1,7 +1,8 @@
"use client";
import React, { useState } from "react";
import { Controller, Control } from "react-hook-form";
import type { Control } from "react-hook-form";
import { Controller } from "react-hook-form";
// icons
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
@@ -1,5 +1,5 @@
"use client";
import { FC } from "react";
import type { FC } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
@@ -1,6 +1,6 @@
"use client";
import { FC } from "react";
import type { FC } from "react";
import Image from "next/image";
import Link from "next/link";
import { Button } from "@plane/propel/button";
@@ -1,6 +1,7 @@
"use client";
import { FC, useEffect, useMemo, useState } from "react";
import type { FC } from "react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
// icons
import { Eye, EyeOff } from "lucide-react";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/app/(all)/store.provider";
import { IInstanceStore } from "@/store/instance.store";
import type { IInstanceStore } from "@/store/instance.store";
export const useInstance = (): IInstanceStore => {
const context = useContext(StoreContext);
+1 -1
View File
@@ -1,7 +1,7 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/app/(all)/store.provider";
import { IThemeStore } from "@/store/theme.store";
import type { IThemeStore } from "@/store/theme.store";
export const useTheme = (): IThemeStore => {
const context = useContext(StoreContext);
+1 -1
View File
@@ -1,7 +1,7 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/app/(all)/store.provider";
import { IUserStore } from "@/store/user.store";
import type { IUserStore } from "@/store/user.store";
export const useUser = (): IUserStore => {
const context = useContext(StoreContext);
@@ -1,7 +1,7 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/app/(all)/store.provider";
import { IWorkspaceStore } from "@/store/workspace.store";
import type { IWorkspaceStore } from "@/store/workspace.store";
export const useWorkspace = (): IWorkspaceStore => {
const context = useContext(StoreContext);
+4 -3
View File
@@ -1,9 +1,10 @@
import { set } from "lodash-es";
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// plane internal packages
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
import type { TInstanceStatus } from "@plane/constants";
import { EInstanceStatus } from "@plane/constants";
import { InstanceService } from "@plane/services";
import {
import type {
IInstance,
IInstanceAdmin,
IInstanceConfiguration,
@@ -12,7 +13,7 @@ import {
IInstanceConfig,
} from "@plane/types";
// root store
import { CoreRootStore } from "@/store/root.store";
import type { CoreRootStore } from "@/store/root.store";
export interface IInstanceStore {
// issues
+8 -4
View File
@@ -1,9 +1,13 @@
import { enableStaticRendering } from "mobx-react";
// stores
import { IInstanceStore, InstanceStore } from "./instance.store";
import { IThemeStore, ThemeStore } from "./theme.store";
import { IUserStore, UserStore } from "./user.store";
import { IWorkspaceStore, WorkspaceStore } from "./workspace.store";
import type { IInstanceStore } from "./instance.store";
import { InstanceStore } from "./instance.store";
import type { IThemeStore } from "./theme.store";
import { ThemeStore } from "./theme.store";
import type { IUserStore } from "./user.store";
import { UserStore } from "./user.store";
import type { IWorkspaceStore } from "./workspace.store";
import { WorkspaceStore } from "./workspace.store";
enableStaticRendering(typeof window === "undefined");
+1 -1
View File
@@ -1,6 +1,6 @@
import { action, observable, makeObservable } from "mobx";
// root store
import { CoreRootStore } from "@/store/root.store";
import type { CoreRootStore } from "@/store/root.store";
type TTheme = "dark" | "light";
export interface IThemeStore {
+4 -3
View File
@@ -1,10 +1,11 @@
import { action, observable, runInAction, makeObservable } from "mobx";
// plane internal packages
import { EUserStatus, TUserStatus } from "@plane/constants";
import type { TUserStatus } from "@plane/constants";
import { EUserStatus } from "@plane/constants";
import { AuthService, UserService } from "@plane/services";
import { IUser } from "@plane/types";
import type { IUser } from "@plane/types";
// root store
import { CoreRootStore } from "@/store/root.store";
import type { CoreRootStore } from "@/store/root.store";
export interface IUserStore {
// observables
+2 -2
View File
@@ -2,9 +2,9 @@ import { set } from "lodash-es";
import { action, observable, runInAction, makeObservable, computed } from "mobx";
// plane imports
import { InstanceWorkspaceService } from "@plane/services";
import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
import type { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
// root store
import { CoreRootStore } from "@/store/root.store";
import type { CoreRootStore } from "@/store/root.store";
export interface IWorkspaceStore {
// observables
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "1.0.0",
"version": "1.1.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -46,7 +46,7 @@
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash-es": "catalog:",
"@types/node": "18.16.1",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.0.0",
"version": "1.1.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+94 -423
View File
@@ -12,13 +12,7 @@ from django.db.models import (
OuterRef,
Q,
Sum,
FloatField,
Case,
When,
Value,
)
from django.db.models.functions import Cast, Concat
from django.db import models
# Third party imports
from rest_framework import status
@@ -47,7 +41,7 @@ from plane.db.models import (
ProjectMember,
UserFavorite,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
@@ -201,7 +195,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now())
queryset = queryset.filter(
start_date__lte=timezone.now(), end_date__gte=timezone.now()
)
data = CycleSerializer(
queryset,
many=True,
@@ -258,7 +254,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
# Incomplete Cycles
if cycle_view == "incomplete":
queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True))
queryset = queryset.filter(
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)
)
return self.paginate(
request=request,
queryset=(queryset),
@@ -304,11 +302,17 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
Create a new development cycle with specified name, description, and date range.
Supports external ID tracking for integration purposes.
"""
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
if (
request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None
) or (
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
):
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
serializer = CycleCreateSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -351,7 +355,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
{"error": "Both start date and end date are either required or are to be null"},
{
"error": "Both start date and end date are either required or are to be null"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -499,7 +505,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
"""
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
if cycle.archived_at:
return Response(
@@ -512,14 +520,20 @@ class CycleDetailAPIEndpoint(BaseAPIView):
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
}
else:
return Response(
{"error": "The Cycle has already been completed so it cannot be edited"},
{
"error": "The Cycle has already been completed so it cannot be edited"
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
serializer = CycleUpdateSerializer(
cycle, data=request.data, partial=True, context={"request": request}
)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -527,7 +541,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
and Cycle.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source", cycle.external_source),
external_source=request.data.get(
"external_source", cycle.external_source
),
external_id=request.data.get("external_id"),
).exists()
):
@@ -584,7 +600,11 @@ class CycleDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
)
issue_activity.delay(
type="cycle.activity.deleted",
@@ -604,7 +624,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
# Delete the cycle
cycle.delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete()
UserFavorite.objects.filter(
entity_type="cycle", entity_identifier=pk, project_id=project_id
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -742,7 +764,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data,
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
).data,
)
@cycle_docs(
@@ -761,7 +785,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
Move a completed cycle to archived status for historical tracking.
Only cycles that have ended can be archived.
"""
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
if cycle.end_date >= timezone.now():
return Response(
{"error": "Only completed cycles can be archived"},
@@ -792,7 +818,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
Restore an archived cycle to active status, making it available for regular use.
The cycle will reappear in active cycle lists.
"""
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -855,7 +883,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
# List
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True)
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
@@ -892,7 +922,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(issues),
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
on_results=lambda issues: IssueSerializer(
issues, many=True, fields=self.fields, expand=self.expand
).data,
)
@cycle_docs(
@@ -922,10 +954,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
if not issues:
return Response(
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
if cycle.end_date is not None and cycle.end_date < timezone.now():
return Response(
@@ -937,9 +972,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
)
# Get all CycleWorkItems already created
cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues))
cycle_issues = list(
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
)
existing_issues = [
str(cycle_issue.issue_id) for cycle_issue in cycle_issues if str(cycle_issue.issue_id) in issues
str(cycle_issue.issue_id)
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
new_issues = list(set(issues) - set(existing_issues))
@@ -990,7 +1029,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize("json", created_records),
"created_cycle_issues": serializers.serialize(
"json", created_records
),
}
),
epoch=int(timezone.now().timestamp()),
@@ -1066,7 +1107,9 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView):
cycle_id=cycle_id,
issue_id=issue_id,
)
serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand)
serializer = CycleIssueSerializer(
cycle_issue, fields=self.fields, expand=self.expand
)
return Response(serializer.data, status=status.HTTP_200_OK)
@cycle_docs(
@@ -1171,406 +1214,34 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
{"error": "New Cycle Id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
old_cycle = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
)
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter(
old_cycle = Cycle.objects.get(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type:
assignee_estimate_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True,
then="assignees__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar": item.get("avatar", None),
"avatar_url": item.get("avatar_url", None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar": item.get("avatar", None),
"avatar_url": item.get("avatar_url", None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
pk=cycle_id,
)
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.total_issues,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
# transfer work items only when cycle is completed (passed the end data)
if old_cycle.end_date is not None and old_cycle.end_date < timezone.now():
return Response(
{"error": "The cycle where the issues are transferred is already completed"},
{"error": "The old cycle is not completed yet"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
# Call the utility function to handle the transfer
result = transfer_cycle_issues(
slug=slug,
project_id=project_id,
workspace__slug=slug,
issue__state__group__in=["backlog", "unstarted", "started"],
cycle_id=cycle_id,
new_cycle_id=new_cycle_id,
request=request,
user_id=self.request.user.id,
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
# Handle the result
if result.get("success"):
return Response({"message": "Success"}, status=status.HTTP_200_OK)
else:
return Response(
{"error": result.get("error")},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
+2
View File
@@ -17,6 +17,7 @@ from .views import urlpatterns as view_urls
from .webhook import urlpatterns as webhook_urls
from .workspace import urlpatterns as workspace_urls
from .timezone import urlpatterns as timezone_urls
from .exporter import urlpatterns as exporter_urls
urlpatterns = [
*analytic_urls,
@@ -38,4 +39,5 @@ urlpatterns = [
*api_urls,
*webhook_urls,
*timezone_urls,
*exporter_urls,
]
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from plane.app.views import ExportIssuesEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
]
-7
View File
@@ -7,7 +7,6 @@ from plane.app.views import (
IssueLinkViewSet,
IssueAttachmentEndpoint,
CommentReactionViewSet,
ExportIssuesEndpoint,
IssueActivityEndpoint,
IssueArchiveViewSet,
IssueCommentViewSet,
@@ -141,12 +140,6 @@ urlpatterns = [
IssueAttachmentV2Endpoint.as_view(),
name="project-issue-attachments",
),
## Export Issues
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
## End Issues
## Issue Activity
path(
+108 -425
View File
@@ -51,6 +51,7 @@ from plane.db.models import (
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.host import base_host
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
@@ -96,7 +97,9 @@ class CycleViewSet(BaseViewSet):
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(),
queryset=User.objects.only(
"avatar_asset", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
@@ -147,7 +150,8 @@ class CycleViewSet(BaseViewSet):
.annotate(
status=Case(
When(
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
Q(start_date__lte=current_time_in_utc)
& Q(end_date__gte=current_time_in_utc),
then=Value("CURRENT"),
),
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
@@ -166,7 +170,11 @@ class CycleViewSet(BaseViewSet):
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(issue_cycle__issue__assignees__id__isnull=True)
& (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)),
& (
Q(
issue_cycle__issue__issue_assignee__deleted_at__isnull=True
)
),
),
Value([], output_field=ArrayField(UUIDField())),
)
@@ -197,7 +205,9 @@ class CycleViewSet(BaseViewSet):
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc)
queryset = queryset.filter(
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
)
data = queryset.values(
# necessary fields
@@ -264,10 +274,16 @@ class CycleViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
if (
request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None
) or (
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
):
serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id})
serializer = CycleWriteSerializer(
data=request.data, context={"project_id": project_id}
)
if serializer.is_valid():
serializer.save(project_id=project_id, owned_by=request.user)
cycle = (
@@ -307,7 +323,9 @@ class CycleViewSet(BaseViewSet):
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(cycle, datetime_fields, project_timezone)
cycle = user_timezone_converter(
cycle, datetime_fields, project_timezone
)
# Send the model activity
model_activity.delay(
@@ -323,13 +341,17 @@ class CycleViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
{"error": "Both start date and end date are either required or are to be null"},
{
"error": "Both start date and end date are either required or are to be null"
},
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk)
queryset = self.get_queryset().filter(
workspace__slug=slug, project_id=project_id, pk=pk
)
cycle = queryset.first()
if cycle.archived_at:
return Response(
@@ -337,21 +359,29 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
request_data = request.data
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order for a completed cycle``
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
}
else:
return Response(
{"error": "The Cycle has already been completed so it cannot be edited"},
{
"error": "The Cycle has already been completed so it cannot be edited"
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id})
serializer = CycleWriteSerializer(
cycle, data=request.data, partial=True, context={"project_id": project_id}
)
if serializer.is_valid():
serializer.save()
cycle = queryset.values(
@@ -451,7 +481,9 @@ class CycleViewSet(BaseViewSet):
)
if data is None:
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
queryset = queryset.first()
# Fetch the project timezone
@@ -473,7 +505,11 @@ class CycleViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
)
issue_activity.delay(
type="cycle.activity.deleted",
@@ -524,7 +560,9 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
start_date = convert_to_utc(date=str(start_date), project_id=project_id, is_start_date=True)
start_date = convert_to_utc(
date=str(start_date), project_id=project_id, is_start_date=True
)
end_date = convert_to_utc(
date=str(end_date),
project_id=project_id,
@@ -597,409 +635,23 @@ class TransferCycleIssueEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
old_cycle = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
)
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type:
assignee_estimate_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True,
then="assignees__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar": item.get("avatar"),
"avatar_url": item.get("avatar_url"),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar": item.get("avatar"),
"avatar_url": item.get("avatar_url"),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle,
# Transfer cycle issues and create progress snapshot
result = transfer_cycle_issues(
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
new_cycle_id=new_cycle_id,
request=request,
user_id=request.user.id,
)
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.total_issues,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
# Handle error response
if result.get("error"):
return Response(
{"error": "The cycle where the issues are transferred is already completed"},
{"error": result["error"]},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
issue__state__group__in=["backlog", "unstarted", "started"],
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
@@ -1014,8 +666,12 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
)
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
cycle_properties.rich_filters = request.data.get("rich_filters", cycle_properties.rich_filters)
cycle_properties.display_filters = request.data.get("display_filters", cycle_properties.display_filters)
cycle_properties.rich_filters = request.data.get(
"rich_filters", cycle_properties.rich_filters
)
cycle_properties.display_filters = request.data.get(
"display_filters", cycle_properties.display_filters
)
cycle_properties.display_properties = request.data.get(
"display_properties", cycle_properties.display_properties
)
@@ -1039,9 +695,13 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
class CycleProgressEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first()
cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, id=cycle_id
).first()
if not cycle:
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
aggregate_estimates = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
@@ -1087,7 +747,9 @@ class CycleProgressEndpoint(BaseAPIView):
output_field=FloatField(),
)
),
total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()),
total_estimate_points=Sum(
"value_as_float", default=Value(0), output_field=FloatField()
),
)
)
if cycle.progress_snapshot:
@@ -1147,11 +809,22 @@ class CycleProgressEndpoint(BaseAPIView):
return Response(
{
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0,
"unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0,
"started_estimate_points": aggregate_estimates["started_estimate_point"] or 0,
"cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0,
"completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0,
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"]
or 0,
"unstarted_estimate_points": aggregate_estimates[
"unstarted_estimate_point"
]
or 0,
"started_estimate_points": aggregate_estimates["started_estimate_point"]
or 0,
"cancelled_estimate_points": aggregate_estimates[
"cancelled_estimate_point"
]
or 0,
"completed_estimate_points": aggregate_estimates[
"completed_estimate_points"
]
or 0,
"total_estimate_points": aggregate_estimates["total_estimate_points"],
"backlog_issues": backlog_issues,
"total_issues": total_issues,
@@ -1169,7 +842,9 @@ class CycleAnalyticsEndpoint(BaseAPIView):
def get(self, request, slug, project_id, cycle_id):
analytic_type = request.GET.get("type", "issues")
cycle = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id)
Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, id=cycle_id
)
.annotate(
total_issues=Count(
"issue_cycle__issue__id",
@@ -1252,7 +927,9 @@ class CycleAnalyticsEndpoint(BaseAPIView):
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -1287,7 +964,9 @@ class CycleAnalyticsEndpoint(BaseAPIView):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -1389,7 +1068,11 @@ class CycleAnalyticsEndpoint(BaseAPIView):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
total_issues=Count(
"label_id", filter=Q(archived_at__isnull=True, is_draft=False)
)
)
.annotate(
completed_issues=Count(
"label_id",
+17 -8
View File
@@ -137,7 +137,11 @@ class PageViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
# capture the page transaction
page_transaction.delay(request.data, None, serializer.data["id"])
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=None,
page_id=serializer.data["id"],
)
page = self.get_queryset().get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -168,11 +172,8 @@ class PageViewSet(BaseViewSet):
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data,
old_value=json.dumps(
{"description_html": page_description},
cls=DjangoJSONEncoder,
),
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page_description,
page_id=page_id,
)
@@ -504,7 +505,11 @@ class PagesDescriptionViewSet(BaseViewSet):
if serializer.is_valid():
# 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)
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page.description_html,
page_id=page_id,
)
# Update the page using serializer
updated_page = serializer.save()
@@ -550,7 +555,11 @@ class PageDuplicateEndpoint(BaseAPIView):
updated_by_id=page.updated_by_id,
)
page_transaction.delay({"description_html": page.description_html}, None, page.id)
page_transaction.delay(
new_description_html=page.description_html,
old_description_html=None,
page_id=page.id,
)
# Copy the s3 objects uploaded in the page
copy_s3_objects_of_description_and_assets.delay(
+2 -1
View File
@@ -410,7 +410,8 @@ def get_webhook_logs_queryset():
"response_headers",
"retry_count",
)
.iterator(chunk_size=BATCH_SIZE)
.order_by("created_at")
.iterator(chunk_size=100)
)
+43 -374
View File
@@ -1,82 +1,24 @@
# Python imports
import csv
import io
import json
import zipfile
from typing import List
from collections import defaultdict
import boto3
from botocore.client import Config
from uuid import UUID
from datetime import datetime, date
# Third party imports
from celery import shared_task
# Django imports
from django.conf import settings
from django.utils import timezone
from openpyxl import Workbook
from django.db.models import F, Prefetch
from collections import defaultdict
from django.db.models import Prefetch
# Module imports
from plane.db.models import ExporterHistory, Issue, FileAsset, Label, User, IssueComment
from plane.db.models import ExporterHistory, Issue, IssueRelation
from plane.utils.exception_logger import log_exception
def dateTimeConverter(time: datetime) -> str | None:
"""
Convert a datetime object to a formatted string.
"""
if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time: date) -> str | None:
"""
Convert a date object to a formatted string.
"""
if time:
return time.strftime("%a, %d %b %Y")
def create_csv_file(data: List[List[str]]) -> str:
"""
Create a CSV file from the provided data.
"""
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
for row in data:
csv_writer.writerow(row)
csv_buffer.seek(0)
return csv_buffer.getvalue()
def create_json_file(data: List[dict]) -> str:
"""
Create a JSON file from the provided data.
"""
return json.dumps(data)
def create_xlsx_file(data: List[List[str]]) -> bytes:
"""
Create an XLSX file from the provided data.
"""
workbook = Workbook()
sheet = workbook.active
for row in data:
sheet.append(row)
xlsx_buffer = io.BytesIO()
workbook.save(xlsx_buffer)
xlsx_buffer.seek(0)
return xlsx_buffer.getvalue()
from plane.utils.exporters import Exporter, IssueExportSchema
def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
@@ -118,7 +60,9 @@ def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug:
# Generate presigned url for the uploaded file with different base
presign_s3 = boto3.client(
"s3",
endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/", # noqa: E501
endpoint_url=(
f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/"
),
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
@@ -176,187 +120,6 @@ def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug:
exporter_instance.save(update_fields=["status", "url", "key"])
def generate_table_row(issue: dict) -> List[str]:
"""
Generate a table row from an issue dictionary.
"""
return [
f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
issue["project_name"],
issue["name"],
issue["description"],
issue["state_name"],
dateConverter(issue["start_date"]),
dateConverter(issue["target_date"]),
issue["priority"],
issue["created_by"],
", ".join(issue["labels"]) if issue["labels"] else "",
issue["cycle_name"],
issue["cycle_start_date"],
issue["cycle_end_date"],
", ".join(issue.get("module_name", "")) if issue.get("module_name") else "",
dateTimeConverter(issue["created_at"]),
dateTimeConverter(issue["updated_at"]),
dateTimeConverter(issue["completed_at"]),
dateTimeConverter(issue["archived_at"]),
(
", ".join(
[
f"{comment['comment']} ({comment['created_at']} by {comment['created_by']})"
for comment in issue["comments"]
]
)
if issue["comments"]
else ""
),
issue["estimate"] if issue["estimate"] else "",
", ".join(issue["link"]) if issue["link"] else "",
", ".join(issue["assignees"]) if issue["assignees"] else "",
issue["subscribers_count"] if issue["subscribers_count"] else "",
issue["attachment_count"] if issue["attachment_count"] else "",
", ".join(issue["attachment_links"]) if issue["attachment_links"] else "",
]
def generate_json_row(issue: dict) -> dict:
"""
Generate a JSON row from an issue dictionary.
"""
return {
"ID": f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project_name"],
"Name": issue["name"],
"Description": issue["description"],
"State": issue["state_name"],
"Start Date": dateConverter(issue["start_date"]),
"Target Date": dateConverter(issue["target_date"]),
"Priority": issue["priority"],
"Created By": (f"{issue['created_by']}" if issue["created_by"] else ""),
"Assignee": issue["assignees"],
"Labels": issue["labels"],
"Cycle Name": issue["cycle_name"],
"Cycle Start Date": issue["cycle_start_date"],
"Cycle End Date": issue["cycle_end_date"],
"Module Name": issue["module_name"],
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
"Archived At": dateTimeConverter(issue["archived_at"]),
"Comments": issue["comments"],
"Estimate": issue["estimate"],
"Link": issue["link"],
"Subscribers Count": issue["subscribers_count"],
"Attachment Count": issue["attachment_count"],
"Attachment Links": issue["attachment_links"],
}
def update_json_row(rows: List[dict], row: dict) -> None:
"""
Update the json row with the new assignee and label.
"""
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row["ID"] == row["ID"]),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = (
rows[matched_index]["Assignee"],
rows[matched_index]["Labels"],
)
assignee, label = row["Assignee"], row["Labels"]
if assignee is not None and (existing_assignees is None or label not in existing_assignees):
rows[matched_index]["Assignee"] += f", {assignee}"
if label is not None and (existing_labels is None or label not in existing_labels):
rows[matched_index]["Labels"] += f", {label}"
else:
rows.append(row)
def update_table_row(rows: List[List[str]], row: List[str]) -> None:
"""
Update the table row with the new assignee and label.
"""
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = rows[matched_index][7:9]
assignee, label = row[7:9]
if assignee is not None and (existing_assignees is None or label not in existing_assignees):
rows[matched_index][8] += f", {assignee}"
if label is not None and (existing_labels is None or label not in existing_labels):
rows[matched_index][8] += f", {label}"
else:
rows.append(row)
def generate_csv(
header: List[str],
project_id: str,
issues: List[dict],
files: List[tuple[str, str | bytes]],
) -> None:
"""
Generate CSV export for all the passed issues.
"""
rows = [header]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
csv_file = create_csv_file(rows)
files.append((f"{project_id}.csv", csv_file))
def generate_json(
header: List[str],
project_id: str,
issues: List[dict],
files: List[tuple[str, str | bytes]],
) -> None:
"""
Generate JSON export for all the passed issues.
"""
rows = []
for issue in issues:
row = generate_json_row(issue)
update_json_row(rows, row)
json_file = create_json_file(rows)
files.append((f"{project_id}.json", json_file))
def generate_xlsx(
header: List[str],
project_id: str,
issues: List[dict],
files: List[tuple[str, str | bytes]],
) -> None:
"""
Generate XLSX export for all the passed issues.
"""
rows = [header]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
xlsx_file = create_xlsx_file(rows)
files.append((f"{project_id}.xlsx", xlsx_file))
def get_created_by(obj: Issue | IssueComment) -> str:
"""
Get the created by user for the given object.
"""
if obj.created_by:
return f"{obj.created_by.first_name} {obj.created_by.last_name}"
return ""
@shared_task
def issue_export_task(
provider: str,
@@ -377,7 +140,7 @@ def issue_export_task(
exporter_instance.status = "processing"
exporter_instance.save(update_fields=["status"])
# Base query to get the issues
# Build base queryset for issues
workspace_issues = (
Issue.objects.filter(
workspace__id=workspace_id,
@@ -390,7 +153,6 @@ def issue_export_task(
"project",
"workspace",
"state",
"parent",
"created_by",
"estimate_point",
)
@@ -400,144 +162,51 @@ def issue_export_task(
"issue_module__module",
"issue_comments",
"assignees",
Prefetch(
"assignees",
queryset=User.objects.only("first_name", "last_name").distinct(),
to_attr="assignee_details",
),
Prefetch(
"labels",
queryset=Label.objects.only("name").distinct(),
to_attr="label_details",
),
"issue_subscribers",
"issue_link",
Prefetch(
"issue_relation",
queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"),
),
Prefetch(
"issue_related",
queryset=IssueRelation.objects.select_related("issue", "issue__project"),
),
Prefetch(
"parent",
queryset=Issue.objects.select_related("type", "project"),
),
)
)
# Get the attachments for the issues
file_assets = FileAsset.objects.filter(
issue_id__in=workspace_issues.values_list("id", flat=True),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
).annotate(work_item_id=F("issue_id"), asset_id=F("id"))
# Create a dictionary to store the attachments for the issues
attachment_dict = defaultdict(list)
for asset in file_assets:
attachment_dict[asset.work_item_id].append(asset.asset_id)
# Create a list to store the issues data
issues_data = []
# Iterate over the issues
for issue in workspace_issues:
attachments = attachment_dict.get(issue.id, [])
issue_data = {
"id": issue.id,
"project_identifier": issue.project.identifier,
"project_name": issue.project.name,
"project_id": issue.project.id,
"sequence_id": issue.sequence_id,
"name": issue.name,
"description": issue.description_stripped,
"priority": issue.priority,
"start_date": issue.start_date,
"target_date": issue.target_date,
"state_name": issue.state.name if issue.state else None,
"created_at": issue.created_at,
"updated_at": issue.updated_at,
"completed_at": issue.completed_at,
"archived_at": issue.archived_at,
"module_name": [module.module.name for module in issue.issue_module.all()],
"created_by": get_created_by(issue),
"labels": [label.name for label in issue.label_details],
"comments": [
{
"comment": comment.comment_stripped,
"created_at": dateConverter(comment.created_at),
"created_by": get_created_by(comment),
}
for comment in issue.issue_comments.all()
],
"estimate": issue.estimate_point.value if issue.estimate_point and issue.estimate_point.value else "",
"link": [link.url for link in issue.issue_link.all()],
"assignees": [f"{assignee.first_name} {assignee.last_name}" for assignee in issue.assignee_details],
"subscribers_count": issue.issue_subscribers.count(),
"attachment_count": len(attachments),
"attachment_links": [
f"/api/assets/v2/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{asset}/"
for asset in attachments
],
}
# Get Cycles data for the issue
cycle = issue.issue_cycle.last()
if cycle:
# Update cycle data
issue_data["cycle_name"] = cycle.cycle.name
issue_data["cycle_start_date"] = dateConverter(cycle.cycle.start_date)
issue_data["cycle_end_date"] = dateConverter(cycle.cycle.end_date)
else:
issue_data["cycle_name"] = ""
issue_data["cycle_start_date"] = ""
issue_data["cycle_end_date"] = ""
issues_data.append(issue_data)
# CSV header
header = [
"ID",
"Project",
"Name",
"Description",
"State",
"Start Date",
"Target Date",
"Priority",
"Created By",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Created At",
"Updated At",
"Completed At",
"Archived At",
"Comments",
"Estimate",
"Link",
"Assignees",
"Subscribers Count",
"Attachment Count",
"Attachment Links",
]
# Map the provider to the function
EXPORTER_MAPPER = {
"csv": generate_csv,
"json": generate_json,
"xlsx": generate_xlsx,
}
# Create exporter for the specified format
try:
exporter = Exporter(
format_type=provider,
schema_class=IssueExportSchema,
options={"list_joiner": ", "},
)
except ValueError as e:
# Invalid format type
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "failed"
exporter_instance.reason = str(e)
exporter_instance.save(update_fields=["status", "reason"])
return
files = []
if multiple:
project_dict = defaultdict(list)
for issue in issues_data:
project_dict[str(issue["project_id"])].append(issue)
# Export each project separately with its own queryset
for project_id in project_ids:
issues = project_dict.get(str(project_id), [])
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(header, project_id, issues, files)
project_issues = workspace_issues.filter(project_id=project_id)
export_filename = f"{slug}-{project_id}"
filename, content = exporter.export(export_filename, project_issues)
files.append((filename, content))
else:
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(header, workspace_id, issues_data, files)
# Export all issues in a single file
export_filename = f"{slug}-{workspace_id}"
filename, content = exporter.export(export_filename, workspace_issues)
files.append((filename, content))
zip_buffer = create_zip_file(files)
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
+105 -43
View File
@@ -1,5 +1,5 @@
# Python imports
import json
import logging
# Django imports
from django.utils import timezone
@@ -7,72 +7,134 @@ from django.utils import timezone
# Third-party imports
from bs4 import BeautifulSoup
# Module imports
from plane.db.models import Page, PageLog
# App imports
from celery import shared_task
from plane.db.models import Page, PageLog
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
def extract_components(value, tag):
COMPONENT_MAP = {
"mention-component": {
"attributes": ["id", "entity_identifier", "entity_name", "entity_type"],
"extract": lambda m: {
"entity_name": m.get("entity_name"),
"entity_type": None,
"entity_identifier": m.get("entity_identifier"),
},
},
"image-component": {
"attributes": ["id", "src"],
"extract": lambda m: {
"entity_name": "image",
"entity_type": None,
"entity_identifier": m.get("src"),
},
},
}
component_map = {
**COMPONENT_MAP,
}
def extract_all_components(description_html):
"""
Extracts all component types from the HTML value in a single pass.
Returns a dict mapping component_type -> list of extracted entities.
"""
try:
mentions = []
html = value.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(tag)
if not description_html:
return {component: [] for component in component_map.keys()}
for mention_tag in mention_tags:
mention = {
"id": mention_tag.get("id"),
"entity_identifier": mention_tag.get("entity_identifier"),
"entity_name": mention_tag.get("entity_name"),
}
mentions.append(mention)
soup = BeautifulSoup(description_html, "html.parser")
results = {}
for component, config in component_map.items():
attributes = config.get("attributes", ["id"])
component_tags = soup.find_all(component)
entities = []
for tag in component_tags:
entity = {attr: tag.get(attr) for attr in attributes}
entities.append(entity)
results[component] = entities
return results
return mentions
except Exception:
return []
return {component: [] for component in component_map.keys()}
def get_entity_details(component: str, mention: dict):
"""
Normalizes mention attributes into entity_name, entity_type, entity_identifier.
"""
config = component_map.get(component)
if not config:
return {"entity_name": None, "entity_type": None, "entity_identifier": None}
return config["extract"](mention)
@shared_task
def page_transaction(new_value, old_value, page_id):
def page_transaction(new_description_html, old_description_html, page_id):
"""
Tracks changes in page content (mentions, embeds, etc.)
and logs them in PageLog for audit and reference.
"""
try:
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
old_value = json.loads(old_value) if old_value else {}
has_existing_logs = PageLog.objects.filter(page_id=page_id).exists()
# Extract all components in a single pass (optimized)
old_components = extract_all_components(old_description_html)
new_components = extract_all_components(new_description_html)
new_transactions = []
deleted_transaction_ids = set()
# TODO - Add "issue-embed-component", "img", "todo" components
components = ["mention-component"]
for component in components:
old_mentions = extract_components(old_value, component)
new_mentions = extract_components(new_value, component)
for component in component_map.keys():
old_entities = old_components[component]
new_entities = new_components[component]
new_mentions_ids = {mention["id"] for mention in new_mentions}
old_mention_ids = {mention["id"] for mention in old_mentions}
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
old_ids = {m.get("id") for m in old_entities if m.get("id")}
new_ids = {m.get("id") for m in new_entities if m.get("id")}
deleted_transaction_ids.update(old_ids - new_ids)
new_transactions.extend(
PageLog(
transaction=mention["id"],
page_id=page_id,
entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"],
workspace_id=page.workspace_id,
created_at=timezone.now(),
updated_at=timezone.now(),
for mention in new_entities:
mention_id = mention.get("id")
if not mention_id or (mention_id in old_ids and has_existing_logs):
continue
details = get_entity_details(component, mention)
current_time = timezone.now()
new_transactions.append(
PageLog(
transaction=mention_id,
page_id=page_id,
entity_identifier=details["entity_identifier"],
entity_name=details["entity_name"],
entity_type=details["entity_type"],
workspace_id=page.workspace_id,
created_at=current_time,
updated_at=current_time,
)
)
for mention in new_mentions
if mention["id"] not in old_mention_ids or not new_page_mention
# Bulk insert and cleanup
if new_transactions:
PageLog.objects.bulk_create(
new_transactions, batch_size=50, ignore_conflicts=True
)
# Create new PageLog objects for new transactions
PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
if deleted_transaction_ids:
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
# Delete the removed transactions
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
except Page.DoesNotExist:
return
except Exception as e:
+71 -19
View File
@@ -48,6 +48,8 @@ from plane.db.models import (
)
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
from plane.settings.mongo import MongoConnection
SERIALIZER_MAPPER = {
"project": ProjectSerializer,
@@ -84,6 +86,58 @@ def get_issue_prefetches():
]
def save_webhook_log(
webhook: Webhook,
request_method: str,
request_headers: str,
request_body: str,
response_status: str,
response_headers: str,
response_body: str,
retry_count: int,
event_type: str,
) -> None:
# webhook_logs
mongo_collection = MongoConnection.get_collection("webhook_logs")
log_data = {
"workspace_id": str(webhook.workspace_id),
"webhook": str(webhook.id),
"event_type": str(event_type),
"request_method": str(request_method),
"request_headers": str(request_headers),
"request_body": str(request_body),
"response_status": str(response_status),
"response_headers": str(response_headers),
"response_body": str(response_body),
"retry_count": retry_count,
}
mongo_save_success = False
if mongo_collection is not None:
try:
# insert the log data into the mongo collection
mongo_collection.insert_one(log_data)
logger.info("Webhook log saved successfully to mongo")
mongo_save_success = True
except Exception as e:
log_exception(e)
logger.error(f"Failed to save webhook log: {e}")
mongo_save_success = False
# if the mongo save is not successful, save the log data into the database
if not mongo_save_success:
try:
# insert the log data into the database
WebhookLog.objects.create(**log_data)
logger.info("Webhook log saved successfully to database")
except Exception as e:
log_exception(e)
logger.error(f"Failed to save webhook log: {e}")
def get_model_data(event: str, event_id: Union[str, List[str]], many: bool = False) -> Dict[str, Any]:
"""
Retrieve and serialize model data based on the event type.
@@ -273,32 +327,30 @@ def webhook_send_task(
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
# Log the webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=str(response.status_code),
response_headers=str(response.headers),
response_body=str(response.text),
retry_count=str(self.request.retries),
save_webhook_log(
webhook=webhook,
request_method=action,
request_headers=headers,
request_body=payload,
response_status=response.status_code,
response_headers=response.headers,
response_body=response.text,
retry_count=self.request.retries,
event_type=event,
)
logger.info(f"Webhook {webhook.id} sent successfully")
except requests.RequestException as e:
# Log the failed webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
save_webhook_log(
webhook=webhook,
request_method=action,
request_headers=headers,
request_body=payload,
response_status=500,
response_headers="",
response_body=str(e),
retry_count=str(self.request.retries),
retry_count=self.request.retries,
event_type=event,
)
logger.error(f"Webhook {webhook.id} failed with error: {e}")
# Retry logic
+215 -7
View File
@@ -5,9 +5,11 @@ import time
import uuid
from typing import Dict
import logging
from datetime import timedelta
# Django imports
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
@@ -25,6 +27,13 @@ from plane.db.models import (
IssueLabel,
IssueSequence,
IssueActivity,
Page,
ProjectPage,
Cycle,
Module,
CycleIssue,
ModuleIssue,
IssueView,
)
logger = logging.getLogger("plane.worker")
@@ -116,13 +125,33 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
user_id=workspace_member["member_id"],
workspace_id=workspace.id,
display_filters={
"group_by": None,
"order_by": "sort_order",
"type": None,
"sub_issue": True,
"show_empty_groups": True,
"layout": "list",
"calendar_date_range": "",
"calendar": {"layout": "month", "show_weekends": False},
"group_by": "state",
"order_by": "sort_order",
"sub_issue": True,
"sub_group_by": None,
"show_empty_groups": True,
},
display_properties={
"key": True,
"link": True,
"cycle": False,
"state": True,
"labels": False,
"modules": False,
"assignee": True,
"due_date": False,
"estimate": True,
"priority": True,
"created_on": True,
"issue_type": True,
"start_date": False,
"updated_on": True,
"customer_count": True,
"sub_issue_count": False,
"attachment_count": False,
"customer_request_count": True,
},
created_by_id=workspace.created_by_id,
)
@@ -205,6 +234,8 @@ def create_project_issues(
project_map: Dict[int, uuid.UUID],
states_map: Dict[int, uuid.UUID],
labels_map: Dict[int, uuid.UUID],
cycles_map: Dict[int, uuid.UUID],
module_map: Dict[int, uuid.UUID],
) -> None:
"""Creates issues and their associated records for each project.
@@ -234,6 +265,8 @@ def create_project_issues(
labels = issue_seed.pop("labels")
project_id = issue_seed.pop("project_id")
state_id = issue_seed.pop("state_id")
cycle_id = issue_seed.pop("cycle_id")
module_ids = issue_seed.pop("module_ids")
issue = Issue.objects.create(
**issue_seed,
@@ -259,6 +292,7 @@ def create_project_issues(
epoch=time.time(),
)
# Create issue labels
for label_id in labels:
IssueLabel.objects.create(
issue=issue,
@@ -268,10 +302,172 @@ def create_project_issues(
created_by_id=workspace.created_by_id,
)
# Create cycle issues
if cycle_id:
CycleIssue.objects.create(
issue=issue,
cycle_id=cycles_map[cycle_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
)
# Create module issues
if module_ids:
for module_id in module_ids:
ModuleIssue.objects.create(
issue=issue,
module_id=module_map[module_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created")
return
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates pages for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
"""
page_seeds = read_seed_file("pages.json")
if not page_seeds:
return
for page_seed in page_seeds:
page_id = page_seed.pop("id")
page = Page.objects.create(
workspace_id=workspace.id,
is_global=False,
access=page_seed.get("access", Page.PUBLIC_ACCESS),
name=page_seed.get("name"),
description=page_seed.get("description", {}),
description_html=page_seed.get("description_html", "<p></p>"),
description_binary=page_seed.get("description_binary", None),
description_stripped=page_seed.get("description_stripped", None),
created_by_id=workspace.created_by_id,
updated_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Page {page_id} created")
if page_seed.get("project_id") and page_seed.get("type") == "PROJECT":
ProjectPage.objects.create(
workspace_id=workspace.id,
project_id=project_map[page_seed.get("project_id")],
page_id=page.id,
created_by_id=workspace.created_by_id,
updated_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Project Page {page_id} created")
return
def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
# Create cycles
cycle_seeds = read_seed_file("cycles.json")
if not cycle_seeds:
return
cycle_map: Dict[int, uuid.UUID] = {}
for cycle_seed in cycle_seeds:
cycle_id = cycle_seed.pop("id")
project_id = cycle_seed.pop("project_id")
type = cycle_seed.pop("type")
if type == "CURRENT":
start_date = timezone.now()
end_date = start_date + timedelta(days=14)
if type == "UPCOMING":
# Get the last cycle
last_cycle = Cycle.objects.filter(project_id=project_map[project_id]).order_by("-end_date").first()
if last_cycle:
start_date = last_cycle.end_date + timedelta(days=1)
end_date = start_date + timedelta(days=14)
else:
start_date = timezone.now() + timedelta(days=14)
end_date = start_date + timedelta(days=14)
cycle = Cycle.objects.create(
**cycle_seed,
start_date=start_date,
end_date=end_date,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
cycle_map[cycle_id] = cycle.id
logger.info(f"Task: workspace_seed_task -> Cycle {cycle_id} created")
return cycle_map
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates modules for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
"""
module_seeds = read_seed_file("modules.json")
if not module_seeds:
return
module_map: Dict[int, uuid.UUID] = {}
for index, module_seed in enumerate(module_seeds):
module_id = module_seed.pop("id")
project_id = module_seed.pop("project_id")
start_date = timezone.now() + timedelta(days=index * 2)
end_date = start_date + timedelta(days=14)
module = Module.objects.create(
**module_seed,
start_date=start_date,
target_date=end_date,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
)
module_map[module_id] = module.id
logger.info(f"Task: workspace_seed_task -> Module {module_id} created")
return module_map
def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates views for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
"""
view_seeds = read_seed_file("views.json")
if not view_seeds:
return
for view_seed in view_seeds:
project_id = view_seed.pop("project_id")
IssueView.objects.create(
**view_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
@shared_task
def workspace_seed(workspace_id: uuid.UUID) -> None:
"""Seeds a new workspace with initial project data.
@@ -299,8 +495,20 @@ def workspace_seed(workspace_id: uuid.UUID) -> None:
# Create project labels
label_map = create_project_labels(workspace, project_map)
# Create project cycles
cycle_map = create_cycles(workspace, project_map)
# Create project modules
module_map = create_modules(workspace, project_map)
# create project issues
create_project_issues(workspace, project_map, state_map, label_map)
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map)
# create project views
create_views(workspace, project_map)
# create project pages
create_pages(workspace, project_map)
logger.info(f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully")
return
+15
View File
@@ -273,6 +273,21 @@ class IssueRelationChoices(models.TextChoices):
IMPLEMENTED_BY = "implemented_by", "Implemented By"
# Bidirectional relation pairs: (forward, reverse)
# Defined after class to avoid enum metaclass conflicts
IssueRelationChoices._RELATION_PAIRS = (
("blocked_by", "blocking"),
("relates_to", "relates_to"), # symmetric
("duplicate", "duplicate"), # symmetric
("start_before", "start_after"),
("finish_before", "finish_after"),
("implemented_by", "implements"),
)
# Generate reverse mapping from pairs
IssueRelationChoices._REVERSE_MAPPING = {forward: reverse for forward, reverse in IssueRelationChoices._RELATION_PAIRS}
class IssueRelation(ProjectBaseModel):
issue = models.ForeignKey(Issue, related_name="issue_relation", on_delete=models.CASCADE)
related_issue = models.ForeignKey(Issue, related_name="issue_related", on_delete=models.CASCADE)
-1
View File
@@ -12,7 +12,6 @@ from rest_framework.request import Request
from plane.utils.ip_address import get_client_ip
from plane.db.models import APIActivityLog
api_logger = logging.getLogger("plane.api.request")
@@ -0,0 +1,27 @@
from django.core.exceptions import RequestDataTooBig
from django.http import JsonResponse
class RequestBodySizeLimitMiddleware:
"""
Middleware to catch RequestDataTooBig exceptions and return
413 Request Entity Too Large instead of 400 Bad Request.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
try:
_ = request.body
except RequestDataTooBig:
return JsonResponse(
{
"error": "REQUEST_BODY_TOO_LARGE",
"detail": "The size of the request body exceeds the maximum allowed size.",
},
status=413,
)
# If body size is OK, continue with the request
return self.get_response(request)
+18
View File
@@ -0,0 +1,18 @@
[
{
"id": 1,
"name": "Cycle 1: Getting Started with Plane",
"project_id": 1,
"sort_order": 1,
"timezone": "UTC",
"type": "CURRENT"
},
{
"id": 2,
"name": "Cycle 2: Collaboration & Customization",
"project_id": 1,
"sort_order": 2,
"timezone": "UTC",
"type": "UPCOMING"
}
]
+29 -15
View File
@@ -6,10 +6,12 @@
"description_html": "<p class=\"editor-paragraph-block\">Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.</p><p class=\"editor-paragraph-block\">Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.</p><p class=\"editor-paragraph-block\">First thing to try</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look in the <strong>Properties</strong> section below where it says <strong>State: Todo</strong>.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click on it and change it to <strong>Done</strong> from the dropdown. Alternatively, you can drag and drop the card to the Done column.</p></li></ol>",
"description_stripped": "Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.First thing to tryLook in the Properties section below where it says State: Todo.Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.",
"sort_order": 1000,
"state_id": 3,
"state_id": 4,
"labels": [],
"priority": "none",
"project_id": 1
"priority": "urgent",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1]
},
{
"id": 2,
@@ -19,8 +21,10 @@
"sort_order": 2000,
"state_id": 2,
"labels": [2],
"priority": "none",
"project_id": 1
"priority": "high",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1]
},
{
"id": 3,
@@ -31,8 +35,10 @@
"sort_order": 3000,
"state_id": 1,
"labels": [],
"priority": "none",
"project_id": 1
"priority": "high",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1, 2]
},
{
"id": 4,
@@ -41,10 +47,12 @@
"description_html": "<p class=\"editor-paragraph-block\">A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.</p><p class=\"editor-paragraph-block\">Ready to add something to your project's to-do list? Here's how:</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Add work item</strong> button in the top-right corner of the Work Items page.</p><image-component src=\"https://media.docs.plane.so/seed_assets/41.png\" width=\"1085.380859375px\" height=\"482.53758375605696px\" id=\"ba055bc3-4162-4750-9ad4-9434fc0e7121\" aspectratio=\"2.249318801089918\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Give your task a clear title and add any details in the description.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Set up the essentials:</p><ul class=\"list-disc pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Assign it to a team member (or yourself!)</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Choose a priority level</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Add start and due dates if there's a timeline</p></li></ul></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> Save time by using the keyboard shortcut <strong>C</strong> from anywhere in your project to quickly create a new work item!</p></div><div class=\"py-4 border-custom-border-400\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\">Want to dive deeper into all the things you can do with work items? Check out our <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/issues/overview\">documentation</a>.</p>",
"description_stripped": "A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.Ready to add something to your project's to-do list? Here's how:Click the Add work item button in the top-right corner of the Work Items page.Give your task a clear title and add any details in the description.Set up the essentials:Assign it to a team member (or yourself!)Choose a priority levelAdd start and due dates if there's a timelineTip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!Want to dive deeper into all the things you can do with work items? Check out our documentation.",
"sort_order": 4000,
"state_id": 1,
"state_id": 3,
"labels": [2],
"priority": "none",
"project_id": 1
"priority": "high",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1, 2]
},
{
"id": 5,
@@ -53,10 +61,12 @@
"description_html": "<p class=\"editor-paragraph-block\">Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!</p><image-component src=\"https://media.docs.plane.so/seed_assets/51.png\" aspectratio=\"4.489130434782608\"></image-component><h2 class=\"editor-heading-block\">Switch between layouts</h2><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look at the top toolbar in your project. You'll see several layout icons.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click any of these icons to instantly switch between layouts.</p></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/issues/layouts\"><strong>Layouts</strong></a> for more info.</p></div><h2 class=\"editor-heading-block\">Filter and display options</h2><p class=\"editor-paragraph-block\">Need to focus on specific work?</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Filters</strong> dropdown in the toolbar. Select criteria and choose which items to show.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Display</strong> dropdown to tailor how the information appears in your layout</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Created the perfect setup? Save it for later by clicking the the <strong>Save View</strong> button.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Access saved views anytime from the <strong>Views</strong> section in your sidebar.</p></li></ol>",
"description_stripped": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!Switch between layoutsLook at the top toolbar in your project. You'll see several layout icons.Click any of these icons to instantly switch between layouts.Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.Filter and display optionsNeed to focus on specific work?Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.Click the Display dropdown to tailor how the information appears in your layoutCreated the perfect setup? Save it for later by clicking the the Save View button.Access saved views anytime from the Views section in your sidebar.",
"sort_order": 5000,
"state_id": 1,
"state_id": 3,
"labels": [],
"priority": "none",
"project_id": 1
"project_id": 1,
"cycle_id": 2,
"module_ids": [2]
},
{
"id": 6,
@@ -67,8 +77,10 @@
"sort_order": 6000,
"state_id": 1,
"labels": [2],
"priority": "none",
"project_id": 1
"priority": "low",
"project_id": 1,
"cycle_id": 2,
"module_ids": [2, 3]
},
{
"id": 7,
@@ -80,6 +92,8 @@
"state_id": 1,
"labels": [],
"priority": "none",
"project_id": 1
"project_id": 1,
"cycle_id": 2,
"module_ids": [2, 3]
}
]
+26
View File
@@ -0,0 +1,26 @@
[
{
"id": 1,
"name": "Core Workflow (System)",
"project_id": 1,
"sort_order": 1,
"status": "planned",
"description": "Manage, visualize, and track your work items across views."
},
{
"id": 2,
"name": "Onboarding Flow (Feature)",
"project_id": 1,
"sort_order": 2,
"status": "backlog",
"description": "Everything about getting started - creating a project, inviting teammates."
},
{
"id": 3,
"name": "Workspace Setup (Area)",
"project_id": 1,
"sort_order": 3,
"status": "in-progress",
"description": "The personalization layer - settings, labels, automations."
}
]
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
[
{
"id": 1,
"name": "Project Urgent Tasks",
"description": "Project Urgent Tasks",
"access": 1,
"filters": {},
"project_id": 1,
"display_filters": {"layout": "list", "calendar": {"layout": "month", "show_weekends": false}, "group_by": "state", "order_by": "sort_order", "sub_issue": false, "sub_group_by": null, "show_empty_groups": false},
"display_properties": {"key": true, "link": true, "cycle": true, "state": true, "labels": true, "modules": true, "assignee": true, "due_date": true, "estimate": true, "priority": true, "created_on": true, "issue_type": true, "start_date": true, "updated_on": true, "customer_count": true, "sub_issue_count": true, "attachment_count": true, "customer_request_count": true},
"sort_order": 75535,
"rich_filters": {"priority__in": "urgent"}
}
]
+3 -2
View File
@@ -62,6 +62,7 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"crum.CurrentRequestUserMiddleware",
"django.middleware.gzip.GZipMiddleware",
"plane.middleware.request_body_size.RequestBodySizeLimitMiddleware",
"plane.middleware.logger.APITokenLogMiddleware",
"plane.middleware.logger.RequestLoggerMiddleware",
]
@@ -299,14 +300,14 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
SESSION_COOKIE_SECURE = secure_origins
SESSION_COOKIE_HTTPONLY = True
SESSION_ENGINE = "plane.db.models.session"
SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800)
SESSION_COOKIE_AGE = int(os.environ.get("SESSION_COOKIE_AGE", 604800))
SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id")
SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
SESSION_SAVE_EVERY_REQUEST = os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1"
# Admin Cookie
ADMIN_SESSION_COOKIE_NAME = "admin-session-id"
ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)
ADMIN_SESSION_COOKIE_AGE = int(os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600))
# CSRF cookies
CSRF_COOKIE_SECURE = secure_origins
+7 -2
View File
@@ -4,7 +4,9 @@ import nh3
from plane.utils.exception_logger import log_exception
from bs4 import BeautifulSoup
from collections import defaultdict
import logging
logger = logging.getLogger("plane.api")
# Maximum allowed size for binary data (10MB)
MAX_SIZE = 10 * 1024 * 1024
@@ -54,7 +56,9 @@ def validate_binary_data(data):
# Check for suspicious text patterns (HTML/JS)
try:
decoded_text = binary_data.decode("utf-8", errors="ignore")[:200]
if any(pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS):
if any(
pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS
):
return False, "Binary data contains suspicious content patterns"
except Exception:
pass # Binary data might not be decodable as text, which is fine
@@ -232,8 +236,9 @@ def validate_html_content(html_content: str):
summary = json.dumps(diff)
except Exception:
summary = str(diff)
logger.warning(f"HTML sanitization removals: {summary}")
log_exception(
f"HTML sanitization removals: {summary}",
ValueError(f"HTML sanitization removals: {summary}"),
warning=True,
)
return True, None, clean_html
@@ -0,0 +1,486 @@
# Python imports
import json
# Django imports
from django.db.models import (
Case,
Count,
F,
Q,
Sum,
FloatField,
Value,
When,
)
from django.db import models
from django.db.models.functions import Cast, Concat
from django.utils import timezone
# Module imports
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
Project,
)
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host
def transfer_cycle_issues(
slug,
project_id,
cycle_id,
new_cycle_id,
request,
user_id,
):
"""
Transfer incomplete issues from one cycle to another and create progress snapshot.
Args:
slug: Workspace slug
project_id: Project ID
cycle_id: Source cycle ID
new_cycle_id: Destination cycle ID
request: HTTP request object
user_id: User ID performing the transfer
Returns:
dict: Response data with success or error message
"""
# Get the new cycle
new_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
).first()
# Check if new cycle is already completed
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
return {
"success": False,
"error": "The cycle where the issues are transferred is already completed",
}
# Get the old cycle with issue counts
old_cycle = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
)
old_cycle = old_cycle.first()
if old_cycle is None:
return {
"success": False,
"error": "Source cycle not found",
}
# Check if project uses estimates
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
# Initialize estimate distribution variables
assignee_estimate_distribution = []
label_estimate_distribution = []
estimate_completion_chart = {}
if estimate_type:
assignee_estimate_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset",
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True,
then="assignees__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# Assignee estimate distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar_url": item.get("avatar_url"),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label estimate distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset",
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# Assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar_url": item.get("avatar_url"),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Label distribution serialization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
# Generate completion chart
completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
)
# Get the current cycle and save progress snapshot
current_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.total_issues,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
# Get issues to transfer (only incomplete issues)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
issue__archived_at__isnull=True,
issue__is_draft=False,
issue__state__group__in=["backlog", "unstarted", "started"],
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
# Bulk update cycle issues
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(user_id),
issue_id=None,
project_id=str(project_id),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": [],
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return {"success": True}
+496
View File
@@ -0,0 +1,496 @@
# 📊 Exporters
A flexible and extensible data export utility for exporting Django model data in multiple formats (CSV, JSON, XLSX).
## 🎯 Overview
The exporters module provides a schema-based approach to exporting data with support for:
- **📄 Multiple formats**: CSV, JSON, and XLSX (Excel)
- **🔒 Type-safe field definitions**: StringField, NumberField, DateField, DateTimeField, BooleanField, ListField, JSONField
- **⚡ Custom transformations**: Field-level transformations and custom preparer methods
- **🔗 Dotted path notation**: Easy access to nested attributes and related models
- **🎨 Format-specific handling**: Automatic formatting based on export format (e.g., lists as arrays in JSON, comma-separated in CSV)
## 🚀 Quick Start
### Basic Usage
```python
from plane.utils.exporters import Exporter, ExportSchema, StringField, NumberField
# Define a schema
class UserExportSchema(ExportSchema):
name = StringField(source="username", label="User Name")
email = StringField(source="email", label="Email Address")
posts_count = NumberField(label="Total Posts")
def prepare_posts_count(self, obj):
return obj.posts.count()
# Export data - just pass the queryset!
users = User.objects.all()
exporter = Exporter(format_type="csv", schema_class=UserExportSchema)
filename, content = exporter.export("users_export", users)
```
### Exporting Issues
```python
from plane.utils.exporters import Exporter, IssueExportSchema
# Get issues with prefetched relations
issues = Issue.objects.filter(project_id=project_id).prefetch_related(
'assignee_details',
'label_details',
'issue_module',
# ... other relations
)
# Export as XLSX - pass the queryset directly!
exporter = Exporter(format_type="xlsx", schema_class=IssueExportSchema)
filename, content = exporter.export("issues", issues)
# Export with custom fields only
exporter = Exporter(format_type="json", schema_class=IssueExportSchema)
filename, content = exporter.export("issues_filtered", issues, fields=["id", "name", "state_name", "assignees"])
```
### Exporting Multiple Projects Separately
```python
# Export each project to a separate file
for project_id in project_ids:
project_issues = issues.filter(project_id=project_id)
exporter = Exporter(format_type="csv", schema_class=IssueExportSchema)
filename, content = exporter.export(f"issues-{project_id}", project_issues)
# Save or upload the file
```
## 📝 Schema Definition
### Field Types
#### 📝 StringField
Converts values to strings.
```python
name = StringField(source="name", label="Name", default="N/A")
```
#### 🔢 NumberField
Handles numeric values (int, float).
```python
count = NumberField(source="items_count", label="Count", default=0)
```
#### 📅 DateField
Formats date objects as `%a, %d %b %Y` (e.g., "Mon, 01 Jan 2024").
```python
start_date = DateField(source="start_date", label="Start Date")
```
#### ⏰ DateTimeField
Formats datetime objects as `%a, %d %b %Y %I:%M:%S %Z%z`.
```python
created_at = DateTimeField(source="created_at", label="Created At")
```
#### ✅ BooleanField
Converts values to boolean.
```python
is_active = BooleanField(source="is_active", label="Active", default=False)
```
#### 📋 ListField
Handles list/array values. In CSV/XLSX, lists are joined with a separator (default: `", "`). In JSON, they remain as arrays.
```python
tags = ListField(source="tags", label="Tags")
assignees = ListField(label="Assignees") # Custom preparer can populate this
```
#### 🗂️ JSONField
Handles complex JSON-serializable objects (dicts, lists of dicts). In CSV/XLSX, they're serialized as JSON strings. In JSON, they remain as objects.
```python
metadata = JSONField(source="metadata", label="Metadata")
comments = JSONField(label="Comments")
```
### ⚙️ Field Parameters
All field types support these parameters:
- **`source`**: Dotted path string to the attribute (e.g., `"project.name"`)
- **`default`**: Default value when field is None
- **`label`**: Display name in export headers
### 🔗 Dotted Path Notation
Access nested attributes using dot notation:
```python
project_name = StringField(source="project.name", label="Project")
owner_email = StringField(source="created_by.email", label="Owner Email")
```
### 🎯 Custom Preparers
For complex logic, define `prepare_{field_name}` methods:
```python
class MySchema(ExportSchema):
assignees = ListField(label="Assignees")
def prepare_assignees(self, obj):
return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details]
```
Preparers take precedence over field definitions.
### ⚡ Custom Transformations with Preparer Methods
For any custom logic or transformations, use `prepare_<field_name>` methods:
```python
class MySchema(ExportSchema):
name = StringField(source="name", label="Name (Uppercase)")
status = StringField(label="Status")
def prepare_name(self, obj):
"""Transform the name field to uppercase."""
return obj.name.upper() if obj.name else ""
def prepare_status(self, obj):
"""Compute status based on model state."""
return "Active" if obj.is_active else "Inactive"
```
## 📦 Export Formats
### 📊 CSV Format
- Fields are quoted with `QUOTE_ALL`
- Lists are joined with `", "` (customizable with `list_joiner` option)
- JSON objects are serialized as JSON strings
- File extension: `.csv`
```python
exporter = Exporter(
format_type="csv",
schema_class=MySchema,
options={"list_joiner": "; "} # Custom separator
)
```
### 📋 JSON Format
- Lists remain as arrays
- Objects remain as nested structures
- Preserves data types
- File extension: `.json`
```python
exporter = Exporter(format_type="json", schema_class=MySchema)
filename, content = exporter.export("data", records)
# content is a JSON string: '[{"field": "value"}, ...]'
```
### 📗 XLSX Format
- Creates Excel-compatible files using openpyxl
- Lists are joined with `", "` (customizable with `list_joiner` option)
- JSON objects are serialized as JSON strings
- File extension: `.xlsx`
- Returns binary content (bytes)
```python
exporter = Exporter(format_type="xlsx", schema_class=MySchema)
filename, content = exporter.export("data", records)
# content is bytes
```
## 🔧 Advanced Usage
### 📦 Using Context for Pre-fetched Data
Pass context data to schemas to avoid N+1 queries. Override `get_context_data()` in your schema:
```python
class MySchema(ExportSchema):
attachment_count = NumberField(label="Attachments")
def prepare_attachment_count(self, obj):
attachments_dict = self.context.get("attachments_dict", {})
return len(attachments_dict.get(obj.id, []))
@classmethod
def get_context_data(cls, queryset):
"""Pre-fetch all attachments in one query."""
attachments_dict = get_attachments_dict(queryset)
return {"attachments_dict": attachments_dict}
# The Exporter automatically uses get_context_data() when serializing
queryset = MyModel.objects.all()
exporter = Exporter(format_type="csv", schema_class=MySchema)
filename, content = exporter.export("data", queryset)
```
### 🔌 Registering Custom Formatters
Add support for new export formats:
```python
from plane.utils.exporters import Exporter, BaseFormatter
class XMLFormatter(BaseFormatter):
def format(self, filename, records, schema_class, options=None):
# Implementation
return (f"{filename}.xml", xml_content)
# Register the formatter
Exporter.register_formatter("xml", XMLFormatter)
# Use it
exporter = Exporter(format_type="xml", schema_class=MySchema)
```
### ✅ Checking Available Formats
```python
formats = Exporter.get_available_formats()
# Returns: ['csv', 'json', 'xlsx']
```
### 🔍 Filtering Fields
Pass a `fields` parameter to export only specific fields:
```python
# Export only specific fields
exporter = Exporter(format_type="csv", schema_class=MySchema)
filename, content = exporter.export(
"filtered_data",
queryset,
fields=["id", "name", "email"]
)
```
### 🎯 Extending Schemas
Create extended schemas by inheriting from existing ones and overriding `get_context_data()`:
```python
class ExtendedIssueExportSchema(IssueExportSchema):
custom_field = JSONField(label="Custom Data")
def prepare_custom_field(self, obj):
# Use pre-fetched data from context
return self.context.get("custom_data", {}).get(obj.id, {})
@classmethod
def get_context_data(cls, queryset):
# Get parent context (attachments, etc.)
context = super().get_context_data(queryset)
# Add your custom pre-fetched data
context["custom_data"] = fetch_custom_data(queryset)
return context
```
### 💾 Manual Serialization
If you need to serialize data without exporting, you can use the schema directly:
```python
# Serialize a queryset to a list of dicts
data = MySchema.serialize_queryset(queryset, fields=["id", "name"])
# Or serialize a single object
schema = MySchema()
obj_data = schema.serialize(obj)
```
## 💡 Example: IssueExportSchema
The `IssueExportSchema` demonstrates a complete implementation:
```python
from plane.utils.exporters import Exporter, IssueExportSchema
# Simple export - just pass the queryset!
issues = Issue.objects.filter(project_id=project_id)
exporter = Exporter(format_type="csv", schema_class=IssueExportSchema)
filename, content = exporter.export("issues", issues)
# Export specific fields only
filename, content = exporter.export(
"issues_filtered",
issues,
fields=["id", "name", "state_name", "assignees", "labels"]
)
# Export multiple projects to separate files
for project_id in project_ids:
project_issues = issues.filter(project_id=project_id)
filename, content = exporter.export(f"issues-{project_id}", project_issues)
# Save or upload each file
```
Key features:
- 🔗 Access to related models via dotted paths
- 🎯 Custom preparers for complex fields
- 📎 Context-based attachment handling via `get_context_data()`
- 📋 List and JSON field handling
- 📅 Date/datetime formatting
## ✨ Best Practices
1. **🚄 Avoid N+1 Queries**: Override `get_context_data()` to pre-fetch related data:
```python
@classmethod
def get_context_data(cls, queryset):
return {
"attachments": get_attachments_dict(queryset),
"comments": get_comments_dict(queryset),
}
```
2. **🏷️ Use Labels**: Provide descriptive labels for better export headers:
```python
created_at = DateTimeField(source="created_at", label="Created At")
```
3. **🛡️ Handle None Values**: Set appropriate defaults for fields that might be None:
```python
count = NumberField(source="count", default=0)
```
4. **🎯 Use Preparers for Complex Logic**: Keep field definitions simple and use preparers for complex transformations:
```python
def prepare_assignees(self, obj):
return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details]
```
5. **⚡ Pass QuerySets Directly**: Let the Exporter handle serialization:
```python
# Good - Exporter handles serialization
exporter.export("data", queryset)
# Avoid - Manual serialization unless needed
data = MySchema.serialize_queryset(queryset)
exporter.export("data", data)
```
6. **📦 Filter QuerySets, Not Data**: For multiple exports, filter the queryset instead of the serialized data:
```python
# Good - efficient, only serializes what's needed
for project_id in project_ids:
project_issues = issues.filter(project_id=project_id)
exporter.export(f"project-{project_id}", project_issues)
# Avoid - serializes all data upfront
all_data = MySchema.serialize_queryset(issues)
for project_id in project_ids:
project_data = [d for d in all_data if d['project_id'] == project_id]
exporter.export(f"project-{project_id}", project_data)
```
## 📚 API Reference
### 📊 Exporter
**`__init__(format_type, schema_class, options=None)`**
- `format_type`: Export format ('csv', 'json', 'xlsx')
- `schema_class`: Schema class defining fields
- `options`: Optional dict of format-specific options
**`export(filename, data, fields=None)`**
- `filename`: Filename without extension
- `data`: Django QuerySet or list of dicts
- `fields`: Optional list of field names to include
- Returns: `(filename_with_extension, content)`
- `content` is str for CSV/JSON, bytes for XLSX
**`get_available_formats()`** (class method)
- Returns: List of available format types
**`register_formatter(format_type, formatter_class)`** (class method)
- Register a custom formatter
### 📝 ExportSchema
**`__init__(context=None)`**
- `context`: Optional dict accessible in preparer methods via `self.context` for pre-fetched data
**`serialize(obj, fields=None)`**
- Returns: Dict of serialized field values for a single object
**`serialize_queryset(queryset, fields=None)`** (class method)
- `queryset`: QuerySet of objects to serialize
- `fields`: Optional list of field names to include
- Returns: List of dicts with serialized data
**`get_context_data(queryset)`** (class method)
- Override to pre-fetch related data for the queryset
- Returns: Dict of context data
### 🔧 ExportField
Base class for all field types. Subclass to create custom field types.
**`get_value(obj, context)`**
- Returns: Formatted value for the field
**`_format_value(raw)`**
- Override in subclasses for type-specific formatting
## 🧪 Testing
```python
# Test exporting a queryset
queryset = MyModel.objects.all()
exporter = Exporter(format_type="json", schema_class=MySchema)
filename, content = exporter.export("test", queryset)
assert filename == "test.json"
assert isinstance(content, str)
# Test with field filtering
filename, content = exporter.export("test", queryset, fields=["id", "name"])
data = json.loads(content)
assert all(set(item.keys()) == {"id", "name"} for item in data)
# Test manual serialization
data = MySchema.serialize_queryset(queryset)
assert len(data) == queryset.count()
```
@@ -0,0 +1,38 @@
"""Export utilities for various data formats."""
from .exporter import Exporter
from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter
from .schemas import (
BooleanField,
DateField,
DateTimeField,
ExportField,
ExportSchema,
IssueExportSchema,
JSONField,
ListField,
NumberField,
StringField,
)
__all__ = [
# Core Exporter
"Exporter",
# Schemas
"ExportSchema",
"ExportField",
"StringField",
"NumberField",
"DateField",
"DateTimeField",
"BooleanField",
"ListField",
"JSONField",
# Formatters
"BaseFormatter",
"CSVFormatter",
"JSONFormatter",
"XLSXFormatter",
# Issue Schema
"IssueExportSchema",
]
@@ -0,0 +1,72 @@
from typing import Any, Dict, List, Type, Union
from django.db.models import QuerySet
from .formatters import CSVFormatter, JSONFormatter, XLSXFormatter
class Exporter:
"""Generic exporter class that handles data exports using different formatters."""
# Available formatters
FORMATTERS = {
"csv": CSVFormatter,
"json": JSONFormatter,
"xlsx": XLSXFormatter,
}
def __init__(self, format_type: str, schema_class: Type, options: Dict[str, Any] = None):
"""Initialize exporter with specified format type and schema.
Args:
format_type: The export format (csv, json, xlsx)
schema_class: The schema class to use for field definitions
options: Optional formatting options
"""
if format_type not in self.FORMATTERS:
raise ValueError(f"Unsupported format: {format_type}. Available: {list(self.FORMATTERS.keys())}")
self.format_type = format_type
self.schema_class = schema_class
self.formatter = self.FORMATTERS[format_type]()
self.options = options or {}
def export(
self,
filename: str,
data: Union[QuerySet, List[dict]],
fields: List[str] = None,
) -> tuple[str, str | bytes]:
"""Export data using the configured formatter and return (filename, content).
Args:
filename: The filename for the export (without extension)
data: Either a Django QuerySet or a list of already-serialized dicts
fields: Optional list of field names to include in export
Returns:
Tuple of (filename_with_extension, content)
"""
# Serialize the queryset if needed
if isinstance(data, QuerySet):
records = self.schema_class.serialize_queryset(data, fields=fields)
else:
# Already serialized data
records = data
# Merge fields into options for the formatter
format_options = {**self.options}
if fields:
format_options["fields"] = fields
return self.formatter.format(filename, records, self.schema_class, format_options)
@classmethod
def get_available_formats(cls) -> List[str]:
"""Get list of available export formats."""
return list(cls.FORMATTERS.keys())
@classmethod
def register_formatter(cls, format_type: str, formatter_class: type) -> None:
"""Register a new formatter for a format type."""
cls.FORMATTERS[format_type] = formatter_class
@@ -0,0 +1,199 @@
import csv
import io
import json
from typing import Any, Dict, List, Type
from openpyxl import Workbook
class BaseFormatter:
"""Base class for export formatters."""
def format(
self,
filename: str,
records: List[dict],
schema_class: Type,
options: Dict[str, Any] | None = None,
) -> tuple[str, str | bytes]:
"""Format records for export.
Args:
filename: The filename for the export (without extension)
records: List of records to export
schema_class: Schema class to extract field order and labels
options: Optional formatting options
Returns:
Tuple of (filename_with_extension, content)
"""
raise NotImplementedError
@staticmethod
def _get_field_info(schema_class: Type) -> tuple[List[str], Dict[str, str]]:
"""Extract field order and labels from schema.
Args:
schema_class: Schema class with field definitions
Returns:
Tuple of (field_order, field_labels)
"""
if not hasattr(schema_class, "_declared_fields"):
raise ValueError(f"Schema class {schema_class.__name__} must have _declared_fields attribute")
# Get order and labels from schema
field_order = list(schema_class._declared_fields.keys())
field_labels = {
name: field.label if field.label else name.replace("_", " ").title()
for name, field in schema_class._declared_fields.items()
}
return field_order, field_labels
class CSVFormatter(BaseFormatter):
"""Formatter for CSV exports."""
@staticmethod
def _format_field_value(value: Any, list_joiner: str = ", ") -> str:
"""Format a field value for CSV output."""
if value is None:
return ""
if isinstance(value, list):
return list_joiner.join(str(v) for v in value)
if isinstance(value, dict):
# For complex objects, serialize as JSON
return json.dumps(value)
return str(value)
def _generate_table_row(
self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None
) -> List[str]:
"""Generate a CSV row from a record."""
opts = options or {}
list_joiner = opts.get("list_joiner", ", ")
return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order]
def _create_csv_file(self, data: List[List[str]]) -> str:
"""Create CSV file content from row data."""
buf = io.StringIO()
writer = csv.writer(buf, delimiter=",", quoting=csv.QUOTE_ALL)
for row in data:
writer.writerow(row)
buf.seek(0)
return buf.getvalue()
def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]:
if not records:
return (f"{filename}.csv", "")
# Get field order and labels from schema
field_order, field_labels = self._get_field_info(schema_class)
# Filter to requested fields if specified
opts = options or {}
requested_fields = opts.get("fields")
if requested_fields:
field_order = [f for f in field_order if f in requested_fields]
header = [field_labels[field] for field in field_order]
rows = [header]
for record in records:
row = self._generate_table_row(record, field_order, options)
rows.append(row)
content = self._create_csv_file(rows)
return (f"{filename}.csv", content)
class JSONFormatter(BaseFormatter):
"""Formatter for JSON exports."""
def _generate_json_row(
self, record: dict, field_labels: Dict[str, str], field_order: List[str], options: Dict[str, Any] | None = None
) -> dict:
"""Generate a JSON object from a record.
Preserves data types - lists stay as arrays, dicts stay as objects.
"""
return {field_labels[field]: record.get(field) for field in field_order if field in record}
def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]:
if not records:
return (f"{filename}.json", "[]")
# Get field order and labels from schema
field_order, field_labels = self._get_field_info(schema_class)
# Filter to requested fields if specified
opts = options or {}
requested_fields = opts.get("fields")
if requested_fields:
field_order = [f for f in field_order if f in requested_fields]
rows: List[dict] = []
for record in records:
row = self._generate_json_row(record, field_labels, field_order, options)
rows.append(row)
content = json.dumps(rows)
return (f"{filename}.json", content)
class XLSXFormatter(BaseFormatter):
"""Formatter for XLSX (Excel) exports."""
@staticmethod
def _format_field_value(value: Any, list_joiner: str = ", ") -> str:
"""Format a field value for XLSX output."""
if value is None:
return ""
if isinstance(value, list):
return list_joiner.join(str(v) for v in value)
if isinstance(value, dict):
# For complex objects, serialize as JSON
return json.dumps(value)
return str(value)
def _generate_table_row(
self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None
) -> List[str]:
"""Generate an XLSX row from a record."""
opts = options or {}
list_joiner = opts.get("list_joiner", ", ")
return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order]
def _create_xlsx_file(self, data: List[List[str]]) -> bytes:
"""Create XLSX file content from row data."""
wb = Workbook()
sh = wb.active
for row in data:
sh.append(row)
out = io.BytesIO()
wb.save(out)
out.seek(0)
return out.getvalue()
def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, bytes]:
if not records:
# Create empty workbook
content = self._create_xlsx_file([])
return (f"{filename}.xlsx", content)
# Get field order and labels from schema
field_order, field_labels = self._get_field_info(schema_class)
# Filter to requested fields if specified
opts = options or {}
requested_fields = opts.get("fields")
if requested_fields:
field_order = [f for f in field_order if f in requested_fields]
header = [field_labels[field] for field in field_order]
rows = [header]
for record in records:
row = self._generate_table_row(record, field_order, options)
rows.append(row)
content = self._create_xlsx_file(rows)
return (f"{filename}.xlsx", content)
@@ -0,0 +1,30 @@
"""Export schemas for various data types."""
from .base import (
BooleanField,
DateField,
DateTimeField,
ExportField,
ExportSchema,
JSONField,
ListField,
NumberField,
StringField,
)
from .issue import IssueExportSchema
__all__ = [
# Base field types
"ExportField",
"StringField",
"NumberField",
"DateField",
"DateTimeField",
"BooleanField",
"ListField",
"JSONField",
# Base schema
"ExportSchema",
# Issue schema
"IssueExportSchema",
]
@@ -0,0 +1,234 @@
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from django.db.models import QuerySet
@dataclass
class ExportField:
"""Base export field class for generic fields."""
source: Optional[str] = None
default: Any = ""
label: Optional[str] = None # Display name for export headers
def get_value(self, obj: Any, context: Dict[str, Any]) -> Any:
raw: Any
if self.source:
raw = self._resolve_dotted_path(obj, self.source)
else:
raw = obj
return self._format_value(raw)
def _format_value(self, raw: Any) -> Any:
"""Format the raw value. Override in subclasses for type-specific formatting."""
return raw if raw is not None else self.default
def _resolve_dotted_path(self, obj: Any, path: str) -> Any:
current = obj
for part in path.split("."):
if current is None:
return None
if hasattr(current, part):
current = getattr(current, part)
elif isinstance(current, dict):
current = current.get(part)
else:
return None
return current
@dataclass
class StringField(ExportField):
"""Export field for string values."""
default: str = ""
def _format_value(self, raw: Any) -> str:
if raw is None:
return self.default
return str(raw)
@dataclass
class DateField(ExportField):
"""Export field for date values with automatic conversion."""
default: str = ""
def _format_value(self, raw: Any) -> str:
if raw is None:
return self.default
# Convert date to formatted string
if hasattr(raw, "strftime"):
return raw.strftime("%a, %d %b %Y")
return str(raw)
@dataclass
class DateTimeField(ExportField):
"""Export field for datetime values with automatic conversion."""
default: str = ""
def _format_value(self, raw: Any) -> str:
if raw is None:
return self.default
# Convert datetime to formatted string
if hasattr(raw, "strftime"):
return raw.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
return str(raw)
@dataclass
class NumberField(ExportField):
"""Export field for numeric values."""
default: Any = ""
def _format_value(self, raw: Any) -> Any:
if raw is None:
return self.default
return raw
@dataclass
class BooleanField(ExportField):
"""Export field for boolean values."""
default: bool = False
def _format_value(self, raw: Any) -> bool:
if raw is None:
return self.default
return bool(raw)
@dataclass
class ListField(ExportField):
"""Export field for list/array values.
Returns the list as-is by default. The formatter will handle conversion to strings
when needed (e.g., CSV/XLSX will join with separator, JSON will keep as array).
"""
default: Optional[List] = field(default_factory=list)
def _format_value(self, raw: Any) -> List[Any]:
if raw is None:
return self.default if self.default is not None else []
if isinstance(raw, (list, tuple)):
return list(raw)
return [raw] # Wrap single items in a list
@dataclass
class JSONField(ExportField):
"""Export field for complex JSON-serializable values (dicts, lists of dicts, etc).
Preserves the structure as-is for JSON exports. For CSV/XLSX, the formatter
will handle serialization (e.g., JSON stringify).
"""
default: Any = field(default_factory=dict)
def _format_value(self, raw: Any) -> Any:
if raw is None:
return self.default
# Return as-is - should be JSON-serializable
return raw
class ExportSchemaMeta(type):
def __new__(mcls, name, bases, attrs):
declared: Dict[str, ExportField] = {
key: value for key, value in list(attrs.items()) if isinstance(value, ExportField)
}
for key in declared.keys():
attrs.pop(key)
cls = super().__new__(mcls, name, bases, attrs)
base_fields: Dict[str, ExportField] = {}
for base in bases:
if hasattr(base, "_declared_fields"):
base_fields.update(base._declared_fields)
base_fields.update(declared)
cls._declared_fields = base_fields
return cls
class ExportSchema(metaclass=ExportSchemaMeta):
"""Base schema for exporting data in various formats.
Subclasses should define fields as class attributes and can override:
- prepare_<field_name> methods for custom field serialization
- get_context_data() class method to pre-fetch related data for the queryset
"""
def __init__(self, context: Optional[Dict[str, Any]] = None) -> None:
self.context = context or {}
def serialize(self, obj: Any, fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Serialize a single object.
Args:
obj: The object to serialize
fields: Optional list of field names to include. If None, all fields are serialized.
Returns:
Dictionary of serialized data
"""
output: Dict[str, Any] = {}
# Determine which fields to process
fields_to_process = fields if fields else list(self._declared_fields.keys())
for field_name in fields_to_process:
# Skip if field doesn't exist in schema
if field_name not in self._declared_fields:
continue
export_field = self._declared_fields[field_name]
# Prefer explicit preparer methods if present
preparer = getattr(self, f"prepare_{field_name}", None)
if callable(preparer):
output[field_name] = preparer(obj)
continue
output[field_name] = export_field.get_value(obj, self.context)
return output
@classmethod
def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]:
"""Get context data for serialization. Override in subclasses to pre-fetch related data.
Args:
queryset: QuerySet of objects to be serialized
Returns:
Dictionary of context data to be passed to the schema instance
"""
return {}
@classmethod
def serialize_queryset(cls, queryset: QuerySet, fields: List[str] = None) -> List[Dict[str, Any]]:
"""Serialize a queryset of objects to export data.
Args:
queryset: QuerySet of objects to serialize
fields: Optional list of field names to include. Defaults to all fields.
Returns:
List of dictionaries containing serialized data
"""
# Get context data (can be extended by subclasses)
context = cls.get_context_data(queryset)
# Serialize each object, passing fields to only process requested fields
schema = cls(context=context)
data = []
for obj in queryset:
obj_data = schema.serialize(obj, fields=fields)
data.append(obj_data)
return data
@@ -0,0 +1,210 @@
from collections import defaultdict
from typing import Any, Dict, List, Optional
from django.db.models import F, QuerySet
from plane.db.models import CycleIssue, FileAsset
from .base import (
DateField,
DateTimeField,
ExportSchema,
JSONField,
ListField,
NumberField,
StringField,
)
def get_issue_attachments_dict(issues_queryset: QuerySet) -> Dict[str, List[str]]:
"""Get attachments dictionary for the given issues queryset.
Args:
issues_queryset: Queryset of Issue objects
Returns:
Dictionary mapping issue IDs to lists of attachment IDs
"""
file_assets = FileAsset.objects.filter(
issue_id__in=issues_queryset.values_list("id", flat=True),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
).annotate(work_item_id=F("issue_id"), asset_id=F("id"))
attachment_dict = defaultdict(list)
for asset in file_assets:
attachment_dict[asset.work_item_id].append(asset.asset_id)
return attachment_dict
def get_issue_last_cycles_dict(issues_queryset: QuerySet) -> Dict[str, Optional[CycleIssue]]:
"""Get the last cycle for each issue in the given queryset.
Args:
issues_queryset: Queryset of Issue objects
Returns:
Dictionary mapping issue IDs to their last CycleIssue object
"""
# Fetch all cycle issues for the given issues, ordered by created_at descending
# select_related is used to fetch cycle data in the same query
cycle_issues = (
CycleIssue.objects.filter(issue_id__in=issues_queryset.values_list("id", flat=True))
.select_related("cycle")
.order_by("issue_id", "-created_at")
)
# Keep only the last (most recent) cycle for each issue
last_cycles_dict = {}
for cycle_issue in cycle_issues:
if cycle_issue.issue_id not in last_cycles_dict:
last_cycles_dict[cycle_issue.issue_id] = cycle_issue
return last_cycles_dict
class IssueExportSchema(ExportSchema):
"""Schema for exporting issue data in various formats."""
@staticmethod
def _get_created_by(obj) -> str:
"""Get the created by user for the given object."""
try:
if getattr(obj, "created_by", None):
return f"{obj.created_by.first_name} {obj.created_by.last_name}"
except Exception:
pass
return ""
@staticmethod
def _format_date(date_obj) -> str:
"""Format date object to string."""
if date_obj and hasattr(date_obj, "strftime"):
return date_obj.strftime("%a, %d %b %Y")
return ""
# Field definitions with display labels
id = StringField(label="ID")
project_identifier = StringField(source="project.identifier", label="Project Identifier")
project_name = StringField(source="project.name", label="Project")
project_id = StringField(source="project.id", label="Project ID")
sequence_id = NumberField(source="sequence_id", label="Sequence ID")
name = StringField(source="name", label="Name")
description = StringField(source="description_stripped", label="Description")
priority = StringField(source="priority", label="Priority")
start_date = DateField(source="start_date", label="Start Date")
target_date = DateField(source="target_date", label="Target Date")
state_name = StringField(label="State")
created_at = DateTimeField(source="created_at", label="Created At")
updated_at = DateTimeField(source="updated_at", label="Updated At")
completed_at = DateTimeField(source="completed_at", label="Completed At")
archived_at = DateTimeField(source="archived_at", label="Archived At")
module_name = ListField(label="Module Name")
created_by = StringField(label="Created By")
labels = ListField(label="Labels")
comments = JSONField(label="Comments")
estimate = StringField(label="Estimate")
link = ListField(label="Link")
assignees = ListField(label="Assignees")
subscribers_count = NumberField(label="Subscribers Count")
attachment_count = NumberField(label="Attachment Count")
attachment_links = ListField(label="Attachment Links")
cycle_name = StringField(label="Cycle Name")
cycle_start_date = DateField(label="Cycle Start Date")
cycle_end_date = DateField(label="Cycle End Date")
parent = StringField(label="Parent")
relations = JSONField(label="Relations")
def prepare_id(self, i):
return f"{i.project.identifier}-{i.sequence_id}"
def prepare_state_name(self, i):
return i.state.name if i.state else None
def prepare_module_name(self, i):
return [m.module.name for m in i.issue_module.all()]
def prepare_created_by(self, i):
return self._get_created_by(i)
def prepare_labels(self, i):
return [label.name for label in i.labels.all()]
def prepare_comments(self, i):
return [
{
"comment": comment.comment_stripped,
"created_at": self._format_date(comment.created_at),
"created_by": self._get_created_by(comment),
}
for comment in i.issue_comments.all()
]
def prepare_estimate(self, i):
return i.estimate_point.value if i.estimate_point and i.estimate_point.value else ""
def prepare_link(self, i):
return [link.url for link in i.issue_link.all()]
def prepare_assignees(self, i):
return [f"{u.first_name} {u.last_name}" for u in i.assignees.all()]
def prepare_subscribers_count(self, i):
return i.issue_subscribers.count()
def prepare_attachment_count(self, i):
return len((self.context.get("attachments_dict") or {}).get(i.id, []))
def prepare_attachment_links(self, i):
return [
f"/api/assets/v2/workspaces/{i.workspace.slug}/projects/{i.project_id}/issues/{i.id}/attachments/{asset}/"
for asset in (self.context.get("attachments_dict") or {}).get(i.id, [])
]
def prepare_cycle_name(self, i):
cycles_dict = self.context.get("cycles_dict") or {}
last_cycle = cycles_dict.get(i.id)
return last_cycle.cycle.name if last_cycle else ""
def prepare_cycle_start_date(self, i):
cycles_dict = self.context.get("cycles_dict") or {}
last_cycle = cycles_dict.get(i.id)
if last_cycle and last_cycle.cycle.start_date:
return self._format_date(last_cycle.cycle.start_date)
return ""
def prepare_cycle_end_date(self, i):
cycles_dict = self.context.get("cycles_dict") or {}
last_cycle = cycles_dict.get(i.id)
if last_cycle and last_cycle.cycle.end_date:
return self._format_date(last_cycle.cycle.end_date)
return ""
def prepare_parent(self, i):
if not i.parent:
return ""
return f"{i.parent.project.identifier}-{i.parent.sequence_id}"
def prepare_relations(self, i):
# Should show reverse relation as well
from plane.db.models.issue import IssueRelationChoices
relations = {
r.relation_type: f"{r.related_issue.project.identifier}-{r.related_issue.sequence_id}"
for r in i.issue_relation.all()
}
reverse_relations = {}
for relation in i.issue_related.all():
reverse_relations[IssueRelationChoices._REVERSE_MAPPING[relation.relation_type]] = (
f"{relation.issue.project.identifier}-{relation.issue.sequence_id}"
)
relations.update(reverse_relations)
return relations
@classmethod
def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]:
"""Get context data for issue serialization."""
return {
"attachments_dict": get_issue_attachments_dict(queryset),
"cycles_dict": get_issue_last_cycles_dict(queryset),
}
@@ -159,6 +159,7 @@ class IssueFilterSet(BaseFilterSet):
"start_date": ["exact", "range"],
"target_date": ["exact", "range"],
"created_at": ["exact", "range"],
"updated_at": ["exact", "range"],
"is_draft": ["exact"],
"priority": ["exact", "in"],
}
+10 -12
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.0.0",
"version": "1.1.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./dist/start.js",
@@ -20,14 +20,17 @@
"author": "Plane Software Inc.",
"dependencies": {
"@dotenvx/dotenvx": "^1.49.0",
"@hocuspocus/extension-database": "^3.0.0",
"@hocuspocus/extension-logger": "^3.0.0",
"@hocuspocus/extension-redis": "^3.0.0",
"@hocuspocus/server": "^3.0.0",
"@hocuspocus/extension-database": "2.15.2",
"@hocuspocus/extension-logger": "2.15.2",
"@hocuspocus/extension-redis": "2.15.2",
"@hocuspocus/server": "2.15.2",
"@hocuspocus/transformer": "2.15.2",
"@plane/decorators": "workspace:*",
"@plane/editor": "workspace:*",
"@plane/logger": "workspace:*",
"@plane/types": "workspace:*",
"@sentry/node": "catalog:",
"@sentry/profiling-node": "catalog:",
"@tiptap/core": "catalog:",
"@tiptap/html": "catalog:",
"axios": "catalog:",
@@ -37,10 +40,7 @@
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
"morgan": "1.10.1",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"ioredis": "5.7.0",
"uuid": "catalog:",
"ws": "^8.18.3",
"y-prosemirror": "^1.3.7",
@@ -55,9 +55,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.23",
"@types/express-ws": "^3.0.5",
"@types/node": "^20.14.9",
"@types/pino-http": "^5.8.4",
"@types/uuid": "^9.0.1",
"@types/node": "catalog:",
"@types/ws": "^8.18.1",
"tsdown": "catalog:",
"typescript": "catalog:"
@@ -22,11 +22,11 @@ export class CollaborationController {
// Set up error handling for the connection
ws.on("error", (error: Error) => {
logger.error("WebSocket connection error:", error);
logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error);
ws.close(1011, "Internal server error");
});
} catch (error) {
logger.error("WebSocket connection error:", error);
logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error);
ws.close(1011, "Internal server error");
}
}
@@ -1,37 +0,0 @@
import type { Request, Response } from "express";
// plane imports
import { Controller, Post } from "@plane/decorators";
import { logger } from "@plane/logger";
// types
import type { TConvertDocumentRequestBody } from "@/types";
// utils
import { convertHTMLDocumentToAllFormats } from "@/utils";
@Controller("/convert-document")
export class ConvertDocumentController {
@Post("/")
handleConvertDocument(req: Request, res: Response) {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (typeof description_html !== "string" || variant === undefined) {
res.status(400).json({
message: "Missing required fields",
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
logger.error("Error in /convert-document endpoint:", error);
res.status(500).json({
message: `Internal server error.`,
});
}
}
}
@@ -0,0 +1,63 @@
import type { Request, Response } from "express";
import { z } from "zod";
// helpers
import { Controller, Post } from "@plane/decorators";
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
// logger
import { logger } from "@plane/logger";
import { type TConvertDocumentRequestBody } from "@/types";
// Define the schema with more robust validation
const convertDocumentSchema = z.object({
description_html: z
.string()
.min(1, "HTML content cannot be empty")
.refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace")
.refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"),
variant: z.enum(["rich", "document"]),
});
@Controller("/convert-document")
export class DocumentController {
@Post("/")
async convertDocument(req: Request, res: Response) {
try {
// Validate request body
const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody);
const { description_html, variant } = validatedData;
// Process document conversion
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
// Return successful response
res.status(200).json({
description,
description_binary,
});
} catch (error) {
if (error instanceof z.ZodError) {
const validationErrors = error.errors.map((err) => ({
path: err.path.join("."),
message: err.message,
}));
logger.error("DOCUMENT_CONTROLLER: Validation error", {
validationErrors,
});
return res.status(400).json({
message: `Validation error`,
context: {
validationErrors,
},
});
} else {
logger.error("DOCUMENT_CONTROLLER: Internal server error", error);
return res.status(500).json({
message: `Internal server error.`,
});
}
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { CollaborationController } from "./collaboration.controller";
import { ConvertDocumentController } from "./convert-document.controller";
import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller";
export const CONTROLLERS = [CollaborationController, ConvertDocumentController, HealthController];
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController];
+60 -17
View File
@@ -6,22 +6,17 @@ import {
} from "@plane/editor";
// logger
import { logger } from "@plane/logger";
// lib
import { AppError } from "@/lib/errors";
// services
import { getPageService } from "@/services/page/handler";
// type
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
import { ForceCloseReason, CloseCode } from "@/types/admin-commands";
import { broadcastError } from "@/utils/broadcast-error";
// force close utility
import { forceCloseDocumentAcrossServers } from "./force-close-handler";
const normalizeToError = (error: unknown, fallbackMessage: string) => {
if (error instanceof Error) {
return error;
}
const message = typeof error === "string" && error.trim().length > 0 ? error : fallbackMessage;
return new Error(message);
};
const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWithContext) => {
const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => {
try {
const service = getPageService(context.documentType, context);
// fetch details
@@ -38,12 +33,22 @@ const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWith
// return binary data
return binaryData;
} catch (error) {
logger.error("Error in fetching document", error);
throw normalizeToError(error, `Failed to fetch document: ${pageId}`);
const appError = new AppError(error, { context: { pageId } });
logger.error("Error in fetching document", appError);
// Broadcast error to frontend for user document types
await broadcastError(instance, pageId, "Unable to load the page. Please try refreshing.", "fetch", context);
throw appError;
}
};
const storeDocument = async ({ context, state: pageBinaryData, documentName: pageId }: StorePayloadWithContext) => {
const storeDocument = async ({
context,
state: pageBinaryData,
documentName: pageId,
instance,
}: StorePayloadWithContext) => {
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
@@ -57,8 +62,46 @@ const storeDocument = async ({ context, state: pageBinaryData, documentName: pag
};
await service.updateDescriptionBinary(pageId, payload);
} catch (error) {
logger.error("Error in updating document:", error);
throw normalizeToError(error, `Failed to update document: ${pageId}`);
const appError = new AppError(error, { context: { pageId } });
logger.error("Error in updating document:", appError);
// Check error types
const isContentTooLarge = appError.statusCode === 413;
// Determine if we should disconnect and unload
const shouldDisconnect = isContentTooLarge;
// Determine error message and code
let errorMessage: string;
let errorCode: "content_too_large" | "page_locked" | "page_archived" | undefined;
if (isContentTooLarge) {
errorMessage = "Document is too large to save. Please reduce the content size.";
errorCode = "content_too_large";
} else {
errorMessage = "Unable to save the page. Please try again.";
}
// Broadcast error to frontend for user document types
await broadcastError(instance, pageId, errorMessage, "store", context, errorCode, shouldDisconnect);
// If we should disconnect, close connections and unload document
if (shouldDisconnect) {
// Map error code to ForceCloseReason with proper types
const reason =
errorCode === "content_too_large" ? ForceCloseReason.DOCUMENT_TOO_LARGE : ForceCloseReason.CRITICAL_ERROR;
const closeCode = errorCode === "content_too_large" ? CloseCode.DOCUMENT_TOO_LARGE : CloseCode.FORCE_CLOSE;
// force close connections and unload document
await forceCloseDocumentAcrossServers(instance, pageId, reason, closeCode);
// Don't throw after force close - document is already unloaded
// Throwing would cause hocuspocus's finally block to access the null document
return;
}
throw appError;
}
};
@@ -0,0 +1,203 @@
import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server";
import { logger } from "@plane/logger";
import { Redis } from "@/extensions/redis";
import {
AdminCommand,
CloseCode,
ForceCloseReason,
getForceCloseMessage,
isForceCloseCommand,
type ClientForceCloseMessage,
type ForceCloseCommandData,
} from "@/types/admin-commands";
/**
* Extension to handle force close commands from other servers via Redis admin channel
*/
export class ForceCloseHandler implements Extension {
name = "ForceCloseHandler";
priority = 999;
async onConfigure({ instance }: onConfigurePayload) {
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
if (!redisExt) {
logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found");
return;
}
// Register handler for force_close admin command
redisExt.onAdminCommand<ForceCloseCommandData>(AdminCommand.FORCE_CLOSE, async (data) => {
// Type guard for safety
if (!isForceCloseCommand(data)) {
logger.error("[FORCE_CLOSE_HANDLER] Received invalid force close command");
return;
}
const { docId, reason, code } = data;
const document = instance.documents.get(docId);
if (!document) {
// Not our document, ignore
return;
}
const connectionCount = document.getConnectionsCount();
logger.info(`[FORCE_CLOSE_HANDLER] Sending force close message to ${connectionCount} clients...`);
// Step 1: Send force close message to ALL clients first
const forceCloseMessage: ClientForceCloseMessage = {
type: "force_close",
reason,
code,
message: getForceCloseMessage(reason),
timestamp: new Date().toISOString(),
};
let messageSent = 0;
document.connections.forEach(({ connection }: { connection: Connection }) => {
try {
connection.sendStateless(JSON.stringify(forceCloseMessage));
messageSent++;
} catch (error) {
logger.error("[FORCE_CLOSE_HANDLER] Failed to send message:", error);
}
});
logger.info(`[FORCE_CLOSE_HANDLER] Sent force close message to ${messageSent}/${connectionCount} clients`);
// Wait a moment for messages to be delivered
await new Promise((resolve) => setTimeout(resolve, 50));
// Step 2: Close connections
logger.info(`[FORCE_CLOSE_HANDLER] Closing ${connectionCount} connections...`);
let closed = 0;
document.connections.forEach(({ connection }: { connection: Connection }) => {
try {
connection.close({ code, reason });
closed++;
} catch (error) {
logger.error("[FORCE_CLOSE_HANDLER] Failed to close connection:", error);
}
});
logger.info(`[FORCE_CLOSE_HANDLER] Closed ${closed}/${connectionCount} connections for ${docId}`);
});
logger.info("[FORCE_CLOSE_HANDLER] Registered with Redis extension");
}
}
/**
* Force close all connections to a document across all servers and unload it from memory.
* Used for critical errors or admin operations.
*
* @param instance - The Hocuspocus server instance
* @param pageId - The document ID to force close
* @param reason - The reason for force closing
* @param code - Optional WebSocket close code (defaults to FORCE_CLOSE)
* @returns Promise that resolves when document is closed and unloaded
* @throws Error if document not found in memory
*/
export const forceCloseDocumentAcrossServers = async (
instance: Hocuspocus,
pageId: string,
reason: ForceCloseReason,
code: CloseCode = CloseCode.FORCE_CLOSE
): Promise<void> => {
// STEP 1: VERIFY DOCUMENT EXISTS
const document = instance.documents.get(pageId);
if (!document) {
logger.info(`[FORCE_CLOSE] Document ${pageId} already unloaded - no action needed`);
return; // Document already cleaned up, nothing to do
}
const connectionsBefore = document.getConnectionsCount();
logger.info(`[FORCE_CLOSE] Sending force close message to ${connectionsBefore} local clients...`);
const forceCloseMessage: ClientForceCloseMessage = {
type: "force_close",
reason,
code,
message: getForceCloseMessage(reason),
timestamp: new Date().toISOString(),
};
let messageSentCount = 0;
document.connections.forEach(({ connection }: { connection: Connection }) => {
try {
connection.sendStateless(JSON.stringify(forceCloseMessage));
messageSentCount++;
} catch (error) {
logger.error("[FORCE_CLOSE] Failed to send message to client:", error);
}
});
logger.info(`[FORCE_CLOSE] Sent force close message to ${messageSentCount}/${connectionsBefore} clients`);
// Wait a moment for messages to be delivered
await new Promise((resolve) => setTimeout(resolve, 50));
// STEP 3: CLOSE LOCAL CONNECTIONS
logger.info(`[FORCE_CLOSE] Closing ${connectionsBefore} local connections...`);
let closedCount = 0;
document.connections.forEach(({ connection }: { connection: Connection }) => {
try {
connection.close({ code, reason });
closedCount++;
} catch (error) {
logger.error("[FORCE_CLOSE] Failed to close local connection:", error);
}
});
logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`);
// STEP 4: BROADCAST TO OTHER SERVERS
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
if (redisExt) {
const commandData: ForceCloseCommandData = {
command: AdminCommand.FORCE_CLOSE,
docId: pageId,
reason,
code,
originServer: instance.configuration.name || "unknown",
timestamp: new Date().toISOString(),
};
const receivers = await redisExt.publishAdminCommand(commandData);
logger.info(`[FORCE_CLOSE] Notified ${receivers} other server(s)`);
} else {
logger.warn("[FORCE_CLOSE] Redis extension not found, cannot notify other servers");
}
// STEP 5: WAIT FOR OTHER SERVERS
const waitTime = 800;
logger.info(`[FORCE_CLOSE] Waiting ${waitTime}ms for other servers to close connections...`);
await new Promise((resolve) => setTimeout(resolve, waitTime));
// STEP 6: UNLOAD DOCUMENT after closing all the connections
logger.info(`[FORCE_CLOSE] Unloading document from memory...`);
try {
await instance.unloadDocument(document);
logger.info(`[FORCE_CLOSE] Document unloaded successfully ✅`);
} catch (unloadError: unknown) {
logger.error("[FORCE_CLOSE] UNLOAD FAILED:", unloadError);
logger.error(` Error: ${unloadError instanceof Error ? unloadError.message : "unknown"}`);
}
// STEP 7: VERIFY UNLOAD
const documentAfterUnload = instance.documents.get(pageId);
if (documentAfterUnload) {
logger.error(
`❌ [FORCE_CLOSE] Document still in memory!, Document ID: ${pageId}, Connections: ${documentAfterUnload.getConnectionsCount()}`
);
} else {
logger.info(`✅ [FORCE_CLOSE] COMPLETE, Document: ${pageId}, Status: Successfully closed and unloaded`);
}
};
+114 -10
View File
@@ -1,30 +1,134 @@
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
import { OutgoingMessage } from "@hocuspocus/server";
// redis
import { OutgoingMessage, type onConfigurePayload } from "@hocuspocus/server";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
import { redisManager } from "@/redis";
import { AdminCommand } from "@/types/admin-commands";
import type { AdminCommandData, AdminCommandHandler } from "@/types/admin-commands";
const getRedisClient = () => {
const redisClient = redisManager.getClient();
if (!redisClient) {
throw new Error("Redis client not initialized");
throw new AppError("Redis client not initialized");
}
return redisClient;
};
export class Redis extends HocuspocusRedis {
private adminHandlers = new Map<AdminCommand, AdminCommandHandler>();
private readonly ADMIN_CHANNEL = "hocuspocus:admin";
constructor() {
super({ redis: getRedisClient() });
}
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
async onConfigure(payload: onConfigurePayload) {
await super.onConfigure(payload);
// Subscribe to admin channel
await new Promise<void>((resolve, reject) => {
this.sub.subscribe(this.ADMIN_CHANNEL, (error: Error) => {
if (error) {
logger.error(`[Redis] Failed to subscribe to admin channel:`, error);
reject(error);
} else {
logger.info(`[Redis] Subscribed to admin channel: ${this.ADMIN_CHANNEL}`);
resolve();
}
});
});
// Listen for admin messages
this.sub.on("message", this.handleAdminMessage);
logger.info(`[Redis] Attached admin message listener`);
}
private handleAdminMessage = async (channel: string, message: string) => {
if (channel !== this.ADMIN_CHANNEL) return;
try {
const data = JSON.parse(message) as AdminCommandData;
// Validate command
if (!data.command || !Object.values(AdminCommand).includes(data.command as AdminCommand)) {
logger.warn(`[Redis] Invalid admin command received: ${data.command}`);
return;
}
const handler = this.adminHandlers.get(data.command);
if (handler) {
await handler(data);
} else {
logger.warn(`[Redis] No handler registered for admin command: ${data.command}`);
}
} catch (error) {
logger.error("[Redis] Error handling admin message:", error);
}
};
/**
* Register handler for an admin command
*/
public onAdminCommand<T extends AdminCommandData = AdminCommandData>(
command: AdminCommand,
handler: AdminCommandHandler<T>
) {
this.adminHandlers.set(command, handler as AdminCommandHandler);
logger.info(`[Redis] Registered admin command: ${command}`);
}
/**
* Publish admin command to global channel
*/
public async publishAdminCommand<T extends AdminCommandData>(data: T): Promise<number> {
// Validate command data
if (!data.command || !Object.values(AdminCommand).includes(data.command)) {
throw new AppError(`Invalid admin command: ${data.command}`);
}
const message = JSON.stringify(data);
const receivers = await this.pub.publish(this.ADMIN_CHANNEL, message);
logger.info(`[Redis] Published "${data.command}" command, received by ${receivers} server(s)`);
return receivers;
}
async onDestroy() {
// Unsubscribe from admin channel
await new Promise<void>((resolve) => {
this.sub.unsubscribe(this.ADMIN_CHANNEL, (error: Error) => {
if (error) {
logger.error(`[Redis] Error unsubscribing from admin channel:`, error);
}
resolve();
});
});
// Remove the message listener to prevent memory leaks
this.sub.removeListener("message", this.handleAdminMessage);
logger.info(`[Redis] Removed admin message listener`);
await super.onDestroy();
}
/**
* Broadcast a message to a document across all servers via Redis.
* Uses empty identifier so ALL servers process the message.
*/
public async broadcastToDocument(documentName: string, payload: unknown): Promise<number> {
const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload);
const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload);
return this.pub.publish(
// we're accessing the private method of the hocuspocus redis extension
this["pubKey"](documentName),
// we're accessing the private method of the hocuspocus redis extension to encode the message
this["encodeMessage"](message.toUint8Array())
);
const emptyPrefix = Buffer.concat([Buffer.from([0])]);
const channel = this["pubKey"](documentName);
const encodedMessage = Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())]);
const result = await this.pub.publishBuffer(channel, encodedMessage);
logger.info(`REDIS_EXTENSION: Published to ${documentName}, ${result} subscribers`);
return result;
}
}
+15
View File
@@ -0,0 +1,15 @@
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
export const setupSentry = () => {
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
integrations: [Sentry.httpIntegration(), Sentry.expressIntegration(), nodeProfilingIntegration()],
tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE ? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE) : 0.5,
environment: process.env.SENTRY_ENVIRONMENT || "development",
release: process.env.APP_VERSION || "v1.0.0",
sendDefaultPii: true,
});
}
};
+50
View File
@@ -0,0 +1,50 @@
import type { Request, Response, NextFunction } from "express";
import { logger } from "@plane/logger";
import { env } from "@/env";
/**
* Express middleware to verify secret key authentication for protected endpoints
*
* Checks for secret key in headers:
* - x-admin-secret-key (preferred for admin endpoints)
* - live-server-secret-key (for backward compatibility)
*
* @param req - Express request object
* @param res - Express response object
* @param next - Express next function
*
* @example
* ```typescript
* import { Middleware } from "@plane/decorators";
* import { requireSecretKey } from "@/lib/auth-middleware";
*
* @Get("/protected")
* @Middleware(requireSecretKey)
* async protectedEndpoint(req: Request, res: Response) {
* // This will only execute if secret key is valid
* }
* ```
*/
// TODO - Move to hmac
export const requireSecretKey = (req: Request, res: Response, next: NextFunction): void => {
const secretKey = req.headers["live-server-secret-key"];
if (!secretKey || secretKey !== env.LIVE_SERVER_SECRET_KEY) {
logger.warn(`
⚠️ [AUTH] Unauthorized access attempt
Endpoint: ${req.path}
Method: ${req.method}
IP: ${req.ip}
User-Agent: ${req.headers["user-agent"]}
`);
res.status(401).json({
error: "Unauthorized",
status: 401,
});
return;
}
// Secret key is valid, proceed to the route handler
next();
};
+15 -6
View File
@@ -2,6 +2,7 @@
import type { IncomingHttpHeaders } from "http";
import type { TUserDetails } from "@plane/editor";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
// services
import { UserService } from "@/services/user.service";
// types
@@ -35,8 +36,10 @@ export const onAuthenticate = async ({
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
logger.error("Token parsing failed, using request headers:", error);
const appError = new AppError(error, {
context: { operation: "onAuthenticate" },
});
logger.error("Token parsing failed, using request headers", appError);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
@@ -45,7 +48,9 @@ export const onAuthenticate = async ({
}
if (!cookie || !userId) {
throw new Error("Credentials not provided");
const appError = new AppError("Credentials not provided", { code: "AUTH_MISSING_CREDENTIALS" });
logger.error("Credentials not provided", appError);
throw appError;
}
// set cookie in context, so it can be used throughout the ws connection
@@ -67,7 +72,7 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
const userService = new UserService();
const user = await userService.currentUser(cookie);
if (user.id !== userId) {
throw new Error("Authentication unsuccessful!");
throw new AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" });
}
return {
@@ -76,7 +81,11 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
name: user.display_name,
},
};
} catch (_error) {
throw Error("Authentication unsuccessful!");
} catch (error) {
const appError = new AppError(error, {
context: { operation: "handleAuthentication" },
});
logger.error("Authentication failed", appError);
throw new AppError("Authentication unsuccessful", { code: appError.code });
}
};
+73
View File
@@ -0,0 +1,73 @@
import { AxiosError } from "axios";
/**
* Application error class that sanitizes and standardizes errors across the app.
* Extracts only essential information from AxiosError to prevent massive log bloat
* and sensitive data leaks (cookies, tokens, etc).
*
* Usage:
* new AppError("Simple error message")
* new AppError("Custom error", { code: "MY_CODE", statusCode: 400 })
* new AppError(axiosError) // Auto-extracts essential info
* new AppError(anyError) // Works with any error type
*/
export class AppError extends Error {
statusCode?: number;
method?: string;
url?: string;
code?: string;
context?: Record<string, any>;
constructor(messageOrError: string | unknown, data?: Partial<Omit<AppError, "name" | "message">>) {
// Handle error objects - extract essential info
const error = messageOrError;
// Already AppError - return immediately for performance (no need to re-process)
if (error instanceof AppError) {
return error;
}
// Handle string message (simple case like regular Error)
if (typeof messageOrError === "string") {
super(messageOrError);
this.name = "AppError";
if (data) {
Object.assign(this, data);
}
return;
}
// AxiosError - extract ONLY essential info (no config, no headers, no cookies)
if (error && typeof error === "object" && "isAxiosError" in error) {
const axiosError = error as AxiosError;
const responseData = axiosError.response?.data as any;
super(responseData?.message || axiosError.message);
this.name = "AppError";
this.statusCode = axiosError.response?.status;
this.method = axiosError.config?.method?.toUpperCase();
this.url = axiosError.config?.url;
this.code = axiosError.code;
return;
}
// DOMException (AbortError from cancelled requests)
if (error instanceof DOMException && error.name === "AbortError") {
super(error.message);
this.name = "AppError";
this.code = "ABORT_ERROR";
return;
}
// Standard Error objects
if (error instanceof Error) {
super(error.message);
this.name = "AppError";
this.code = error.name;
return;
}
// Unknown error types - safe fallback
super("Unknown error occurred");
this.name = "AppError";
}
}

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